diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index 95abdf2f3..3d532ac02 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -1,19 +1,26 @@ //! "VectorCraft Generic CMYK (SWOP-like)": our own parametric press model. //! //! We can't redistribute Adobe's or ECI's characterisation profiles, so the built-in CMYK space is -//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*: the numbers -//! are rounded, typical values for coated web offset, not a measured dataset. +//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*. Two of its +//! figures are published values for SWOP printing: the tone value increase of each ink at 40% and +//! 50% from CGATS TR 001 (), and the 300% +//! total ink limit (). +//! The paper, solid and overprint colors and the black-generation curve are VectorCraft's own values. //! -//! * **Forward (CMYK → XYZ):** each ink's nominal dot area is widened by a parabolic dot-gain -//! curve (`a + 4·g·a·(1−a)`, i.e. `g` gain at 50%); the effective areas mix the eight CMY -//! Neugebauer primaries with Demichel weights; black is mixed in the same way against -//! "primary + K" colours whose optical densities add but saturate towards a maximum density -//! (normalised so K on paper is exact), so rich blacks don't multiply to an unrealistic depth. +//! * **Forward (CMYK → XYZ):** each ink's nominal dot area `a` is widened by a tone curve +//! `a + 4·g·a·(1−a)·(1 + s·(2a−1))` that passes through the TR 001 tone value increase at 50% +//! (`g`) and 40% (which sets `s`). The effective areas mix the 16 Neugebauer primaries (paper and +//! every solid and overprint of C, M, Y and K, media-relative) with Demichel weights, linearly in +//! XYZ, so a single ink's colorimetric tone value increase (Murray–Davies) is the TR 001 value. //! * **Inverse (Lab → CMYK):** damped, bounded Gauss–Newton on the forward model solves CMY for a -//! fixed K. Black generation is GCR: solve CMY with K = 0, take the grey component -//! `min(C, M, Y)`, map it through the black-generation curve to K, re-solve CMY. Under-colour +//! fixed K. Black generation is GCR: solve CMY with K = 0, take the gray component +//! `min(C, M, Y)`, add [`BlackGeneration::shortfall`] for each L* point by which that solve stays +//! lighter than the target, map the sum through the black curve to K, re-solve CMY. Under-color //! removal enforces the total area coverage (TAC) limit by adding K and re-solving, then scaling -//! CMY as a last resort. +//! CMY as a last resort. The black point, which source black separates to and which displays as +//! black with black-point compensation, is [`BlackGeneration::black_point_k`] black with neutral +//! CMY filling the TAC. With compensation, a build darker than it in every XYZ channel also +//! displays as black (see [`GenericCmyk::display_xyz`]). //! * **Rendering intents:** colorimetric intents map the source white to paper (relative) or not //! (absolute) and clip; perceptual applies black-point compensation plus soft chroma compression //! against a gamut boundary descriptor (segment maxima of the forward model); saturation keeps @@ -24,50 +31,82 @@ use std::sync::OnceLock; use super::Intent; use super::lab::{D50, Lab, delta_e2000, lab_to_xyz, srgb_to_lab, xyz_to_lab, xyz_to_srgb}; -/// Black generation strength. +/// Black generation. #[derive(Clone, Copy, Debug, PartialEq)] pub struct BlackGeneration { - /// Grey component below which no black is generated. - pub start: f32, - /// Maximum black. - pub max_k: f32, - /// Curve exponent (>1 = lighter black in the midtones). - pub gamma: f32, + /// The black curve as `(x, K)` knots with rising `x`, read by linear interpolation. `x` is the + /// gray component of the solve without black plus [`Self::shortfall`] per L* point by which + /// that solve stays lighter than the target. + pub curve: [(f32, f32); 13], + /// Added to the gray component for each L* point the black-free solve stays too light. + pub shortfall: f32, + /// Black of the black point, whose CMY is neutral and fills the TAC. + pub black_point_k: f32, } /// Parameters of the parametric press model. #[derive(Clone, Debug)] pub struct GenericCmykParams { pub name: &'static str, + /// Paper color (absolute Lab, D50). pub paper: Lab, - /// Solid overprints, Demichel order: C, M, CM, Y, CY, MY, CMY. - pub solids: [Lab; 7], - pub black: Lab, - /// Dot gain at 50% per ink (C, M, Y, K). - pub dot_gain: [f32; 4], + /// Media-relative Lab of the solids and overprints, Demichel order (index `c | m<<1 | y<<2 | + /// k<<3`, paper left out): C, M, CM, Y, CY, MY, CMY, then K, CK, MK, CMK, YK, CYK, MYK, CMYK. + pub primaries: [Lab; 15], + /// Tone value increase at 50% per ink (C, M, Y, K). + pub tvi50: [f32; 4], + /// Tone value increase at 40% per ink (C, M, Y, K). + pub tvi40: [f32; 4], /// Total area coverage limit (sum of inks, 1.0 = 100%). pub tac: f32, pub bg: BlackGeneration, } -/// Rounded, typical SWOP-like values (not measured data). +/// The built-in SWOP-like press: TR 001 tone value increase, a 300% ink limit, and VectorCraft's +/// own paper, primaries and black curve. pub fn swop_like() -> GenericCmykParams { GenericCmykParams { name: super::GENERIC_CMYK, paper: Lab::new(93.0, -0.5, 2.5), - solids: [ - Lab::new(55.0, -37.0, -50.0), // C - Lab::new(48.0, 74.0, -3.0), // M - Lab::new(24.0, 22.0, -46.0), // CM (blue) - Lab::new(89.0, -5.0, 93.0), // Y - Lab::new(50.0, -65.0, 27.0), // CY (green) - Lab::new(47.0, 68.0, 48.0), // MY (red) - Lab::new(24.0, 1.0, 0.0), // CMY + primaries: [ + Lab::new(65.7, -42.6, -47.2), // C + Lab::new(55.5, 76.2, -9.4), // M + Lab::new(31.1, 20.3, -49.7), // CM (blue) + Lab::new(96.2, -7.0, 90.1), // Y + Lab::new(61.0, -68.4, 30.1), // CY (green) + Lab::new(55.0, 69.8, 45.0), // MY (red) + Lab::new(28.3, 0.7, -0.5), // CMY + Lab::new(21.5, 0.8, -0.9), // K + Lab::new(15.9, -8.4, -10.0), // CK + Lab::new(14.7, 15.4, -1.0), // MK + Lab::new(11.2, 8.9, -6.6), // CMK + Lab::new(20.2, -2.4, 12.0), // YK + Lab::new(15.7, -14.6, 4.6), // CYK + Lab::new(14.5, 11.2, 5.7), // MYK + Lab::new(11.0, -0.7, 0.1), // CMYK ], - black: Lab::new(19.0, 0.5, 0.5), - dot_gain: [0.20, 0.20, 0.20, 0.22], + tvi50: [0.200, 0.176, 0.168, 0.216], + tvi40: [0.200, 0.173, 0.167, 0.219], tac: 3.0, - bg: BlackGeneration { start: 0.1, max_k: 0.95, gamma: 1.25 }, + bg: BlackGeneration { + curve: [ + (0.0, 0.0), + (0.25, 0.0), + (0.35, 0.006), + (0.45, 0.059), + (0.55, 0.139), + (0.65, 0.23), + (0.75, 0.322), + (0.85, 0.41), + (0.95, 0.511), + (1.05, 0.6), + (1.2, 0.733), + (1.35, 0.858), + (1.5, 1.0), + ], + shortfall: 0.03, + black_point_k: 0.9, + }, } } @@ -83,11 +122,12 @@ const GBD_H: usize = 72; // 5° bins pub struct GenericCmyk { pub params: GenericCmykParams, - /// XYZ of the 8 CMY primaries (index = c | m<<1 | y<<2), without and with K. - prim: [[f32; 3]; 8], - prim_k: [[f32; 3]; 8], + /// Media-relative XYZ of the 16 primaries (index = c | m<<1 | y<<2 | k<<3; 0 is paper). + prim: [[f32; 3]; 16], + /// Shape `s` of each ink's tone curve, from its 40% and 50% tone value increase. + skew: [f32; 4], paper_xyz: [f32; 3], - /// Media-relative XYZ of the darkest printable black (for black-point compensation). + /// Media-relative XYZ of the black point (for black-point compensation). black_rel: [f32; 3], gbd: OnceLock, } @@ -95,37 +135,17 @@ pub struct GenericCmyk { impl GenericCmyk { pub fn new(params: GenericCmykParams) -> Self { let paper_xyz = lab_to_xyz(params.paper); - let mut prim = [[0.0f32; 3]; 8]; - prim[0] = paper_xyz; - for (p, lab) in prim[1..].iter_mut().zip(params.solids.iter()) { - *p = lab_to_xyz(*lab); + let mut prim = [D50; 16]; + for (p, lab) in prim[1..].iter_mut().zip(params.primaries.iter()) { + *p = lab_to_xyz(*lab).map(|v| v.max(1e-6)); } - let k_xyz = lab_to_xyz(params.black); - // Black over other inks: optical densities add, but saturate towards a maximum density - // (ink trapping / surface reflection), normalised so that K on paper is exact. - const D_MAX: f32 = 2.0; - let sat = |d: f32| D_MAX * (1.0 - (-d / D_MAX).exp()); - let mut prim_k = [[0.0f32; 3]; 8]; - for i in 0..8 { - for ch in 0..3 { - let di = -(prim[i][ch] / paper_xyz[ch]).max(1e-6).log10(); - let dk = -(k_xyz[ch] / paper_xyz[ch]).max(1e-6).log10(); - let d = sat(di + dk) * dk / sat(dk).max(1e-6); - prim_k[i][ch] = paper_xyz[ch] * 10f32.powf(-d.max(di)); - } - } - let mut g = Self { params, prim, prim_k, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() }; - // Darkest black within TAC: search a few rich blacks. - let mut best = [1.0f32; 3]; - for k in [0.8f32, 0.9, 1.0] { - let rest = (g.params.tac - k).max(0.0) / 3.0; - let cmy = rest.min(1.0); - let x = g.to_xyz_rel([cmy, cmy, cmy, k]); - if x[1] < best[1] { - best = x; - } - } - g.black_rel = best; + // The curve's increase at 40% is 0.96·g·(1 − 0.2·s), so `s` follows from the 40% value. + let skew = [0, 1, 2, 3].map(|i| { + let g = params.tvi50[i]; + if g > 1e-6 { (1.0 - params.tvi40[i] / (0.96 * g)) / 0.2 } else { 0.0 } + }); + let mut g = Self { params, prim, skew, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() }; + g.black_rel = g.to_xyz_rel(g.black_point_build()); g } @@ -133,35 +153,81 @@ impl GenericCmyk { self.params.name } - fn gain(a: f32, g: f32) -> f32 { - let a = a.clamp(0.0, 1.0); - (a + 4.0 * g * a * (1.0 - a)).clamp(0.0, 1.0) + /// The black point: [`BlackGeneration::black_point_k`] black, with C, M and Y + /// chosen so that they fill the TAC and the color is neutral (a* = b* = 0). + pub fn black_point_build(&self) -> [f32; 4] { + let k = self.params.bg.black_point_k.clamp(0.0, 1.0); + let rest = (self.params.tac - k).clamp(0.0, 3.0); + let build = |c: f32, m: f32| [c, m, (rest - c - m).clamp(0.0, 1.0), k]; + let ab = |c: f32, m: f32| { + let l = self.to_lab_rel(build(c, m)); + [l.a, l.b] + }; + let lo = (rest - 2.0).max(0.0); + let hi = rest.min(1.0); + let (mut c, mut m) = ((rest / 3.0).clamp(lo, hi), (rest / 3.0).clamp(lo, hi)); + for _ in 0..30 { + let r = ab(c, m); + if r[0].abs() + r[1].abs() < 1e-4 { + break; + } + let h = 1e-3; + let rc = ab(c - h, m); + let rm = ab(c, m - h); + let j = [[(r[0] - rc[0]) / h, (r[0] - rm[0]) / h], [(r[1] - rc[1]) / h, (r[1] - rm[1]) / h]]; + let det = j[0][0] * j[1][1] - j[0][1] * j[1][0]; + if det.abs() < 1e-9 { + break; + } + let dc = (-r[0] * j[1][1] + r[1] * j[0][1]) / det; + let dm = (-r[1] * j[0][0] + r[0] * j[1][0]) / det; + c = (c + dc.clamp(-0.1, 0.1)).clamp(lo, hi); + m = (m + dm.clamp(-0.1, 0.1)).clamp(lo, hi); + // Yellow takes the rest, so keep it within 0..1 as well. + let y = rest - c - m; + if y > 1.0 { + c += (y - 1.0) / 2.0; + m += (y - 1.0) / 2.0; + } else if y < 0.0 { + c += y / 2.0; + m += y / 2.0; + } + c = c.clamp(lo, hi); + m = m.clamp(lo, hi); + } + build(c.clamp(lo, hi), m.clamp(lo, hi)) + } + + /// Effective dot area of ink `i` at nominal area `a`: the TR 001-style tone curve. + fn gain(&self, i: usize, a: f32) -> f32 { + let a = if a.is_finite() { a.clamp(0.0, 1.0) } else { 0.0 }; + let g = self.params.tvi50[i]; + (a + 4.0 * g * a * (1.0 - a) * (1.0 + self.skew[i] * (2.0 * a - 1.0))).clamp(0.0, 1.0) } /// CMYK (0..1) → absolute XYZ (D50). pub fn to_xyz(&self, cmyk: [f32; 4]) -> [f32; 3] { - let dg = self.params.dot_gain; - let c = Self::gain(cmyk[0], dg[0]); - let m = Self::gain(cmyk[1], dg[1]); - let y = Self::gain(cmyk[2], dg[2]); - let k = Self::gain(cmyk[3], dg[3]); - let mut out = [0.0f32; 3]; - for i in 0..8 { - let w = (if i & 1 != 0 { c } else { 1.0 - c }) * (if i & 2 != 0 { m } else { 1.0 - m }) * (if i & 4 != 0 { y } else { 1.0 - y }); - if w == 0.0 { - continue; - } - for (ch, o) in out.iter_mut().enumerate() { - *o += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]); - } - } - out + let x = self.to_xyz_rel(cmyk); + [x[0] * self.paper_xyz[0] / D50[0], x[1] * self.paper_xyz[1] / D50[1], x[2] * self.paper_xyz[2] / D50[2]] } /// Media-relative XYZ (paper = D50 white). pub fn to_xyz_rel(&self, cmyk: [f32; 4]) -> [f32; 3] { - let x = self.to_xyz(cmyk); - [x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]] + let e = [0, 1, 2, 3].map(|i| self.gain(i, cmyk[i])); + let mut out = [0.0f32; 3]; + for (i, p) in self.prim.iter().enumerate() { + let mut w = 1.0; + for (bit, a) in e.iter().enumerate() { + w *= if i & (1 << bit) != 0 { *a } else { 1.0 - a }; + } + if w == 0.0 { + continue; + } + for (o, v) in out.iter_mut().zip(p) { + *o += w * v; + } + } + out } pub fn to_lab_rel(&self, cmyk: [f32; 4]) -> Lab { @@ -174,7 +240,9 @@ impl GenericCmyk { /// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour). /// With `bpc` (media-relative only) the press black displays as black, undoing the /// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the - /// darkest rich black shows as black again. + /// black point shows as black again. Each XYZ channel is clamped at 0 on its own, so a build + /// darker than the black point in every channel shows as black, and one darker in only some + /// channels shows as a colored near-black. pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { if absolute { return self.to_xyz(cmyk); @@ -192,7 +260,7 @@ impl GenericCmyk { xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc)) } - /// Lab of the darkest printable black (media-relative). + /// Lab of the black point (media-relative), see [`Self::black_point_build`]. pub fn black_point(&self) -> Lab { xyz_to_lab(self.black_rel) } @@ -283,12 +351,21 @@ impl GenericCmyk { rgb.map(|v| ((1.0 - v - k) / d).clamp(0.0, 1.0)) } - fn black_for_grey(&self, grey: f32) -> f32 { - let bg = self.params.bg; - if grey <= bg.start { - return 0.0; + /// Black for `x` on the black curve (see [`BlackGeneration::curve`]). + fn black_for(&self, x: f32) -> f32 { + let curve = &self.params.bg.curve; + let mut prev = curve[0]; + if x <= prev.0 { + return prev.1.clamp(0.0, 1.0); } - (((grey - bg.start) / (1.0 - bg.start)).clamp(0.0, 1.0).powf(bg.gamma) * bg.max_k).clamp(0.0, 1.0) + for &(kx, kk) in &curve[1..] { + if x <= kx { + let t = if kx > prev.0 { (x - prev.0) / (kx - prev.0) } else { 1.0 }; + return (prev.1 + (kk - prev.1) * t).clamp(0.0, 1.0); + } + prev = (kx, kk); + } + prev.1.clamp(0.0, 1.0) } /// Separate a media-relative Lab (already gamut-mapped) into CMYK with GCR/UCR and the TAC limit. @@ -297,13 +374,10 @@ impl GenericCmyk { return [0.0; 4]; } let cmy0 = self.solve_cmy(target, 0.0, Self::initial_cmy(target, 0.0)); - let grey = cmy0[0].min(cmy0[1]).min(cmy0[2]); - let mut k = self.black_for_grey(grey); - // If CMY alone can't get dark enough, black must carry the rest. + let gray = cmy0[0].min(cmy0[1]).min(cmy0[2]); + // When CMY alone can't get dark enough, black carries the rest. let l0 = self.to_lab_rel([cmy0[0], cmy0[1], cmy0[2], 0.0]).l; - if l0 > target.l + 1.0 { - k = k.max(((l0 - target.l) / l0.max(1.0)).clamp(0.0, 1.0)); - } + let mut k = self.black_for(gray + self.params.bg.shortfall * (l0 - target.l).max(0.0)); let mut cmy = if k > 0.0 { self.solve_cmy(target, k, Self::initial_cmy(target, k)) } else { cmy0 }; // UCR: trade CMY for K until within the TAC limit. for _ in 0..6 { diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index ecb4c1c59..57709abe6 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -245,8 +245,8 @@ fn loads_system_cmyk_icc() { assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]); } -/// With black-point compensation, sRGB black separates to the press's darkest black and that black -/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey). +/// With black-point compensation, sRGB black separates to the press's black point and that black +/// displays as black again (hex 000000 -> C75 M68 Y67 K90 -> 000000, not a dark gray). #[test] fn generic_cmyk_black_round_trips_with_bpc() { let c = cms(); @@ -259,25 +259,104 @@ fn generic_cmyk_black_round_trips_with_bpc() { assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}"); } -/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few -/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024 -/// in all) stays within 0.001 on every ink. +/// Each ink's 40% and 50% tints gain like CGATS TR 001 (SWOP): the colorimetric tone value increase +/// (Murray–Davies on X for cyan, Y for magenta and black, Z for yellow) is C 20.0, M 17.6, Y 16.8 and +/// K 21.6 points at 50%, and C 20.0, M 17.3, Y 16.7 and K 21.9 points at 40%. +#[test] +fn generic_cmyk_tints_gain_like_tr001() { + let g = generic(); + let tvi = |ink: usize, a: f32| { + let ch = [0, 1, 2, 1][ink]; + let mut cmyk = [0.0f32; 4]; + cmyk[ink] = a; + let tint = g.to_xyz_rel(cmyk)[ch]; + cmyk[ink] = 1.0; + let solid = g.to_xyz_rel(cmyk)[ch]; + let paper = g.to_xyz_rel([0.0; 4])[ch]; + (paper - tint) / (paper - solid) - a + }; + for (ink, (at50, at40)) in [(0.200, 0.200), (0.176, 0.173), (0.168, 0.167), (0.216, 0.219)].into_iter().enumerate() { + assert!((tvi(ink, 0.5) - at50).abs() < 0.002, "ink {ink} at 50%: {}", tvi(ink, 0.5)); + assert!((tvi(ink, 0.4) - at40).abs() < 0.002, "ink {ink} at 40%: {}", tvi(ink, 0.4)); + } +} + +/// More of any ink never makes a Generic CMYK color lighter, in any XYZ channel: on a 10% grid of +/// builds within the 300% ink limit, adding 10% of one ink keeps X, Y and Z or lowers them. +#[test] +fn generic_cmyk_more_ink_is_never_lighter() { + let g = generic(); + let steps = (0..=10).map(|i| i as f32 / 10.0); + for c in steps.clone() { + for m in steps.clone() { + for y in steps.clone() { + for k in steps.clone() { + let base = [c, m, y, k]; + if base.iter().sum::() > 3.0 - 0.1 + 1e-4 { + continue; + } + let x0 = g.to_xyz_rel(base); + for ink in 0..4 { + if base[ink] >= 1.0 { + continue; + } + let mut more = base; + more[ink] += 0.1; + let x1 = g.to_xyz_rel(more); + assert!((0..3).all(|ch| x1[ch] <= x0[ch] + 1e-5), "{base:?} plus ink {ink}: {x0:?} to {x1:?}"); + } + } + } + } + } +} + +/// sRGB black separates to 90% black under neutral CMY that fills the 300% ink limit, light grays +/// carry no black, and black rises steadily from the light end of the gray ramp to the dark end. +#[test] +fn generic_cmyk_black_generation_follows_the_black_curve() { + let c = cms(); + let black = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric); + assert!((black[3] - 0.9).abs() < 0.005, "{black:?}"); + assert!((black.iter().sum::() - 3.0).abs() < 0.01, "{black:?}"); + let lab = c.cmyk_to_lab(black); + assert!(lab.a.abs() < 0.05 && lab.b.abs() < 0.05, "neutral: {lab:?}"); + let light = c.srgb_to_cmyk([0.7; 3], Intent::RelativeColorimetric); + assert_eq!(light[3], 0.0, "{light:?}"); + let mut prev = 0.0; + for v in (0..=50).rev().map(|i| i as f32 / 50.0) { + let k = c.srgb_to_cmyk([v; 3], Intent::RelativeColorimetric)[3]; + assert!(k >= prev - 1e-4, "gray {v}: K {k} after {prev}"); + prev = k; + } +} + +/// A color whose solve holds an ink at a bound separates to the same inks when its values change by +/// a few float steps: hex f01482 (cyan held at 0) and hex ed1e79 (magenta held at 100%), each with +/// its red stepped one `f32` at a time over 4,001 values (about 0.00024 in all), stay within 0.001 +/// on every ink. #[test] fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() { let c = cms(); - let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]; - let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); - for step in -2000i32..=2000 { - let mut rgb = pink; - rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); - let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); - for i in 0..4 { - lo[i] = lo[i].min(ink[i]); - hi[i] = hi[i].max(ink[i]); + let spread = |pink: [f32; 3]| { + let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); + for step in -2000i32..=2000 { + let mut rgb = pink; + rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); + let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); + for i in 0..4 { + lo[i] = lo[i].min(ink[i]); + hi[i] = hi[i].max(ink[i]); + } } - } + (lo, hi) + }; + let (lo, hi) = spread([240.0f32 / 255.0, 20.0 / 255.0, 130.0 / 255.0]); assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); assert_eq!(lo[0], 0.0, "{lo:?}"); + let (lo, hi) = spread([237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]); + assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); + assert_eq!(hi[1], 1.0, "{hi:?}"); } /// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 09e8154c9..3ac145fd2 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -376,6 +376,11 @@ impl Appearance { pub fn has_placed_gradient(&self) -> bool { self.has_gradient(true) } + /// Does a fill or stroke carry a gradient that is drawn at a place in document space: a placed + /// one, or a freeform gradient with points of its own whose vector is fitted to the bounds? + pub fn has_positioned_gradient(&self) -> bool { + self.gradients().any(|g| g.is_placed() || g.has_freeform_points()) + } /// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit /// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce. pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 287a6b2aa..ebd27fd87 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -34,6 +34,7 @@ impl Document { graphic_styles, char_styles, para_styles, + composite_fonts, text_threads, symbols, graph_designs, @@ -90,6 +91,7 @@ impl Document { && *graphic_styles == other.graphic_styles && *char_styles == other.char_styles && *para_styles == other.para_styles + && *composite_fonts == other.composite_fonts && *text_threads == other.text_threads && symbols.len() == other.symbols.len() && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) @@ -213,6 +215,7 @@ impl Document { graphic_styles: default_graphic_styles().into(), char_styles: Default::default(), para_styles: Default::default(), + composite_fonts: Default::default(), text_threads: vec![], symbols: vec![], graph_designs: vec![], diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8a866256e..5ab3b8bdf 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -121,6 +121,7 @@ pub use text::{ RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; +pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts}; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; @@ -195,6 +196,9 @@ pub struct Document { /// Paragraph styles (besides the built-in [Normal Paragraph Style]). #[serde(default, skip_serializing_if = "skip::is_empty_vec")] pub para_styles: Shared>, + /// Type › Composite Fonts…: the composite fonts defined in the document, by name. + #[serde(default, skip_serializing_if = "skip::is_empty_vec")] + pub composite_fonts: Shared>, /// Threaded text: area-type frames one story flows through, in order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub text_threads: Vec>, diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index a93830822..e431fc9d1 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -103,6 +103,11 @@ impl Document { let styles = |s: &Vec| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::(); n += shared(&self.char_styles, &newer.char_styles, styles); n += shared(&self.para_styles, &newer.para_styles, styles); + n += shared(&self.composite_fonts, &newer.composite_fonts, |c| { + let part = |p: &crate::CompositePart| p.font.capacity() + p.style.capacity(); + vec_bytes(c) + + c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::()).sum::() + }); n += shared(&self.saved_selections, &newer.saved_selections, |s| { vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::() }); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index a2f21e856..11be20ee2 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -8,6 +8,7 @@ use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, Li mod area; mod char_style; +mod composite; mod object; mod para_style; mod runs; @@ -17,6 +18,10 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics}; +pub use composite::{ + COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, + CompositeParts, +}; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index 515f52627..7bb742f48 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -101,15 +101,24 @@ impl PathEffect { } } -/// Type on a Path Options › Align to Path: which height of the type runs along the path. +/// Type on a Path Options › Align to Path: which height of the type runs along the path. Each +/// character is measured by its own font, size and vertical scale, and in vertical type by its own +/// size and horizontal scale. An inline graphic moves as a character of its run would, by its +/// run's vertical scale in vertical type too. These distances are VectorCraft's own values. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PathAlign { - /// The font's top edge: the type hangs below the path. + /// The top of the font's em box: the type hangs below the path. The em box is the font's + /// ideographic em box (from its `BASE` table, its typographic or `hhea` box, or its + /// ideographs), else an em box centered on the cap height, else a box whose top is 0.88 em + /// above the baseline. In vertical type the characters move half an em. Ascender, - /// The font's bottom edge: the type stands above the path. + /// The bottom of the font's bounding box, else the font's descent: the type stands above the + /// path. In vertical type the characters move half an em the other way. Descender, - /// Halfway between the ascender and the descender. + /// A line at half the height of the top of the font's H above the baseline, else at half its + /// cap height, else at the middle of its em box. In vertical type the characters stay on the + /// path. Center, /// The baseline (the default). #[default] diff --git a/crates/doc/src/text/composite.rs b/crates/doc/src/text/composite.rs new file mode 100644 index 000000000..e95428c2f --- /dev/null +++ b/crates/doc/src/text/composite.rs @@ -0,0 +1,344 @@ +//! Type › Composite Fonts…: a font made of other fonts, one for each class of character (the +//! Kanji font is the base; kana, punctuation, symbols, Roman letters and numerals each take a font +//! of their own, sized, raised and scaled against it). Type set in a composite font names it as its +//! family; the layout sets each character in its class's font. +//! +//! Behavior reference (public documentation): +//! . +//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own. + +use serde::{Deserialize, Serialize}; + +/// A class of characters a composite font gives a font of its own. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CharClass { + /// The base font: ideographs, full-width letters and digits, and every character no other + /// class takes. + Kanji, + /// Hiragana and katakana, full-width and half-width. + Kana, + /// East Asian and full-width punctuation, and typographic quotes and dashes. + Punctuation, + /// Arrows, math, shapes, enclosed and other symbols. + Symbols, + /// Half-width Roman letters, their punctuation and spaces. + Roman, + /// The half-width digits 0 to 9. + Numerals, +} + +impl CharClass { + /// Every class, in the order the Composite Fonts dialog lists them. + pub const ALL: [CharClass; 6] = [Self::Kanji, Self::Kana, Self::Punctuation, Self::Symbols, Self::Roman, Self::Numerals]; + + /// The name commands and files use (`kanji`, `kana`, …). + pub fn key(self) -> &'static str { + match self { + Self::Kanji => "kanji", + Self::Kana => "kana", + Self::Punctuation => "punctuation", + Self::Symbols => "symbols", + Self::Roman => "roman", + Self::Numerals => "numerals", + } + } + + /// The class named `key` ([`Self::key`], any case). + pub fn named(key: &str) -> Option { + Self::ALL.into_iter().find(|c| c.key().eq_ignore_ascii_case(key)) + } + + /// Whether the class's characters can be scaled across and up (Kana, Roman and Numerals). + pub fn scales(self) -> bool { + matches!(self, Self::Kana | Self::Roman | Self::Numerals) + } + + /// The class of character `c`. + pub fn of(c: char) -> Self { + let u = c as u32; + match u { + 0x30..=0x39 => Self::Numerals, + // Basic Latin, Latin-1, Latin Extended, IPA, spacing and combining marks, and Latin + // Extended Additional. + 0x00..=0x036F | 0x1E00..=0x1EFF => Self::Roman, + // The other Latin blocks: combining marks extended, phonetic extensions and their + // supplement, combining marks supplement, Latin Extended-C, -D, -E, -F and -G, and the + // Latin ligatures of the alphabetic presentation forms. + 0x1AB0..=0x1AFF + | 0x1D00..=0x1DFF + | 0x2C60..=0x2C7F + | 0xA720..=0xA7FF + | 0xAB30..=0xAB6F + | 0xFB00..=0xFB06 + | 0x10780..=0x107BF + | 0x1DF00..=0x1DFFF => Self::Roman, + // ・ and ゠ sit in the katakana block but are punctuation. + 0x30A0 | 0x30FB => Self::Punctuation, + // Hiragana, katakana, katakana phonetic extensions, half-width katakana (with its + // prolonged sound and voicing marks) and the kana supplements. + 0x3041..=0x309F | 0x30A1..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F | 0x1B000..=0x1B16F => Self::Kana, + // CJK symbols: 〄 〒 〓 〠 〶; ideographic space, 々 〆 〇 and the like stay with the base. + 0x3004 | 0x3012 | 0x3013 | 0x3020 | 0x3036 => Self::Symbols, + 0x3000 | 0x3005..=0x3007 | 0x3021..=0x302F | 0x3031..=0x3035 | 0x3038..=0x303C => Self::Kanji, + 0x3001..=0x303F => Self::Punctuation, + // ※ is a symbol; the rest of General Punctuation (dashes, quotes, leaders) punctuation. + 0x203B => Self::Symbols, + 0x2010..=0x205E => Self::Punctuation, + // Letterlike symbols, number forms, arrows, math, technical, enclosed alphanumerics, box + // drawing, shapes, miscellaneous symbols and dingbats; enclosed CJK and CJK + // compatibility; full-width signs. + 0x2100..=0x2BFF | 0x3200..=0x33FF | 0xFFE0..=0xFFEE => Self::Symbols, + // Full-width forms of the ASCII signs. + 0xFF03..=0xFF06 | 0xFF0A | 0xFF0B | 0xFF1C..=0xFF1E | 0xFF20 | 0xFF3C | 0xFF3E | 0xFF5C | 0xFF5E => Self::Symbols, + // Full-width forms of the other ASCII punctuation, and the half-width CJK punctuation. + 0xFF01..=0xFF0F | 0xFF1A..=0xFF1F | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65 => Self::Punctuation, + _ => Self::Kanji, + } + } +} + +/// The font a composite font sets one class of characters in, and how. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompositePart { + /// The font family and style. + pub font: String, + #[serde(default = "regular")] + pub style: String, + /// The size, in percent of the type's size. + #[serde(default = "hundred")] + pub size: f64, + /// How far the characters are raised from the baseline, in percent of the type's size + /// (negative lowers them). + #[serde(default)] + pub baseline: f64, + /// Horizontal and vertical scale, in percent (Kana, Roman and Numerals only). + #[serde(default = "hundred")] + pub h_scale: f64, + #[serde(default = "hundred")] + pub v_scale: f64, + /// Kana only: the characters are scaled from the center of the em box rather than from the + /// baseline. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub scale_at_center: bool, +} + +fn hundred() -> f64 { + 100.0 +} + +fn regular() -> String { + "Regular".into() +} + +impl CompositePart { + /// The part a new composite font gives every class: `font` in `style` at full size, on the + /// baseline, unscaled. + pub fn new(font: impl Into, style: impl Into) -> Self { + Self { font: font.into(), style: style.into(), size: 100.0, baseline: 0.0, h_scale: 100.0, v_scale: 100.0, scale_at_center: false } + } +} + +/// The six parts of a composite font, one per [`CharClass`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CompositeParts { + pub kanji: CompositePart, + pub kana: CompositePart, + pub punctuation: CompositePart, + pub symbols: CompositePart, + pub roman: CompositePart, + pub numerals: CompositePart, +} + +impl CompositeParts { + /// Every class set in `part`. + pub fn all(part: CompositePart) -> Self { + Self { kanji: part.clone(), kana: part.clone(), punctuation: part.clone(), symbols: part.clone(), roman: part.clone(), numerals: part } + } + + pub fn get(&self, class: CharClass) -> &CompositePart { + match class { + CharClass::Kanji => &self.kanji, + CharClass::Kana => &self.kana, + CharClass::Punctuation => &self.punctuation, + CharClass::Symbols => &self.symbols, + CharClass::Roman => &self.roman, + CharClass::Numerals => &self.numerals, + } + } + + pub fn get_mut(&mut self, class: CharClass) -> &mut CompositePart { + match class { + CharClass::Kanji => &mut self.kanji, + CharClass::Kana => &mut self.kana, + CharClass::Punctuation => &mut self.punctuation, + CharClass::Symbols => &mut self.symbols, + CharClass::Roman => &mut self.roman, + CharClass::Numerals => &mut self.numerals, + } + } +} + +/// A composite font (Type › Composite Fonts…), saved with the document. Its name is the family +/// that type set in it names. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CompositeFont { + pub name: String, + pub parts: CompositeParts, +} + +/// The size a part can take, in percent of the type's size. +pub const COMPOSITE_SIZE: std::ops::RangeInclusive = 10.0..=400.0; +/// How far a part can raise or lower its characters, in percent of the type's size. +pub const COMPOSITE_BASELINE: std::ops::RangeInclusive = -100.0..=100.0; +/// The horizontal and vertical scale a part can take, in percent. +pub const COMPOSITE_SCALE: std::ops::RangeInclusive = 10.0..=400.0; +/// The longest composite font name, in characters. +pub const COMPOSITE_NAME_MAX: usize = 255; +/// The most composite fonts a document or a file keeps. +pub const COMPOSITE_FONTS_MAX: usize = 256; + +impl CompositeFont { + /// A composite font named `name` setting every class in `font` and `style`. + pub fn new(name: impl Into, font: impl Into, style: impl Into) -> Self { + Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)) } + } + + /// Do `a` and `b` name the same composite font: equal in any case? + pub fn same_name(a: &str, b: &str) -> bool { + a == b || a.to_lowercase() == b.to_lowercase() + } + + /// The font of `fonts` (a document's composite fonts) named `name`, in any case. + pub fn named<'a>(fonts: &'a [CompositeFont], name: &str) -> Option<&'a CompositeFont> { + fonts.iter().find(|f| Self::same_name(&f.name, name)) + } + + /// `name` trimmed, when it can name a composite font: not empty, at most + /// [`COMPOSITE_NAME_MAX`] characters and without control characters. + pub fn clean_name(name: &str) -> Option { + let name = name.trim(); + let ok = !name.is_empty() && name.chars().count() <= COMPOSITE_NAME_MAX && !name.chars().any(char::is_control); + ok.then(|| name.to_string()) + } + + /// Why this composite font can't be used, if it can't: a name [`Self::clean_name`] refuses, a + /// part without a font, a value out of its range, or a scale or Scale at Center on a class + /// that has none. + pub fn check(&self) -> Result<(), String> { + if Self::clean_name(&self.name).as_deref() != Some(self.name.as_str()) { + return Err(format!("`{}` can't name a composite font", self.name)); + } + for class in CharClass::ALL { + let p = self.parts.get(class); + let k = class.key(); + if p.font.trim().is_empty() { + return Err(format!("{k}: give a font")); + } + let within = |v: f64, r: &std::ops::RangeInclusive| v.is_finite() && r.contains(&v); + if !within(p.size, &COMPOSITE_SIZE) { + return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end())); + } + if !within(p.baseline, &COMPOSITE_BASELINE) { + return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end())); + } + for v in [p.h_scale, p.v_scale] { + if !within(v, &COMPOSITE_SCALE) { + return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end())); + } + if v != 100.0 && !class.scales() { + return Err(format!("{k}: only kana, roman and numerals can be scaled")); + } + } + if p.scale_at_center && class != CharClass::Kana { + return Err(format!("{k}: only kana can be scaled at the center")); + } + } + Ok(()) + } + + /// `fonts` without the ones that can't be used: those [`Self::check`] refuses, a later font + /// sharing an earlier one's name (in any case), a part set in one of the fonts, and those past + /// [`COMPOSITE_FONTS_MAX`]. `None` when every font can be used. + pub fn usable(fonts: &[CompositeFont]) -> Option> { + let mut names = std::collections::HashSet::new(); + let mut kept: Vec = + fonts.iter().filter(|f| f.check().is_ok() && names.insert(f.name.to_lowercase())).take(COMPOSITE_FONTS_MAX).cloned().collect(); + kept.retain(|f| CharClass::ALL.iter().all(|&c| !names.contains(&f.parts.get(c).font.to_lowercase()))); + (kept.len() != fonts.len()).then_some(kept) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each class takes its characters: digits and Latin letters are half-width numerals and + /// Roman, kana both widths, and the punctuation, symbols and full-width forms their own. + #[test] + fn characters_fall_in_their_classes() { + for (s, class) in [ + ("漢字国一龍〇々", CharClass::Kanji), + ("AZa09 ", CharClass::Kanji), + ("あをアンーアンー", CharClass::Kana), + ("、。「」『』【】〜・!?():", CharClass::Punctuation), + ("“”‘’—…", CharClass::Punctuation), + ("※→★○〒+=$%①㈱", CharClass::Symbols), + ("AZaz é!? ()", CharClass::Roman), + // Latin Extended-C, -D and -E, phonetic extensions and a ligature. + ("\u{2C60}\u{A7A0}\u{AB30}\u{1D00}\u{FB01}", CharClass::Roman), + ("0123456789", CharClass::Numerals), + ] { + for c in s.chars() { + assert_eq!(CharClass::of(c), class, "{c:?} (U+{:04X})", c as u32); + } + } + } + + /// A composite font is read and written by its fields' names, and a part's omitted values + /// take the defaults. + #[test] + fn composite_fonts_read_back_with_defaults() { + let mut f = CompositeFont::new("Mixed", "Source Sans 3", "Regular"); + f.parts.kana.v_scale = 90.0; + f.parts.kana.scale_at_center = true; + f.parts.roman.baseline = -5.0; + let json = serde_json::to_value(&f).unwrap(); + assert_eq!(json["parts"]["kana"]["vScale"], 90.0); + assert_eq!(json["parts"]["kana"]["scaleAtCenter"], true); + assert!(json["parts"]["roman"].get("scaleAtCenter").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), f); + let part: CompositePart = serde_json::from_value(serde_json::json!({"font": "Inter"})).unwrap(); + assert_eq!(part, CompositePart::new("Inter", "Regular")); + } + + /// Names are trimmed; values out of their ranges, and scales on classes that don't scale, are + /// refused. + #[test] + fn composite_fonts_check_their_values() { + assert_eq!(CompositeFont::clean_name(" Mixed ").as_deref(), Some("Mixed")); + for bad in ["", " ", "a\tb", &"x".repeat(COMPOSITE_NAME_MAX + 1)] { + assert_eq!(CompositeFont::clean_name(bad), None, "{bad:?}"); + } + let ok = CompositeFont::new("Mixed", "Inter", "Regular"); + assert_eq!(ok.check(), Ok(())); + let with = |f: fn(&mut CompositeParts)| { + let mut c = ok.clone(); + f(&mut c.parts); + c.check() + }; + assert!(with(|p| p.roman.size = 9.0).is_err()); + assert!(with(|p| p.roman.size = f64::NAN).is_err()); + assert!(with(|p| p.kana.baseline = 101.0).is_err()); + assert!(with(|p| p.kana.h_scale = 401.0).is_err()); + assert!(with(|p| p.kanji.v_scale = 90.0).is_err()); + assert!(with(|p| p.symbols.h_scale = 90.0).is_err()); + assert!(with(|p| p.roman.scale_at_center = true).is_err()); + assert!(with(|p| p.numerals.font = " ".into()).is_err()); + assert_eq!(with(|p| p.kana.scale_at_center = true), Ok(())); + assert_eq!(with(|p| p.numerals.h_scale = 50.0), Ok(())); + let mut spaced = ok.clone(); + spaced.name = " Mixed".into(); + assert!(spaced.check().is_err()); + } +} diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 715143e1a..57a10f66e 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -236,10 +236,11 @@ impl Carry { self.0.is_empty() } - /// Does it move any of the gradients of `ap`? Callers check first, so an appearance shared - /// with other objects is not copied for nothing. + /// Does it move any of the gradients of `ap` (those drawn at a place in document space, a + /// freeform gradient's own points included)? Callers check first, so an appearance shared with + /// other objects is not copied for nothing. pub fn moves(&self, ap: &Appearance) -> bool { - !self.0.is_empty() && ap.has_placed_gradient() + !self.0.is_empty() && ap.has_positioned_gradient() } /// Move the placed gradients of `ap` along, through each step in turn. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e4ca42a4e..7bba3e8dd 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -86,7 +86,7 @@ pub use reshape::{ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, - three_d_meshes, three_d_params_as, validate_solid, validate_three_d, view_options, + three_d_meshes, three_d_meshes_of, three_d_params_as, validate_solid, validate_three_d, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 4ea3e4610..85f2849d9 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -670,8 +670,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b if follows { group_marks(art, &mut vec![], &HashSet::new(), &mut groups); } - // Whether a path kept its shape (past the budget), and the ids of those that did. - let mut kept = false; + // The ids of the paths that kept their shape (past the budget). let mut kept_ids = HashSet::new(); let transform = (e.id == "distort.transform").then(|| { let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); @@ -697,7 +696,6 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); used = used.minus(before).plus(made); if !moved { - kept = true; kept_ids.insert(leaf.id); } return; @@ -709,12 +707,11 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b } } let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { - kept = true; kept_ids.insert(leaf.id); return; }; used = used.minus(before).plus(Budget::of(&out)); - if follows && !boxes && leaf.appearance.has_placed_gradient() { + if follows && !boxes && leaf.appearance.has_positioned_gradient() { crate::Carry::of(&e.id, ¶ms, bounds, None, None, seed).apply(&mut leaf.appearance); } set_geometry(leaf, out, rule); @@ -732,13 +729,13 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b } // What paints the art as a whole, and the own fills and strokes of the groups inside it, // follow their bounds under Free Distort (they stay when no path changed them). Each stays - // when a path under it kept its shape (past the budget). + // when a path under it that its bounds measure kept its shape (past the budget). + let mut after = vec![]; + let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1; if !groups.is_empty() { - let mut after = vec![]; - group_marks(art, &mut vec![], &kept_ids, &mut after); carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); } - if follows && !kept { + if follows && !held { whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed)); } } @@ -1020,7 +1017,7 @@ fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter bool { /// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a /// placed gradient: the child indices that lead to it from the art, its geometric bounds, and -/// whether a path below it kept its shape. +/// whether a path below it that its bounds measure kept its shape. type GroupMark = (Vec, Option, bool); /// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or -/// compound path below it is in `kept`, measured in one walk. Each group below `n` that +/// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that /// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut Vec) -> (Option, bool) { match &n.kind { @@ -1065,14 +1062,15 @@ fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut for (i, c) in children.iter().enumerate() { at.push(i); let (b, h) = group_marks(c, at, kept, out); - if is_member_group(c) && c.appearance.has_placed_gradient() { + if is_member_group(c) && c.appearance.has_positioned_gradient() { out.push((at.clone(), b, h)); } at.pop(); - held |= h; + // Only members measured in the bounds hold the group's gradients in place. let counts = if *clip { i == 0 } else { i >= skip && (c.visible || !layer) }; if counts { bounds = vectorcraft_geom::union_opt(bounds, b); + held |= h; } } (bounds, held) @@ -1126,7 +1124,7 @@ fn rebase_leaves(n: &mut Node, before: &mut std::vec::IntoIter>) { if let Some(Some(from)) = before.next() && let Some(to) = n.geometric_bounds() && from != to - && n.appearance.has_placed_gradient() + && n.appearance.has_positioned_gradient() { follow_boxes(&mut n.appearance, from, to); } @@ -1224,7 +1222,7 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { _ => {} } // The placed gradients of its own fills and strokes move with it, as a path's do. - if xf != Affine::IDENTITY && one.appearance.has_placed_gradient() { + if xf != Affine::IDENTITY && one.appearance.has_positioned_gradient() { one.appearance.transform_gradients(xf); } one diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index ba4b0e657..946c6532b 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -228,8 +228,9 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option { /// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on /// the object, one in its top visible fill (else its stroke); with it on fills and strokes, one for -/// each of them. -pub(crate) fn revolve_meshes(n: &Node) -> Result, String> { +/// each of them. Each mesh adds its triangles to `total` as it is built, and the first that takes +/// `total` past `limit` stops with an error before the next is built. +pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let path = profile(n).ok_or("Revolve currently requires a path or compound path")?; let ctx = GeomContext::of(n); let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result { @@ -250,7 +251,9 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result, String> .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i))) .ok_or("the revolved object has no fill or stroke")?; - return Ok(vec![mesh(&path, fx, item)?]); + let mut out = vec![]; + crate::solids::keep_mesh(&mut out, total, mesh(&path, fx, item)?, limit)?; + return Ok(out); } let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; @@ -259,7 +262,7 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result, String> && let Some(fx) = item.effects().get(index) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); - out.push(mesh(&p, fx, item)?); + crate::solids::keep_mesh(&mut out, total, mesh(&p, fx, item)?, limit)?; } } Ok(out) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 09a2fc8ec..6f8eac9be 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -566,14 +566,15 @@ pub(crate) fn keep_mesh(out: &mut Vec, total: &mut usize, mesh: Solid /// The solids effect `fx` builds from `shapes`, as [`art_for`] draws them, as meshes: Rotate turns /// every fill and stroke; Extrude & Bevel and Inflate build one from each shape's top fill and one -/// from the outline of each stroke. A stroke whose solid is past the limits is left out. -fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result, String> { +/// from the outline of each stroke. A stroke whose solid is past the limits is left out. Each mesh +/// is counted against `limit` ([`keep_mesh`]) before the next is built. +fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result, String> { let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…')); let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; let p = crate::merged_params(&fx.id, &fx.params); let view = view_options(&p); let mut out = vec![]; - let mut keep = |out: &mut Vec, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, MAX_MESH_TRIANGLES); + let mut keep = |out: &mut Vec, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit); if fx.id == ROTATE { for s in shapes { for item in &s.items { @@ -585,8 +586,8 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect(); vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) @@ -614,16 +615,33 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result Result, String> { - // Triangles of every solid together, against [`MAX_MESH_TRIANGLES`]. + meshes_within(n, &mut 0, MAX_MESH_TRIANGLES) +} + +/// The meshes of every object in `nodes` ([`three_d_meshes`]), in order, as one 3D asset exports +/// them: the objects share one budget of [`MAX_MESH_TRIANGLES`]. +pub fn three_d_meshes_of<'a>(nodes: impl IntoIterator) -> Result, String> { + meshes_of_within(nodes, MAX_MESH_TRIANGLES) +} + +/// [`three_d_meshes_of`] with a budget of `limit` triangles. +fn meshes_of_within<'a>(nodes: impl IntoIterator, limit: usize) -> Result, String> { let mut total = 0usize; + let mut out = vec![]; + for n in nodes { + out.extend(meshes_within(n, &mut total, limit)?); + } + Ok(out) +} + +/// [`three_d_meshes`], with the triangles of every solid added to `total` as it is built and an +/// error as soon as `total` passes `limit`. +fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let meshes = if crate::has_revolve(n) { - let mut out = vec![]; - for m in crate::revolve::revolve_meshes(n)? { - keep_mesh(&mut out, &mut total, m, MAX_MESH_TRIANGLES)?; - } - out + crate::revolve::revolve_meshes(n, total, limit)? } else if !has_solid(n) { return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into()); } else if is_container(n) { @@ -631,21 +649,21 @@ pub fn three_d_meshes(n: &Node) -> Result, String> { let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…')); let mut shapes = members(n, label)?; reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); - meshes_for(fx, &shapes, &mut total)? + meshes_for(fx, &shapes, total, limit)? } else { let (path, rule) = profile(n).ok_or("3D export needs a path, compound path, type or group")?; let ctx = GeomContext::of(n); let paints = solid_paints(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); - meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], &mut total)? + meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], total, limit)? } else { let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); - out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], &mut total)?); + out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], total, limit)?); } } out diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index 9aca2ab8c..70706e068 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -54,9 +54,49 @@ fn the_meshes_of_one_object_share_one_triangle_budget() { let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }]; let Some(effect) = n.appearance.effects.first() else { panic!("no effect") }; let mut total = 0; - assert!(meshes_for(effect, &shapes, &mut total).is_ok()); + assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_ok()); let mut total = MAX_MESH_TRIANGLES - first + 1; - assert!(meshes_for(effect, &shapes, &mut total).is_err_and(|e| e.contains("too complex"))); + assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_err_and(|e| e.contains("too complex"))); +} + +#[test] +fn revolve_stops_at_the_first_mesh_past_the_budget() { + // The fill revolves into a mesh; the stroke's profile, copied 1000 times, is too complex to + // revolve at all. + let mut n = square_node(1, 100.0); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.get_mut(0) { + f.effects.push(fx(crate::REVOLVE, json!({}))); + } + let fill_only = n.clone(); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.get_mut(1) { + s.effects.push(fx("distort.transform", json!({ "moveH": 1.0, "copies": 1000 }))); + s.effects.push(fx(crate::REVOLVE, json!({}))); + } + let fill = three_d_meshes(&fill_only).unwrap(); + let triangles = fill[0].mesh.triangles.len(); + assert!(triangles > 1); + let whole = three_d_meshes(&n); + assert!(whole.as_ref().is_err_and(|e| !e.contains("to export")), "the stroke's own error: {whole:?}"); + // With room for less than the fill's mesh, the stroke's is never built. + let mut total = 0; + let capped = meshes_within(&n, &mut total, triangles - 1); + assert!(capped.as_ref().is_err_and(|e| e.contains("too complex to export")), "{capped:?}"); + assert_eq!(total, triangles); +} + +#[test] +fn the_objects_of_one_asset_share_one_triangle_budget() { + let mut a = square_node(1, 0.0); + a.appearance.effects.push(fx(EXTRUDE, json!({}))); + let mut b = square_node(2, 300.0); + b.appearance.effects.push(fx(EXTRUDE, json!({}))); + let triangles = |n: &Node| three_d_meshes(n).unwrap().iter().map(|m| m.mesh.triangles.len()).sum::(); + let (ta, tb) = (triangles(&a), triangles(&b)); + assert!(meshes_of_within([&a], ta).is_ok()); + assert!(meshes_of_within([&b], tb).is_ok()); + assert!(meshes_of_within([&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export"))); + assert_eq!(meshes_of_within([&a, &b], ta + tb).map(|m| m.len()), Ok(4)); + assert_eq!(three_d_meshes_of([&a, &b]).map(|m| m.len()), Ok(4)); } #[test] diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 0b3fc0b1c..1afa679aa 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -455,6 +455,92 @@ fn art_past_the_budget_keeps_its_own_gradients_in_place() { } } +/// A hidden path in a layer that keeps its shape past the budget does not hold the layer's +/// gradients in place: the layer's bounds leave it out, so the layer's own gradient moves with +/// the visible art under a Transform effect and Free Distort, as does the art's when the layer is +/// the art. +#[test] +fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, LayerColor, NodeId, NodeKind, color::Paint}; + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let end = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| g.end.x), + _ => None, + }; + // A layer with a visible square and a hidden circle, both 0..100, and a gradient fill of its own. + let layer = || { + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut circle = Node::path(NodeId(4), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); + circle.visible = false; + let mut layer = Node::layer(NodeId(2), "Layer 1", LayerColor::Preset(0)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![square, Arc::new(circle)]; + } + layer.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + layer + }; + // No room beyond what the paths cost: the square takes each effect, the circle would be drawn + // with more lines and keeps its shape. + let budget = Budget { anchors: usize::MAX, lines: 0 }; + // Each scales the art ten times about its center: 0..100 becomes -450..550. + let corners = json!({"corners": [[-4.5, -4.5], [5.5, -4.5], [5.5, 5.5], [-4.5, 5.5]]}); + for grow in [fx("distort.transform", json!({"scaleH": 1000, "scaleV": 1000})), fx("distort.freeDistort", corners)] { + let mut art = layer(); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget); + let circle = art.children().unwrap()[1].geometric_bounds().unwrap(); + assert!(circle.width() < 101.0, "{}: the hidden circle keeps its shape: {circle:?}", grow.id); + let square = art.children().unwrap()[0].geometric_bounds().unwrap(); + assert!((square.x1 - 550.0).abs() < 1e-6, "{}: the square takes the effect: {square:?}", grow.id); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert_eq!(end(&own), Some(550.0), "{}: the layer's gradient, as the art", grow.id); + let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]); + crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget); + assert_eq!(end(&art.children().unwrap()[0].appearance), Some(550.0), "{}: the layer's gradient, inside the art", grow.id); + } +} + +/// A freeform gradient whose points were edited (its vector still fitted to the bounds) moves its +/// points with a Transform effect and Free Distort, on a path, on a group inside the art and on the +/// art itself, as a placed gradient does. +#[test] +fn edited_freeform_points_follow_a_transform_effect_and_free_distort() { + use std::sync::Arc; + + use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let mut ramp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() }); + let pt = |x: f64, c| FreeformPoint::new(Point::new(x, 50.0), c); + ramp.set_freeform(Freeform { points: vec![pt(0.0, Color::BLACK), pt(100.0, Color::WHITE)], ..Default::default() }); + assert!(ramp.geom.is_none() && !ramp.is_placed()); + let freeform = Paint::Gradient(Box::new(ramp)); + let xs = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points.iter().map(|p| p.at.x).collect::>()), + _ => None, + }; + let square = |ap: Appearance| Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap)); + // Each moves the art 30 pt right. + let corners = json!({"corners": [[0.3, 0.0], [1.3, 0.0], [1.3, 1.0], [0.3, 1.0]]}); + for e in [fx("distort.transform", json!({"moveH": 30})), fx("distort.freeDistort", corners)] { + let mut inner = Node::group(NodeId(2), vec![square(Appearance::default())]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]); + art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS); + let members = art.children().unwrap(); + assert_eq!(xs(&members[0].appearance), Some(vec![30.0, 130.0]), "{}: the path's points", e.id); + assert_eq!(xs(&members[1].appearance), Some(vec![30.0, 130.0]), "{}: the inner group's points", e.id); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert_eq!(xs(&own), Some(vec![30.0, 130.0]), "{}: the art's own points", e.id); + } +} + /// #3578: a large Roughen draws each of its points as many lines; they stay within the budget, /// the Roughen built again with fewer points where they wouldn't. #[test] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index b24386645..75835394a 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -12,6 +12,7 @@ pub mod clipboard; mod colorcmds; pub mod colormgmt; pub mod colortheme; +pub mod compositefonts; mod compoundshape; pub(crate) mod create; mod css; @@ -286,6 +287,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(inline::specs()); v.extend(textstyles::specs()); v.extend(fonts::specs()); + v.extend(compositefonts::specs()); v.extend(fontfiles::specs()); v.extend(help::specs()); v.extend(threads::specs()); diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index d8f2b0289..73b21356b 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -962,11 +962,9 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { let default = CharStyle::default().font_family; let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default); let db = FontDb::global(); - // Any name of the family is stored as its own name. - let family = match db.resolve(wanted, "Regular") { - Some((f, m)) if m != FontMatch::Missing => f.family.clone(), - _ => return Err(bad(C, "the replacement font is not available")), - }; + // Any name of the family is stored as its own name, and a composite font as its own name. + let composite = db.composite_font(wanted).is_some(); + let family = db.stored_family(wanted, "Regular").ok_or_else(|| bad(C, "the replacement font is not available"))?; let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing); let lacks = |t: &TextObject| t.runs.iter().any(|r| missing(&r.style)); let d = &s.doc()?.doc; @@ -988,8 +986,11 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { let mut runs = 0; let mut replace = |t: &mut TextObject| { for r in t.runs.iter_mut().filter(|r| missing(&r.style)) { - // The style the font has closest to the one the character had. - r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + // The style the font has closest to the one the character had (a composite font's + // parts name their own styles: the character keeps its style). + if !composite { + r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + } r.style.font_family = family.clone(); r.style.font_version = None; r.style.font_axes.clear(); diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index fd273e067..8c225b877 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -277,12 +277,11 @@ fn export_3d(s: &mut Session, p: &Value) -> Result { for id in ids { let Some(a) = d.asset(id) else { continue }; let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D); - let mut parts = vec![]; - for node in &a.nodes { - let n = d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))?; - let what = &a.name; - parts.extend(vectorcraft_render::effects::three_d_meshes(n).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?); - } + let nodes = + a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::>>()?; + // The asset's objects share one budget of triangles. + let what = &a.name; + let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?; let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into()); let mut stem = base.clone(); let mut k = 1; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 7bdcfad58..d9b5784b6 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -287,7 +287,8 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { /// How Document Color Mode converts neutral RGB greys (R = G = B) to CMYK (the `grays` param). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Grays { - /// Through the CMYK profile like any colour: four-colour greys and a rich black. + /// Through the CMYK profile like any colour: light grays in cyan, magenta and yellow only, darker + /// grays in four inks, and a rich black. #[default] Profile, /// On the black plate only: K is the grey's ink percentage (as Convert to Grayscale and then diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs new file mode 100644 index 000000000..2b356ab85 --- /dev/null +++ b/crates/engine/src/cmd/compositefonts.rs @@ -0,0 +1,309 @@ +//! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]), +//! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a +//! font of its own. Type names a composite font as its family, and the font menus list them first. +//! The font database holds the active document's composite fonts ([`sync`]); a file exported here +//! carries them to another document. +//! +//! Behavior reference (public documentation): +//! . + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts}; +use vectorcraft_text::FontDb; + +use super::fileio::{read_file_head, write_file}; +use super::textstyles::each_text_mut; +use super::typecmd::refresh_bounds; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param}; + +/// The extension of the files Export writes and Import reads. +pub const EXTS: &[&str] = &["vccomposite"]; + +/// The largest file Import reads, in bytes. +const MAX_FILE_BYTES: usize = 4 << 20; + +/// The keys a part may give. +const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "type.compositeFonts.list", + "Composite Fonts", + [], + None, + "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}", + has_doc, + list + ), + cmd!( + "type.compositeFonts.set", + "Composite Fonts…", + ["Type"], + None, + "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", + has_doc, + set + ), + cmd!( + query "type.compositeFonts.export", + "Export Composite Fonts", + [], + None, + "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}", + has_doc, + export + ), + cmd!( + "type.compositeFonts.import", + "Import Composite Fonts", + [], + None, + "{path: .vccomposite file | data: its text | dataBase64: its bytes (at most 4 MB), apply?: bool = true} read the composite fonts a file holds (type.compositeFonts.export writes them); with apply they join the active document's, a font of the same name (any case) replaced, as one undo step, none when nothing changes; apply false only reads them → {fonts, imported: [name]}", + has_doc, + import + ), + ] +} + +/// Register the active document's composite fonts in the font database, and remove the ones the +/// other open documents (or an earlier active document) defined. Run after each command and when +/// another document takes the front, so an undo, a revert, an opened or closed file or a document +/// switch draws type in the active document's own composite fonts, and type naming one it does +/// not define shows as missing. Whether a composite font was added, changed or removed. +pub(crate) fn sync(s: &mut Session) -> bool { + let db = FontDb::global(); + let mut names = Vec::new(); + let mut changed = false; + for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() { + changed |= db.set_composite_font(&f); + names.push(f.name.to_lowercase()); + } + let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + let stale: Vec = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect(); + for gone in stale { + changed |= db.remove_composite_font(&gone); + } + s.composite_names = names; + changed +} + +/// Leave out a file's unusable composite fonts before registering and measuring its type. +pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) { + let db = FontDb::global(); + if let Some(kept) = CompositeFont::usable(&doc.composite_fonts) { + *doc.composite_fonts = kept; + } + doc.composite_fonts.retain(|f| !db.has_installed_family(&f.name)); +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "fonts": *s.doc()?.doc.composite_fonts })) +} + +/// Composite font `v` from command parameters or a file: its name trimmed, each part left out a +/// copy of the Kanji part, and keys or values a part can't take refused. +fn parse_font(v: &Value, c: &str) -> Result { + let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?; + if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) { + return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts"))); + } + let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?; + let name = CompositeFont::clean_name(raw) + .ok_or_else(|| bad(c, format!("`{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?; + let parts = o.get("parts").and_then(Value::as_object).ok_or_else(|| bad(c, format!("`{name}`: parts must be an object")))?; + if let Some(k) = parts.keys().find(|k| !CharClass::ALL.iter().any(|c| c.key() == k.as_str())) { + return Err(bad(c, format!("`{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals"))); + } + let part = |class: CharClass| -> Result> { + let Some(p) = parts.get(class.key()) else { return Ok(None) }; + let po = p.as_object().ok_or_else(|| bad(c, format!("`{name}`: {} must be an object", class.key())))?; + if let Some(k) = po.keys().find(|k| !PART_KEYS.contains(&k.as_str())) { + return Err(bad(c, format!("`{name}`: unknown key `{k}` in {}", class.key()))); + } + serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key()))) + }; + let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?; + let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) }; + for class in CharClass::ALL.into_iter().skip(1) { + if let Some(p) = part(class)? { + *f.parts.get_mut(class) = p; + } + } + f.check().map_err(|e| bad(c, format!("`{}`: {e}", f.name)))?; + Ok(f) +} + +/// Error unless `fonts` can stand together in a document: names unique in any case, none naming +/// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`]. +fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { + if fonts.len() > COMPOSITE_FONTS_MAX { + return Err(bad(c, format!("a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts"))); + } + let db = FontDb::global(); + let mut names = std::collections::HashSet::new(); + for f in fonts { + if !names.insert(f.name.to_lowercase()) { + return Err(bad(c, format!("two composite fonts are named `{}`", f.name))); + } + if db.has_installed_family(&f.name) { + return Err(bad(c, format!("`{}` is the name of an installed font", f.name))); + } + } + for f in fonts { + for class in CharClass::ALL { + let font = &f.parts.get(class).font; + if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() { + return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key()))); + } + } + } + Ok(()) +} + +/// Make `after` the active document's composite fonts, as one undo step named `label` (none when +/// they are what it has). The font database forgets the ones taken out, and type set in a font +/// that changed is measured again. +fn replace(s: &mut Session, label: &str, after: Vec) -> Result<()> { + let before = s.doc()?.doc.composite_fonts.to_vec(); + if before == after { + return Ok(()); + } + let changed: Vec = + before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect(); + let db = FontDb::global(); + for f in &before { + if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) { + db.remove_composite_font(&f.name); + } + } + for f in &after { + db.set_composite_font(f); + } + let r = s.edit(label, |d, _| { + *d.composite_fonts = after.clone(); + each_text_mut(d, &mut |t| { + if t.runs.iter().any(|r| changed.contains(&r.style.font_family.to_lowercase())) { + refresh_bounds(t); + } + Ok(()) + }) + }); + sync(s); + r +} + +fn set(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.set"; + let list = p.get("fonts").and_then(Value::as_array).ok_or_else(|| bad(C, "give fonts: [{name, parts}…]"))?; + let fonts = list.iter().map(|v| parse_font(v, C)).collect::>>()?; + check_set(&fonts, C)?; + replace(s, "Composite Fonts", fonts.clone())?; + Ok(json!({ "fonts": fonts })) +} + +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.export"; + let path = text_param(p, "path", C)?; + if path.is_some_and(|p| p.trim().is_empty()) { + return Err(bad(C, "path is empty")); + } + let all = match p.get("fonts") { + None | Some(Value::Null) => s.doc()?.doc.composite_fonts.to_vec(), + Some(Value::Array(list)) => { + let fonts = list.iter().map(|v| parse_font(v, C)).collect::>>()?; + check_set(&fonts, C)?; + fonts + } + Some(_) => return Err(bad(C, "fonts must be a list of composite fonts")), + }; + let fonts = match p.get("names") { + None | Some(Value::Null) => all, + Some(Value::Array(names)) => { + let mut out = Vec::with_capacity(names.len()); + for n in names { + let n = n.as_str().ok_or_else(|| bad(C, "names must be strings"))?; + let f = all + .iter() + .find(|f| CompositeFont::same_name(&f.name, n.trim())) + .ok_or_else(|| bad(C, format!("no composite font named `{n}`")))?; + if !out.contains(f) { + out.push(f.clone()); + } + } + out + } + Some(_) => return Err(bad(C, "names must be a list of names")), + }; + if fonts.is_empty() { + return Err(bad(C, "no composite fonts to export")); + } + let mut file = Map::new(); + file.insert("compositeFonts".into(), json!(fonts)); + let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?; + match path { + Some(path) => { + write_file(path, text.as_bytes())?; + Ok(json!({ "path": path, "count": fonts.len() })) + } + None => Ok(json!({ "data": text, "count": fonts.len() })), + } +} + +/// The composite fonts in the bytes of an exported file. +fn read_fonts(bytes: &[u8], c: &str) -> Result> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| bad(c, format!("not a composite fonts file: {e}")))?; + let list = v.get("compositeFonts").and_then(Value::as_array).ok_or_else(|| bad(c, "not a composite fonts file: no compositeFonts list"))?; + if list.len() > COMPOSITE_FONTS_MAX { + return Err(bad(c, format!("the file holds more than {COMPOSITE_FONTS_MAX} composite fonts"))); + } + list.iter().map(|f| parse_font(f, c)).collect() +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.import"; + let apply = switch_or(p, "apply", true, C)?; + let given = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?); + let too_large = || bad(C, "the file is larger than 4 MB"); + let bytes = match given { + (Some(path), None, None) if !path.trim().is_empty() => read_file_head(path, MAX_FILE_BYTES + 1)?, + (None, Some(text), None) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => { + if b64.len() > MAX_FILE_BYTES / 3 * 4 + 4 { + return Err(too_large()); + } + vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "dataBase64 is not base64"))? + } + _ => return Err(bad(C, "give one of path, data or dataBase64")), + }; + if bytes.len() > MAX_FILE_BYTES { + return Err(too_large()); + } + let fonts = read_fonts(&bytes, C)?; + if fonts.is_empty() { + return Err(bad(C, "the file holds no composite fonts")); + } + let mut names = std::collections::HashSet::new(); + if let Some(f) = fonts.iter().find(|f| !names.insert(f.name.to_lowercase())) { + return Err(bad(C, format!("the file holds two composite fonts named `{}`", f.name))); + } + if apply { + let mut after = s.doc()?.doc.composite_fonts.to_vec(); + for f in &fonts { + match after.iter_mut().find(|a| CompositeFont::same_name(&a.name, &f.name)) { + Some(a) => *a = f.clone(), + None => after.push(f.clone()), + } + } + check_set(&after, C)?; + replace(s, "Import Composite Fonts", after)?; + } else { + check_set(&fonts, C)?; + } + let imported: Vec<&str> = fonts.iter().map(|f| f.name.as_str()).collect(); + Ok(json!({ "fonts": fonts, "imported": imported })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs new file mode 100644 index 000000000..670a85ee2 --- /dev/null +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -0,0 +1,502 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +/// A composite font named `name`: Source Sans 3 for the base, JetBrains Mono at half size for +/// Roman, Inter for numerals. +fn font(name: &str) -> Value { + json!({"name": name, "parts": { + "kanji": {"font": "Source Sans 3"}, + "roman": {"font": "JetBrains Mono", "size": 50, "baseline": 10}, + "numerals": {"font": "Inter", "hScale": 80}, + }}) +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn names(s: &Session) -> Vec { + s.doc().unwrap().doc.composite_fonts.iter().map(|f| f.name.clone()).collect() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn text(s: &Session, id: u64) -> vectorcraft_doc::TextObject { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// A scratch folder of the test's own. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-composite-fonts-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Composite Fonts' OK replaces the document's composite fonts as one undo step (none when nothing +/// changes); parts left out copy the Kanji part; they are saved with the document and undone with +/// it. +#[test] +fn composite_fonts_are_set_as_one_undo_step_and_saved() { + let mut s = session(); + let before = steps(&s); + let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font(" CF Saved A "), font("CF Saved B")]})).unwrap(); + assert_eq!(r["fonts"][0]["name"], "CF Saved A"); + assert_eq!(names(&s), ["CF Saved A", "CF Saved B"]); + assert_eq!(steps(&s), before + 1); + let f = &s.doc().unwrap().doc.composite_fonts[0]; + assert_eq!((f.parts.kana.font.as_str(), f.parts.roman.size, f.parts.numerals.h_scale), ("Source Sans 3", 50.0, 80.0)); + // The same fonts again change nothing. + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Saved A"), font("CF Saved B")]})).unwrap(); + assert_eq!(steps(&s), before + 1); + let listed = s.execute("type.compositeFonts.list", &json!({})).unwrap(); + assert_eq!(listed["fonts"].as_array().map(Vec::len), Some(2)); + // They travel with the file. + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(names(&s).is_empty()); +} + +/// Bad fonts are refused with an error and no undo step: a name that is empty, shared (in any +/// case) or an installed font's; a part set in a composite font; an unknown class or key; a value +/// out of range, of the wrong type, or on a class that can't take it; no Kanji part. +#[test] +fn bad_composite_fonts_are_refused() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Refused Base")]})).unwrap(); + let before = steps(&s); + let with = |f: &dyn Fn(&mut Value)| { + let mut v = font("CF Refused"); + f(&mut v); + json!({"fonts": [v]}) + }; + for bad in [ + json!({}), + json!({"fonts": "CF"}), + json!({"fonts": [font(" ")]}), + json!({"fonts": [font("CF Twice"), font("cf twice")]}), + json!({"fonts": [font("Source Sans 3")]}), + json!({"fonts": [font("Tab\tName")]}), + with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused Base")), + with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused")), + with(&|v| v["parts"]["hangul"] = json!({"font": "Inter"})), + with(&|v| v["parts"]["Roman"] = json!({"font": "Inter"})), + with(&|v| v["parts"]["roman"]["width"] = json!(3)), + with(&|v| v["parts"]["roman"]["size"] = json!(5)), + with(&|v| v["parts"]["roman"]["size"] = json!("50")), + with(&|v| v["parts"]["roman"]["baseline"] = json!(-120)), + with(&|v| v["parts"]["kanji"]["hScale"] = json!(90)), + with(&|v| v["parts"]["punctuation"] = json!({"font": "Inter", "vScale": 90})), + with(&|v| v["parts"]["roman"]["scaleAtCenter"] = json!(true)), + with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "scaleAtCenter": "yes"})), + with(&|v| v["parts"]["roman"]["font"] = json!("")), + with(&|v| v["extra"] = json!(1)), + with(&|v| { + v["parts"].as_object_mut().unwrap().remove("kanji"); + }), + ] { + assert!(s.execute("type.compositeFonts.set", &bad).is_err(), "accepted {bad}"); + assert_eq!(names(&s), ["CF Refused Base"]); + assert_eq!(steps(&s), before); + } + // Kana scaled at the center is allowed. + let ok = with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true})); + s.execute("type.compositeFonts.set", &ok).unwrap(); + assert_eq!(names(&s), ["CF Refused"]); +} + +/// Type set in a composite font names it and is not missing; each class is drawn in its part's +/// font; changing the font measures the type again, and taking it out leaves the type naming it, +/// shown as missing. +#[test] +fn type_set_in_a_composite_font_follows_it() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Type")]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Type"})).unwrap(); + assert!(r.get("warning").is_none(), "{r}"); + let t = text(&s, id); + assert_eq!(t.runs[0].style.font_family, "CF Type"); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str| db.face(family, "Regular").unwrap().id(); + let lay = vectorcraft_text::layout(db, &t); + let fonts: Vec = lay.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face("JetBrains Mono"), face("JetBrains Mono"), face("Inter"), face("Inter")]); + let missing = |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().filter(|f| f["missing"] == true).count(); + assert_eq!(missing(&mut s), 0); + // Roman at full size makes the type wider, measured again in the same step. + let wide = t.cached_bounds.unwrap().width(); + let mut full = font("CF Type"); + full["parts"]["roman"]["size"] = json!(100); + let before = steps(&s); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + assert_eq!(steps(&s), before + 1); + assert!(text(&s, id).cached_bounds.unwrap().width() > wide + 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6); + assert_eq!(db.composite_font("CF Type").unwrap().parts.roman.size, 50.0); + // Taken out, the type keeps the name, now missing; undone, the font is back. + s.execute("type.compositeFonts.set", &json!({"fonts": []})).unwrap(); + assert!(db.composite_font("CF Type").is_none()); + assert_eq!(text(&s, id).runs[0].style.font_family, "CF Type"); + assert_eq!(missing(&mut s), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(db.composite_font("CF Type").is_some()); + assert_eq!(missing(&mut s), 0); +} + +/// Type in a symbol's art is measured again too when the composite font it names changes. +#[test] +fn type_in_symbol_art_follows_its_composite_font() { + fn width(s: &Session) -> f64 { + fn find(n: &vectorcraft_doc::Node) -> Option { + if let NodeKind::Text(t) = &n.kind { + return t.cached_bounds.map(|b| b.width()); + } + n.children().into_iter().flatten().find_map(|c| find(c)) + } + s.doc().unwrap().doc.symbols.iter().find_map(|sym| find(&sym.art)).unwrap() + } + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Symbol")]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Symbol"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Mark", "ids": [id]})).unwrap(); + let narrow = width(&s); + let mut full = font("CF Symbol"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + assert!(width(&s) > narrow + 1.0); +} + +/// The font database forgets a composite font once no open document defines it: after the step +/// that added it is undone, and after its document closes. +#[test] +fn composite_fonts_no_document_defines_are_forgotten() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Undo")]})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_some()); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Close")]})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Close").is_none()); + assert!(db.composite_font("CF Forgotten Undo").is_some()); +} + +/// SVG and legacy `.ai` write type in a composite font as its outlines, since a reader can't find +/// the font by its name. +#[test] +fn exports_outline_type_in_a_composite_font() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Export")]})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab1", "size": 20, "font": "CF Export"})).unwrap(); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(!svg.contains("CF Export") && !svg.contains(">(); + let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default()); + assert!(!svg.contains("CF Standalone") && !svg.contains(" = missing["fonts"].as_array().unwrap().iter().filter_map(|f| f["family"].as_str()).collect(); + assert_eq!(names, ["No Such Part Font"], "{missing}"); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Inventory").cloned().unwrap(); + assert_eq!((&row["missing"], &row["status"]), (&json!(true), &json!("missing")), "{row}"); + assert_eq!(row["resolved"]["family"], vectorcraft_text::FALLBACK_FAMILY, "{row}"); + // A run that starts with a combining mark after a run ending in kana: the mark is Roman. + let boundary = json!({"name": "CF Boundary", "parts": {"kanji": {"font": "Source Sans 3"}, "roman": {"font": "No Such Part Font"}}}); + s.execute("type.compositeFonts.set", &json!({"fonts": [boundary]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 170, "text": "\u{304B}\u{301}", "size": 20, "font": "CF Boundary"})).unwrap()["id"] + .as_u64() + .unwrap(); + let row = + |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|r| r["family"] == "CF Boundary").cloned(); + assert_eq!(row(&mut s).unwrap()["status"], "exact"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 5, "size": 30})).unwrap(); + assert_eq!(text(&s, id).runs.len(), 2); + assert_eq!(row(&mut s).unwrap()["status"], "missing"); + // A symbol the Kanji part's font draws but the Symbols part's font lacks. + let db = vectorcraft_text::FontDb::global(); + let (mono, sans) = (db.face("JetBrains Mono", "Regular").unwrap(), db.face("Source Sans 3", "Regular").unwrap()); + let c = ('\u{2190}'..='\u{2BFF}').find(|c| mono.covers(*c) && !sans.covers(*c)).expect("a symbol only JetBrains Mono draws"); + let glyphs = json!({"name": "CF Glyphs", "parts": {"kanji": {"font": "JetBrains Mono"}, "symbols": {"font": "Source Sans 3"}}}); + s.execute("type.compositeFonts.set", &json!({"fonts": [glyphs]})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 130, "text": c.to_string(), "size": 20, "font": "CF Glyphs"})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Glyphs").cloned().unwrap(); + assert_eq!(row["missingGlyphs"], 1, "{row}"); +} + +/// The Glyphs panel's alternates of a character in a composite font come from the font of the +/// part its grapheme cluster falls in. +#[test] +fn glyph_alternates_of_composite_type_come_from_the_characters_part() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Glyph Part")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 2})).unwrap(); + assert_eq!(r["font"], "Inter", "{r}"); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 0})).unwrap(); + assert_eq!(r["font"], "JetBrains Mono", "{r}"); + // A combining mark is drawn in the part of the character it follows. + let id = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "1\u{301}", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 1})).unwrap(); + assert_eq!(r["font"], "Inter", "{r}"); +} + +/// With two documents defining one name differently, the type is drawn in the front document's. +#[test] +fn the_front_document_defines_a_shared_name() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Front")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let mut other = font("CF Front"); + other["parts"]["roman"]["size"] = json!(75); + s.execute("type.compositeFonts.set", &json!({"fonts": [other]})).unwrap(); + let db = vectorcraft_text::FontDb::global(); + assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 75.0); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 50.0); +} + +/// A composite font only a document in the back defines is not available in the front one, where +/// type naming it shows as missing; a click on the back document's tab brings it back. +#[test] +fn a_font_only_a_back_document_defines_is_missing_in_front() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Only")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert!(db.composite_font("CF Back Only").is_none()); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Back Only"})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + assert!(fonts.as_array().unwrap().iter().any(|f| f["missing"] == true), "{fonts}"); + assert!(s.set_active(0)); + assert!(db.composite_font("CF Back Only").is_some()); +} + +/// A newly opened document measures a missing font without using the previous tab's composite +/// font of the same name. +#[test] +fn opened_document_measures_missing_composite_font_as_missing() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Previous Tab")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Previous Tab"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + doc.composite_fonts.clear(); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = None; + } + s.add_document(doc, None); + assert!(s.set_active(0)); + assert!(s.set_active(1)); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A loaded composite font cannot replace an installed family used by existing type. +#[test] +fn loaded_composite_font_cannot_take_an_installed_family_name() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + doc.composite_fonts.push(vectorcraft_doc::CompositeFont::new("Inter", "Source Sans 3", "Regular")); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = None; + } + s.add_document(doc, None); + assert!(names(&s).is_empty()); + assert!(vectorcraft_text::FontDb::global().composite_font("Inter").is_none()); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A file's composite fonts that Composite Fonts… would refuse (a value out of range, a name used +/// twice) are left out when the file opens. +#[test] +fn unusable_composite_fonts_in_a_file_are_left_out() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let mut wild = vectorcraft_doc::CompositeFont::new("CF Wild", "Source Sans 3", "Regular"); + wild.parts.roman.size = 5000.0; + let good = vectorcraft_doc::CompositeFont::new("CF Kept", "Source Sans 3", "Regular"); + let twice = vectorcraft_doc::CompositeFont::new("cf kept", "Inter", "Regular"); + *doc.composite_fonts = vec![wild, good, twice]; + s.add_document(doc, None); + assert_eq!(names(&s), ["CF Kept"]); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + assert!(db.composite_font("CF Wild").is_none()); + assert_eq!(db.composite_font("CF Kept").unwrap().parts.roman.font, "Source Sans 3"); +} + +/// Export writes the document's composite fonts (all, or those named) to a file; Import reads it: +/// with apply false it only returns them, otherwise they join the document's, replacing a font of +/// the same name, as one undo step. +#[test] +fn composite_fonts_export_and_import() { + let dir = folder("io"); + let path = dir.join("Mixed fonts (日本).vccomposite"); + let path = path.to_str().unwrap(); + let mut s = session(); + assert!(s.execute("type.compositeFonts.export", &json!({"path": path})).is_err(), "nothing to export"); + let mut slash = font("CF IO a/b: \"quoted\" 日本"); + slash["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true}); + s.execute("type.compositeFonts.set", &json!({"fonts": [slash, font("CF IO Other")]})).unwrap(); + assert!(s.execute("type.compositeFonts.export", &json!({"path": path, "names": ["CF IO Nope"]})).is_err()); + let r = s + .execute("type.compositeFonts.export", &json!({"path": path, "names": ["cf io a/b: \"quoted\" 日本", "CF IO a/b: \"quoted\" 日本"]})) + .unwrap(); + assert_eq!(r["count"], 1); + let saved = s.doc().unwrap().doc.composite_fonts[0].clone(); + + let mut t = session(); + let mut mine = font("CF IO a/b: \"quoted\" 日本"); + mine["parts"]["roman"]["size"] = json!(90); + t.execute("type.compositeFonts.set", &json!({"fonts": [mine, font("CF IO Kept")]})).unwrap(); + let before = steps(&t); + let read = t.execute("type.compositeFonts.import", &json!({"path": path, "apply": false})).unwrap(); + assert_eq!(read["fonts"].as_array().map(Vec::len), Some(1)); + assert_eq!(steps(&t), before); + t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap(); + assert_eq!(steps(&t), before + 1); + assert_eq!(names(&t), ["CF IO a/b: \"quoted\" 日本", "CF IO Kept"]); + assert_eq!(t.doc().unwrap().doc.composite_fonts[0], saved); + // The same file again changes nothing. + t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap(); + assert_eq!(steps(&t), before + 1); + + // Files that aren't composite fonts, or are too large, are refused. + for (file, bytes) in [ + ("bad.vccomposite", b"not json".to_vec()), + ("empty.vccomposite", br#"{"compositeFonts": []}"#.to_vec()), + ("twice.vccomposite", format!(r#"{{"compositeFonts": [{}, {}]}}"#, font("CF Dup"), font("cf dup")).into_bytes()), + ("large.vccomposite", vec![b' '; (4 << 20) + 1]), + ] { + let p = dir.join(file); + std::fs::write(&p, bytes).unwrap(); + assert!(t.execute("type.compositeFonts.import", &json!({"path": p.to_str().unwrap()})).is_err(), "{file}"); + assert_eq!(steps(&t), before + 1); + } + assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "apply": "no"})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"path": ""})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "data": "{}"})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"dataBase64": "%%%"})).is_err()); + + // Without a path, Export gives the file's text, which Import takes as data. + let data = s.execute("type.compositeFonts.export", &json!({"names": ["CF IO Other"]})).unwrap(); + assert_eq!(data["count"], 1); + let mut u = session(); + let r = u.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap(); + assert_eq!(r["imported"], json!(["CF IO Other"])); + assert_eq!(names(&u), ["CF IO Other"]); + // Fonts given are written in place of the document's; bad ones are refused. + let given = s.execute("type.compositeFonts.export", &json!({"fonts": [font("CF IO Given")]})).unwrap(); + assert!(given["data"].as_str().unwrap().contains("CF IO Given")); + assert!(s.execute("type.compositeFonts.export", &json!({"fonts": [font("")]})).is_err()); + assert!(s.execute("type.compositeFonts.export", &json!({"fonts": []})).is_err()); + assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 6860df149..407b20a1a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -743,7 +743,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let mut made = super::typecmd::with_overflow(s, id.as_slice(), made); if p.get("font").is_some() || p.get("style").is_some() { let runs = match id.and_then(|i| s.doc().ok()?.doc.node(i)).map(|n| &n.kind) { - Some(NodeKind::Text(t)) => t.runs.iter().map(|r| super::fonts::font_of(&r.style)).collect(), + Some(NodeKind::Text(t)) => t.runs.iter().flat_map(super::fonts::run_fonts).collect(), _ => Default::default(), }; if let Some(w) = super::fonts::set_font_warning(runs) { diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 5b1b4300b..a475bc743 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -256,7 +256,7 @@ pub fn specs() -> Vec { "Document Color Mode", ["File", "Document Color Mode"], None, - "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}", + "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}", has_doc, super::colormgmt::convert_mode ), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 19c64859e..bf6b673ff 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -794,11 +794,14 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let mut doc = Document::clone(chosen.as_ref().unwrap_or(&st.doc)); let (imported_from, import_losses, opened_by) = (st.imported_from.clone(), st.import_losses.clone(), st.opened_by.clone()); - if let Some(path) = st.path.as_deref() { + let base = super::links::LinkBase::of(st); + if base.doc.is_some() { // Files are looked for within the asking caller's roots, else within those of the caller that opened the source. let roots = crate::file_access::current().or_else(|| opened_by.clone()); - crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, path)); + crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, base)); } + // The copy's links keep their relative paths, which name places from the same file. + let links_from = base.relative.map(str::to_string); doc.drop_edit_modes(); if doc.pattern_edit.is_some() { super::patterncmds::finish_edit(&mut doc, &mut vectorcraft_doc::Selection::default())?; @@ -815,6 +818,7 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result { let st = s.doc_mut()?; st.imported_from = imported_from; st.import_losses = import_losses; + st.links_from = links_from; st.opened_by = st.opened_by.take().or(opened_by); st.mark_unsaved(); Ok(json!({ "index": i, "title": st.title() })) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 5799bca59..2bc1de185 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -564,7 +564,8 @@ impl ReadFile { /// The first half of [`open_bytes_with`], which needs no session (an app may do it on another /// thread): read and decode the file, read its linked files and lay out its type → what -/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from, +/// [`open_read`] opens. Type in the file's composite fonts is measured again once the document +/// opens and its composite fonts are registered. The linked files are looked for from `path`, where the file was read from, /// and modified ones are read again when `update_links` (Preferences › File Handling › Update /// Links set to Automatically; see [`crate::cmd::links::resolve`]). A file an importer plug-in /// reads (`importer` in `p`, or an extension only an importer lists) is read by it @@ -575,7 +576,7 @@ pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, Some(plugin) => super::importer::load(name, bytes, &plugin, p)?, None => load_with(name, bytes, &opts)?, }; - let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps); + let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); Ok(ReadFile { loaded, opts, path, links }) } @@ -595,7 +596,8 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st "only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first", )); } - let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); + let base = crate::cmd::links::LinkBase::file(path); + let links = crate::cmd::links::resolve(&mut loaded.doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true) } @@ -634,6 +636,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option = g - .gradient - .stops - .iter() - .map(|s| format!("{:.3} {:?}", s.offset, s.color.to_rgb().map(|v| (v * 20.0).round() * 5.0))) - .collect(); - all.insert(format!("{:?} {stops:?}", g.gradient.kind)); + let places: Vec = g.gradient.stops.iter().map(|s| format!("{:.3}", s.offset)).collect(); + let colors: Vec<([f32; 3], bool)> = + g.gradient.stops.iter().map(|s| (s.color.to_rgb(), s.swatch.is_some() && s.tint < 1.0)).collect(); + all.insert(format!("{:?} {places:?}", g.gradient.kind), colors); } } }); all }; - assert_eq!(gradients(&legacy.doc), gradients(&before)); + let (got, want) = (gradients(&legacy.doc), gradients(&before)); + assert_eq!(got.keys().collect::>(), want.keys().collect::>()); + for (key, stops) in &want { + for (i, (rgb, spot)) in stops.iter().enumerate() { + let back = got[key][i].0; + let most = if *spot { 0.1 } else { 0.05 }; + assert!((0..3).all(|c| (back[c] - rgb[c]).abs() < most), "{key} stop {i}: {back:?} for {rgb:?}"); + } + } } diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index cadbd6509..309347985 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Missing Fonts", [], None, - "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols, that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", + "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols (for type in a composite font, the fonts of the parts its characters are drawn in), that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", has_doc, missing ), @@ -103,8 +103,8 @@ pub struct MissingFont { pub resolved: Option<(String, String)>, } -/// The fonts the type of `d` uses, in its layers and its symbols, that aren't available as named -/// (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` +/// The fonts the type of `d` uses, in its layers and its symbols (a composite font's parts, those +/// its characters are drawn in, in place of its name), that aren't available as named (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` /// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out. pub fn missing_fonts(d: &Document) -> Vec { let db = FontDb::global(); diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ba91f87fa..2a6d1cb0d 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fonts in Document", [], None, - "{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)", + "{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font). For a composite font, status is the worst among the parts its characters are drawn in, and missingGlyphs counts the characters their part's font lacks", has_doc, fonts ), @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Replace Font", [], None, - "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", + "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name, a composite font's as its own name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", has_doc, replace ), @@ -208,7 +208,18 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String { } } -/// The fonts (family, style, version) of the type in the layers and symbols of `d`. +/// The fonts (family, style, version) run `r` is drawn in: its own, or, set in a composite font, +/// the fonts of the parts its characters fall in ([`vectorcraft_text::FontDb::composite_fonts_for`]). +pub(super) fn run_fonts(r: &TextRun) -> Vec { + let parts = if r.inline.is_some() { None } else { vectorcraft_text::FontDb::global().composite_fonts_for(&r.style.font_family, &r.text) }; + match parts { + Some(parts) => parts.into_iter().map(|(family, style)| (family, style, None)).collect(), + None => vec![font_of(&r.style)], + } +} + +/// The fonts (family, style, version) the type in the layers and symbols of `d` is drawn in, a +/// composite font's parts in place of its name ([`run_fonts`]). pub(super) fn used_fonts(d: &Document) -> BTreeSet { art_fonts(d, &[]) } @@ -219,7 +230,7 @@ pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet { let mut fonts = BTreeSet::new(); let mut add = |n: &Node| { if let NodeKind::Text(t) = &n.kind { - fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()))); + fonts.extend(t.runs.iter().flat_map(run_fonts)); } }; d.walk(&mut add); @@ -344,7 +355,7 @@ impl Printed<'_> { self.overflow.insert(n.id); } for r in runs { - self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())); + self.fonts.extend(run_fonts(r)); // Inline graphics draw their symbols' art. if let Some(art) = &r.inline { self.symbol(&art.symbol); @@ -546,14 +557,15 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result { let symbols = if selected.is_some() { Some(selected_symbols(s, selected)?.0) } else { None }; let mut texts: Vec<&TextObject> = ids.iter().filter_map(|id| text(d, *id)).collect(); texts.extend(symbol_texts(d, symbols.as_ref()).into_iter().map(|(t, _)| t)); - let mut found: BTreeMap = BTreeMap::new(); + // Each font's runs, objects and the text of its runs. + let mut found: BTreeMap)> = BTreeMap::new(); for t in texts { let mut seen = BTreeSet::new(); for r in &t.runs { let font = (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()); let e = found.entry(font.clone()).or_default(); e.0 += 1; - e.2.push_str(&r.text); + e.2.push(&r.text); if seen.insert(font) { e.1 += 1; } @@ -561,14 +573,27 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result { } let list: Vec = found .into_iter() - .map(|((family, style, version), (runs, objects, chars))| { + .map(|((family, style, version), (runs, objects, texts))| { // The face the type is set in: the version it names when that one is installed. - let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); - let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); - let mut lacking: Vec = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); - lacking.sort_unstable(); - lacking.dedup(); - lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); + let mut resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); + let (status, lacking) = match db.composite_coverage(&family, &texts) { + // A composite font: each character is drawn by the font of the part it falls in, and + // the row names the face of the first part that isn't there as named. + Some(c) => { + if c.status != vectorcraft_text::FontMatch::Exact { + resolved = c.face.map(|f| (f, c.status)); + } + (c.status, c.lacking) + } + None => { + let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); + let mut lacking: Vec = texts.concat().chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); + lacking.sort_unstable(); + lacking.dedup(); + lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); + (status, lacking) + } + }; let mut row = json!({ "family": family, "style": style, "runs": runs, "objects": objects, "missing": status == vectorcraft_text::FontMatch::Missing, @@ -612,10 +637,11 @@ fn replace(s: &mut Session, p: &Value) -> Result { let (ff, fs) = font_param(p, "from", C)?; let (tf, ts) = font_param(p, "to", C)?; let db = vectorcraft_text::FontDb::global(); - // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans). - let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) { - Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(), - _ => return Err(bad(C, format!("font family `{tf}` is not available"))), + // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans), and a + // composite font as its own name. + let composite = db.composite_font(&tf).is_some(); + let Some(tf) = db.stored_family(&tf, ts.as_deref().unwrap_or("Regular")) else { + return Err(bad(C, format!("font family `{tf}` is not available"))); }; let matches = |st: &vectorcraft_doc::CharStyle| { st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x)) @@ -626,8 +652,10 @@ fn replace(s: &mut Session, p: &Value) -> Result { if !matches(st) { return None; } - // An explicit style, else the closest the new family has to the old one. - let style = ts.clone().or_else(|| db.face(&tf, &st.font_style).map(|f| f.style.clone())).unwrap_or_else(|| st.font_style.clone()); + // An explicit style, else the closest the new family has to the old one (a composite font's + // parts name their own styles: the run keeps its style). + let closest = || if composite { None } else { db.face(&tf, &st.font_style).map(|f| f.style.clone()) }; + let style = ts.clone().or_else(closest).unwrap_or_else(|| st.font_style.clone()); (st.font_family != tf || st.font_style != style || st.font_version.is_some() || !st.font_axes.is_empty()).then_some(style) }; // Only text the Selection tool can reach changes: not hidden or locked, in a hidden or locked @@ -674,7 +702,7 @@ fn replace(s: &mut Session, p: &Value) -> Result { // Another font: none of the old one's versions or Variable Font settings. r.style.font_version = None; r.style.font_axes.clear(); - set.insert(font_of(&r.style)); + set.extend(run_fonts(r)); changed = true; n += 1; } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index ff3cb8c87..22d61723e 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -23,7 +23,7 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Component, Path}; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use serde_json::{Value, json}; @@ -219,6 +219,30 @@ struct Found { modified: Option, /// Read when telling whether it changed needed them. bytes: Option>, + /// `path` relative to the folder of [`LinkBase::relative`], when [`locate`] had that file. + relative: Option>, +} + +/// The files a document's links are looked for from: `doc`, whose folder a link's path is followed +/// from and its file is looked for by name in, and `relative`, the file its links' stored relative +/// paths name places from (the file the document was read from). +#[derive(Clone, Copy, Debug, Default)] +pub struct LinkBase<'a> { + pub doc: Option<&'a str>, + pub relative: Option<&'a str>, +} + +impl<'a> LinkBase<'a> { + /// Both the file at `path`. + pub fn file(path: Option<&'a str>) -> Self { + Self { doc: path, relative: path } + } + + /// The open document `st`'s. One with no file of its own looks beside the file it was read from. + pub(crate) fn of(st: &'a crate::DocState) -> Self { + let (path, from) = (st.path.as_deref(), st.links_from.as_deref()); + Self { doc: path.or(from), relative: from.or(path) } + } } impl Found { @@ -237,7 +261,17 @@ impl Found { /// [`Self::link`] with the bytes' hash already taken. fn link_hashed(&self, hash: String, base: &LinkInfo) -> LinkInfo { - LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash), ..base.clone() } + LinkInfo { size: Some(self.size), modified: self.modified, hash: Some(hash), ..self.moved(base) } + } + + /// `base` pointing at this file. A file found away from `base`'s path takes its relative path + /// from where it was found. + fn moved(&self, base: &LinkInfo) -> LinkInfo { + let relative = match &self.relative { + Some(found) if self.path != base.path => found.clone(), + _ => base.relative.clone(), + }; + LinkInfo { path: self.path.clone(), relative, ..base.clone() } } } @@ -255,15 +289,41 @@ pub(crate) fn link_info(path: &str, bytes: &[u8]) -> LinkInfo { /// The file at `path` when there is one. fn found_at(path: String) -> Option { let (size, modified) = file_stamp(&path)?; - Some(Found { path, size, modified, bytes: None }) + Some(Found { path, size, modified, bytes: None, relative: None }) } -/// Where the file of `link` (stored in the document saved at `doc_path`) is: at its path, else at -/// its relative path or by name in the document's folder. Only paths -/// [`fileio::document_path`] follows are looked at. -fn locate(link: &LinkInfo, doc_path: Option<&str>) -> Option { - let stored = [Some(link.path.as_str()), link.relative.as_deref(), Some(link.name())]; - stored.into_iter().flatten().filter_map(|p| fileio::document_path(p, doc_path).ok()).find_map(found_at) +/// Where the file of `link` (of a document looking from `base`) is: at its path, else at its +/// relative path from the folder of [`LinkBase::relative`], then from the document's folder, else +/// by name in the document's folder, then in that of [`LinkBase::relative`]. Only paths +/// [`fileio::document_path`] follows from the document's own file are looked at. +fn locate(link: &LinkInfo, base: LinkBase) -> Option { + let (relative, other) = (link.relative.as_deref(), base.doc != base.relative); + let stored = [ + (Some(link.path.as_str()), base.doc), + (relative, base.relative), + (relative.filter(|_| other), base.doc), + (Some(link.name()), base.doc), + (other.then_some(link.name()), base.relative), + ]; + let mut f = stored.into_iter().filter_map(|(p, from)| follow_from(p?, from, base.doc, fileio::document_path)).find_map(found_at)?; + f.relative = base.relative.map(|from| { + let from = absolute_path(from); + Path::new(&from).parent().and_then(|dir| relative_path(Path::new(&f.path), dir)) + }); + Some(f) +} + +/// The place `stored` names from the file `from`, as `resolve` ([`fileio::document_path`]) follows +/// it, when it also follows that place from the document's own file `doc`: a network share the +/// document is no longer on is refused. +pub(crate) fn follow_from( + stored: &str, + from: Option<&str>, + doc: Option<&str>, + resolve: impl Fn(&str, Option<&str>) -> std::result::Result, +) -> Option { + let path = resolve(stored, from).ok()?; + if from == doc { Some(path) } else { resolve(&path, doc).ok() } } /// Why the path of `link` (stored in the document saved at `doc_path`) isn't followed, when it @@ -272,10 +332,10 @@ fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option) -> (Status, Option) { - let Some(mut f) = locate(&g.link, doc_path) else { return (Status::Missing, None) }; +fn probe(g: &Group, base: LinkBase) -> (Status, Option) { + let Some(mut f) = locate(&g.link, base) else { return (Status::Missing, None) }; let l = &g.link; if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified { return (Status::Ok, Some(f)); @@ -328,9 +388,9 @@ pub(crate) struct LinkedFile { pub bytes: Option>, } -/// Every file the linked images of `d` (saved at `doc_path`) show, in the layers, symbols and +/// Every file the linked images of `d` (looking from `base`) show, in the layers, symbols and /// patterns, once each. -pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec { +pub(crate) fn linked_files(d: &Document, base: LinkBase) -> Vec { let mut seen = std::collections::BTreeSet::new(); groups(d, None) .into_iter() @@ -338,7 +398,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec) -> Vec(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec) { +pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a, Document>, Vec) { let mut keys = BTreeSet::new(); d.visit_images(|_, im| { if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) { @@ -363,7 +423,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> let all = groups(d, None); for key in keys { let Some(g) = all.iter().find(|g| g.document.is_none() && g.key == key) else { continue }; - let Some(mut f) = locate(&g.link, doc_path) else { + let Some(mut f) = locate(&g.link, base) else { previews.insert(key); continue; }; @@ -398,7 +458,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec)> { groups(&st.doc, ids) .into_iter() - .filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, st.path.as_deref()).0 == Status::Missing)) + .filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, LinkBase::of(st)).0 == Status::Missing)) .map(|g| (g.link, g.ids)) .collect() } @@ -847,13 +907,13 @@ impl Resolved { } } -/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read -/// again (their images had the preview), modified ones are read again when `update`, links found -/// away from their path take the new path. A link nothing is known of whose image, or placed +/// The linked files of document `d`, just read and looking from `base`: those found unchanged are +/// read again (their images had the preview), modified ones are read again when `update`, links +/// found away from their path take the new path. A link nothing is known of whose image, or placed /// document, shows a preview (a file another app wrote kept only that) was never read: its file is /// read now, into the object's bounds, which isn't a change to report. A placed document whose /// file's bytes are gone isn't showing a preview. Nothing here is an undo step. -pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved { +pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved { let mut out = Resolved::default(); // The image (with its preview) and hash of each file read for a preview here, by path: images // of other keys linking to the same file share them, rather than each reading, decoding and @@ -863,14 +923,14 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p // away from its path or read again. let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![]; for g in groups(d, None) { - let row = rows(std::iter::once(&g), doc_path); + let row = rows(std::iter::once(&g), base.doc); let shows_preview = match g.document { None => previewing(d, &g.key), Some(_) => d.images.get(&g.key).is_some_and(ImageBlob::is_proxy), }; let unread = g.link.hash.is_none() && g.link.size.is_none() && shows_preview; let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d); - match probe(&g, doc_path) { + match probe(&g, base) { (Status::Missing, _) => out.missing.extend(row), (Status::Modified, Some(mut f)) if update || unread => { let force = unread.then_some(Preserve::Bounds); @@ -894,7 +954,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p None => { let link = match f.bytes.take() { Some(b) => f.link(&b, &g.link), - None => LinkInfo { path: f.path.clone(), ..g.link.clone() }, + None => f.moved(&g.link), }; if link != g.link { set_links(d, &g.ids, &link); @@ -932,7 +992,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p // Found away from its path, or read again: the link follows. let link = match &bytes { Some(b) => f.link(b, &g.link), - None => LinkInfo { path: f.path.clone(), ..g.link.clone() }, + None => f.moved(&g.link), }; if link != g.link { set_links(d, &g.ids, &link); @@ -1035,42 +1095,68 @@ fn relative_path(target: &Path, base: &Path) -> Option { Some(ups.chain(rest).collect::>>().join("/")) } -/// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they -/// are already. Links in the layers, symbols, graph designs, patterns and opacity masks are all set. -pub fn with_relative_paths(d: &Document, dest: &str) -> Option { - let dest = absolute_path(dest); - let dir = Path::new(&dest).parent()?; - let relative = |l: &LinkInfo| relative_path(Path::new(&l.path), dir); - let outdated = |l: &LinkInfo| relative(l) != l.relative; - let mut changed = false; - d.visit_images(|_, im| changed |= im.link.as_ref().is_some_and(outdated)); - d.visit_placed(|_, p| changed |= outdated(&p.link)); - if !changed { - return None; +/// The file `link` (of a document looking from `base`) is written relative to: where [`locate`] +/// finds it, else the place its relative path names from the folder of [`LinkBase::relative`], +/// else its path. +fn link_target(link: &LinkInfo, base: LinkBase) -> PathBuf { + if let Some(f) = locate(link, base) { + return PathBuf::from(f.path); + } + match (base.relative, link.relative.as_deref()) { + (Some(_), Some(rel)) => fileio::document_path(rel, base.relative).map_or_else(|_| PathBuf::from(&link.path), PathBuf::from), + _ => PathBuf::from(&link.path), } - let mut d = d.clone(); - d.update_links_where(outdated, |l| l.relative = relative(l)); - Some(d) } -/// Sets each link of `d`, a copy of the document saved at `doc_path` that has no file of its own, -/// to the path where that document finds its file, so the copy finds the same files. -/// Links in the layers, symbols, graph designs, patterns and opacity masks are all set. -pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) { - // The new path of each link by what `locate` reads of it: its path, relative path and name. - let mut found: BTreeMap<(String, Option), Option> = BTreeMap::new(); +/// `d` (looking from `base`) as saved to `dest`: the links' paths relative to its folder written +/// in; `None` when they are already. A link whose file is not at its path is written relative to +/// where its file is found, else to the place its relative path names. Links in the layers, +/// symbols, graph designs, patterns and opacity masks are all set. +pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option { + let dest = absolute_path(dest); + let dir = Path::new(&dest).parent()?; + // The relative path of each link by its path and stored relative path, each file looked at once. + let mut wanted: BTreeMap<(String, Option), Option> = BTreeMap::new(); let mut look = |l: &LinkInfo| { - found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| locate(l, Some(doc_path)).map(|f| f.path)); + wanted.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| relative_path(&link_target(l, base), dir)); }; d.visit_images(|_, im| im.link.iter().for_each(&mut look)); d.visit_placed(|_, p| look(&p.link)); - found.retain(|(path, _), f| f.as_ref().is_some_and(|f| f != path)); + wanted.retain(|(_, stored), rel| rel != stored); + if wanted.is_empty() { + return None; + } + let key = |l: &LinkInfo| (l.path.clone(), l.relative.clone()); + let mut d = d.clone(); + d.update_links_where(|l| wanted.contains_key(&key(l)), |l| l.relative = wanted.get(&key(l)).cloned().flatten()); + Some(d) +} + +/// Sets each link of `d`, a copy of a document looking from `base`, to the path where that +/// document finds its file, and its relative path to it as that document's links have them, so +/// the copy finds the same files. Links in the layers, symbols, graph designs, patterns and opacity +/// masks are all set. +pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { + // The new path and relative path of each link by what `locate` reads of it: its path, relative + // path and name. + type PathAndRelative = (String, Option); + let mut found: BTreeMap> = BTreeMap::new(); + let mut look = |l: &LinkInfo| { + found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| { + let moved = locate(l, base)?.moved(l); + Some((moved.path, moved.relative)) + }); + }; + d.visit_images(|_, im| im.link.iter().for_each(&mut look)); + d.visit_placed(|_, p| look(&p.link)); + found.retain(|(path, _), f| f.as_ref().is_some_and(|(f, _)| f != path)); if found.is_empty() { return; } d.update_links(|l| { - if let Some(Some(f)) = found.get(&(l.path.clone(), l.relative.clone())) { - l.path = f.clone(); + if let Some(Some((path, relative))) = found.get(&(l.path.clone(), l.relative.clone())) { + l.path = path.clone(); + l.relative = relative.clone(); } }); } @@ -1083,11 +1169,11 @@ fn check(s: &mut Session, p: &Value) -> Result { let (mut missing, mut modified) = (0, 0); let mut links = vec![]; for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) { - let (status, found) = probe(g, st.path.as_deref()); + let (status, found) = probe(g, LinkBase::of(st)); missing += usize::from(status == Status::Missing); modified += usize::from(status == Status::Modified); let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) }); - note_refused(&mut row, &g.link, st.path.as_deref()); + note_refused(&mut row, &g.link, LinkBase::of(st).doc); if let Some(f) = found.filter(|f| f.path != g.link.path) { row["found"] = json!(f.path); } @@ -1112,7 +1198,7 @@ fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) - let (mut reads, mut missing) = (vec![], vec![]); let asked = ids.is_some() || path.is_some(); for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) { - match probe(&g, st.path.as_deref()) { + match probe(&g, LinkBase::of(st)) { (Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)), // Unchanged and showing the file: nothing to read unless asked for. (Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {} @@ -1232,7 +1318,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { let selected: Vec = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect(); match folder_param { Some(_) if selected.is_empty() => { - groups(&st.doc, None).into_iter().filter(|g| probe(g, st.path.as_deref()).0 == Status::Missing).flat_map(|g| g.ids).collect() + groups(&st.doc, None).into_iter().filter(|g| probe(g, LinkBase::of(st)).0 == Status::Missing).flat_map(|g| g.ids).collect() } _ => selected, } @@ -1368,7 +1454,7 @@ fn missing_in_folder( let Some(dir) = files.first().and_then(|(_, p)| Path::new(p).parent()).map(Path::to_path_buf) else { return }; for g in groups(&st.doc, None).into_iter().filter(|g| !(images_only && g.document.is_some())) { let rest: Vec = g.ids.iter().copied().filter(|id| !given.contains(id)).collect(); - if rest.is_empty() || probe(&g, st.path.as_deref()).0 != Status::Missing { + if rest.is_empty() || probe(&g, LinkBase::of(st)).0 != Status::Missing { continue; } let found = absolute_path(&dir.join(g.link.name()).to_string_lossy()); @@ -1482,7 +1568,7 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap, bool)> { let mut out = BTreeMap::new(); for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) { - let (status, found) = probe(g, st.path.as_deref()); + let (status, found) = probe(g, LinkBase::of(st)); let found = found.map(|f| f.path).filter(|p| *p != g.link.path); let preview = stale(&st.doc, g); out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview)))); @@ -1625,7 +1711,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { done.extend(g.ids); continue; } - match probe(&g, st.path.as_deref()) { + match probe(&g, LinkBase::of(st)) { (Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) { Ok(read) => { done.extend(&g.ids); @@ -1714,8 +1800,9 @@ fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) } fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { - let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0); - let doc_path = st.path.as_deref(); + let base = LinkBase::of(st); + let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0); + let doc_path = base.doc; let path = fileio::document_path(&l.path, doc_path).ok(); let stamp = path.as_deref().and_then(file_stamp); out["status"] = json!(status.as_str()); diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index e102bf1f7..673ad2cbb 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -13,6 +13,7 @@ use vectorcraft_doc::{Document, LinkInfo}; use serde_json::{Value, json}; use super::fileio::{create_dir, write_file}; +use super::links::LinkBase; use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, str_param, unique_name}; const C: &str = "file.package"; @@ -187,8 +188,18 @@ impl<'a> Collector<'a> { } /// Collects the files `doc` links. `by_name`: `doc` is a placed `.ai` file's editing data, - /// whose links are not rewritten, so its copy finds them by name beside it. - fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize, by_name: bool) -> Result<()> { + /// whose links are not rewritten, so its copy finds them by name beside it. `lookup`: the + /// document whose links (with the same paths as `doc`'s) are looked for, and where from, when + /// not `doc` from `source`. + fn collect( + &mut self, + doc: &mut Document, + source: &str, + destination: &str, + depth: usize, + by_name: bool, + lookup: Option<(&Document, LinkBase)>, + ) -> Result<()> { // The caller checks cycles and depth before descending; active documents have // a destination reserved so back-links can point at their single packaged copy. self.visiting.insert(source.to_string(), destination.to_string()); @@ -210,6 +221,10 @@ impl<'a> Collector<'a> { // The fonts of the type on the PDF and `.ai` pages placed linked. let mut placed_fonts = BTreeSet::new(); let mut placed = BTreeMap::::new(); + let linked_files = |doc: &Document| match lookup { + Some((d, base)) => super::links::linked_files(d, base), + None => super::links::linked_files(doc, LinkBase::file(Some(source))), + }; doc.visit_placed(|_, p| { let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box); let pages = placed.entry(p.link.path.clone()).or_default(); @@ -218,7 +233,7 @@ impl<'a> Collector<'a> { } }); if self.opts.copy_fonts && !self.opts.copy_links && !placed.is_empty() { - for file in super::links::linked_files(doc, Some(source)) { + for file in linked_files(doc) { if let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) { placed_fonts.extend(page_fonts(&file.name, bytes, file.found_path.as_deref().unwrap_or(&file.path), pages)); } @@ -226,7 +241,7 @@ impl<'a> Collector<'a> { } if self.opts.copy_links { heading(&mut lines, "LINKED FILES"); - for file in super::links::linked_files(doc, Some(source)) { + for file in linked_files(doc) { let found_path = file.found_path.as_deref().unwrap_or(&file.path); if let Some(ancestor) = self.visiting.get(found_path).cloned() { // The ancestor is already included (possibly the root document). A @@ -272,7 +287,7 @@ impl<'a> Collector<'a> { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); } else { - self.collect(&mut art, found_path, &destination_file, depth + 1, true)?; + self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?; } } else if self.opts.copy_fonts && let Some(pages) = pages @@ -289,7 +304,7 @@ impl<'a> Collector<'a> { let include_linked = saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy())); let mut nested = saved.doc; - self.collect(&mut nested, found_path, &destination_file, depth + 1, false)?; + self.collect(&mut nested, found_path, &destination_file, depth + 1, false, None)?; if self.opts.relink { #[cfg(test)] PARTS_READ.with(|c| c.set(c.get() + 1)); @@ -405,6 +420,8 @@ fn package(s: &mut Session, p: &Value) -> Result { pub struct Package { o: Options, doc_path: String, + /// The file the document's links' stored relative paths are taken from. + links_from: String, stem: String, name: String, root: Option, @@ -450,7 +467,8 @@ fn prepare(s: &mut Session, p: &Value) -> Result { let root = folder.map(|f| Path::new(f).join(&name)); let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None }; let doc = s.doc()?.doc.clone(); - Ok(Package { o, doc_path, stem, name, root, info, doc }) + let links_from = s.doc()?.links_from.clone().unwrap_or_else(|| doc_path.clone()); + Ok(Package { o, doc_path, links_from, stem, name, root, info, doc }) } impl Package { @@ -464,18 +482,27 @@ impl Package { /// [`Package::write`]. fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { - let Package { o, doc_path, stem, name, root, info, doc } = package; - let mut doc = (*doc).clone(); + let Package { o, doc_path, links_from, stem, name, root, info, doc: original } = package; + let mut doc = (*original).clone(); let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION); + let base = LinkBase { doc: Some(&doc_path), relative: Some(&links_from) }; + // After a save in another folder, links found away from their paths take the paths where they + // were found, as a copy of the document has them. + let found = (links_from != doc_path).then(|| { + super::links::with_found_paths(&mut doc, base); + doc.clone() + }); + let looked = found.as_ref().unwrap_or(&original); // Prepare original links for the destination *before* rebasing copied links. // Recalculating afterward would overwrite the portable relative paths. if let Some(root) = &root - && let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy()) + && let Some(d) = super::links::with_relative_paths(&doc, base, &root.join(&doc_name).to_string_lossy()) { doc = d; } let mut collector = Collector::new(&o, root.clone(), &doc_name); - collector.collect(&mut doc, &doc_path, &doc_name, 0, false)?; + // The files are looked for where the links name them before the rebasing above. + collector.collect(&mut doc, &doc_path, &doc_name, 0, false, Some((looked, base)))?; let mut entries = std::mem::take(&mut collector.entries); entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc))); let mut taken = std::mem::take(&mut collector.taken); diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 40d176ab8..db9bbf67d 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -254,7 +254,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result (Cow<'_, Document>, Vec) { - let (d, mut warnings) = super::super::links::full_linked_images(d, None); + let (d, mut warnings) = super::super::links::full_linked_images(d, Default::default()); let doc = match &d { Cow::Borrowed(doc) => doc, Cow::Owned(doc) => doc, diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 8b5731b33..a06597ac8 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -575,6 +575,10 @@ struct Meta { /// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it. imported_from: Option, import_losses: Vec, + /// The file the links' relative paths name places from, for a copy without `path` (see + /// [`crate::DocState::links_from`]). + #[serde(skip_serializing_if = "Option::is_none")] + links_from: Option, /// The document's images by key, in entries of the area (none in copies written before /// images were kept apart: the document entry holds them). #[serde(skip_serializing_if = "BTreeMap::is_empty")] @@ -844,6 +848,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, file: &str) -> R if doc.title.is_empty() { doc.title = if meta.title.is_empty() { stem.to_string() } else { meta.title.clone() }; } - // Linked files are looked for from the document's own folder. - let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); + // Linked files are looked for from the document's own folder, else from the file it was read from. + let links_from = meta.path.clone().or_else(|| meta.links_from.clone()); + let base = super::links::LinkBase::file(links_from.as_deref()); + let links = super::links::resolve(&mut doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); let index = s.add_document(doc, meta.path.clone()); let st = s.doc_mut()?; st.recovered = true; + st.links_from = links_from; st.imported_from = meta.imported_from.clone(); st.import_losses = meta.import_losses.clone(); st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft"); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index d8514ef56..4274ce6d5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1068,7 +1068,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { if sets_font { // Empty text has one run, which the empty range styles. Characters that kept their font aren't the command's. let styled = if a == b { t.runs.clone() } else { unprotected_runs(&t.runs, a, b, &kept) }; - set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); + set.extend(styled.iter().flat_map(super::fonts::run_fonts)); } refresh_bounds(t); n = if *f == id { t.runs.len() } else { n }; @@ -1140,7 +1140,12 @@ fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::R return Err(bad(c, "an inline graphic has no glyphs")); } let db = vectorcraft_text::FontDb::global(); - let face = db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)); + let face = match db.composite_resolve_at(&run.style.font_family, &run.text, start - at) { + // Type in a composite font: the character is drawn in the font of the part its + // grapheme cluster falls in. + Some((face, m)) => (m != vectorcraft_text::FontMatch::Missing).then_some(face), + None => db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)), + }; let face = face.ok_or_else(|| bad(c, format!("the font {} is not available", run.style.font_family)))?; return Ok((ch, start..start + ch.len_utf8(), face)); } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 17b2f0f0c..68394e7fd 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -327,7 +327,7 @@ fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map /// Change text in layers, symbols, graph designs, pattern tiles, saved pattern tiles restored by /// Cancel, and brush art, including opacity masks in those trees. -fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> { +pub(crate) fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> { fn has_text(n: &Node) -> bool { matches!(n.kind, NodeKind::Text(_)) || n.extras().mask.as_ref().is_some_and(|mask| has_text(&mask.art)) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b349ef894..c5663b58a 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -919,7 +919,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { super::textedit::unprotected_runs(&t.runs, a, b, &kept) } }; - set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); + set.extend(styled.iter().flat_map(super::fonts::run_fonts)); } if let Some(j) = justify { let span = para_span(range, t); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 872ceee4b..58b4e2e6b 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -56,7 +56,7 @@ pub fn specs() -> Vec { "Type on a Path Options…", ["Type", "Type on a Path"], None, - "{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}", + "{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}", has_selection, path_options ), diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 0baab9654..e70bf5c2d 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -95,6 +95,11 @@ pub struct DocState { /// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved. pub imported_from: Option, pub import_losses: Vec, + /// The file its links' stored relative paths name places from: the file the document was read + /// from, also when that file is not its own (such as a `.ai` file another app wrote). A save + /// writes the saved file's relative paths and leaves the open document's links as they are. + /// `None`: the document's own file. Not saved ([`crate::cmd::links::LinkBase`]). + pub links_from: Option, /// Transform Again after a perspective move or scale (Perspective Selection tool): the /// `perspective.transform` params it repeats. `None` once an ordinary transform follows. pub last_perspective: Option, @@ -296,6 +301,7 @@ impl DocState { layers_open, imported_from: None, import_losses: vec![], + links_from: None, last_perspective: None, opened_by: file_access::current(), empty_artboards: Default::default(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9533158d..27972ef20 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -172,6 +172,9 @@ pub struct Session { /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest /// history step takes the colors the batch finally left. pub(crate) batch_paint_docs: Vec, + /// The composite fonts (lowercase names) that the open documents defined when the font + /// database was last brought in step with them ([`cmd::compositefonts::sync`]). + pub(crate) composite_names: Vec, /// While a command that changes nothing in the document runs (a query, or one of /// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol /// instance's art. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d4859cdf1..b0619c992 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -83,6 +83,7 @@ impl Session { batch_stash: None, batch_steps: None, batch_paint_docs: Vec::new(), + composite_names: Vec::new(), reading: false, place_hand_off: None, queue_hand_off: None, @@ -176,10 +177,16 @@ impl Session { pub fn set_active(&mut self, index: usize) -> bool { if index < self.docs.len() { - if self.active != Some(index) { + let switched = self.active != Some(index); + if switched { self.reset_tool_for_doc_switch(); } self.active = Some(index); + // Type is drawn in the composite fonts of the document now in front, and measured again + // when they differ from those of the document before. + if switched && cmd::compositefonts::sync(self) { + self.refresh_document_text_bounds(index); + } true } else { false @@ -196,7 +203,7 @@ impl Session { format!("Untitled-{}", self.untitled_counter) } - /// Text layout bounds are a cache (not saved): compute them for a document just read, or + /// Text layout bounds are a cache (not saved): compute them after its fonts are registered, or /// selection boxes and hit testing would use the rough estimate until each text is edited, and /// what is fitted to type's bounds (an envelope warping it) would come out differently. That /// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too, @@ -211,6 +218,14 @@ impl Session { } } + /// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an + /// edit: a saved document stays saved, and its undo history keeps its documents as they are. + fn refresh_document_text_bounds(&mut self, index: usize) { + if let Some(st) = self.docs.get_mut(index) { + st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc))); + } + } + /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, /// an undo group with the begun group's name opens in it: here outside a command, and after the /// command that adds it otherwise ([`Session::execute`]). @@ -218,7 +233,7 @@ impl Session { // Every edit is checked against the document before it, so the first one is within the cap // (importers and the native reader have seen to theirs already, with a warning). doc.limit_depth(); - Self::refresh_text_bounds(&mut doc); + cmd::compositefonts::filter_loaded(&mut doc); // Guides from files saved before guides had layers go on a layer. doc.adopt_guides(); cmd::dimension::conform(&mut doc); @@ -229,6 +244,8 @@ impl Session { self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); + cmd::compositefonts::sync(self); + self.refresh_document_text_bounds(i); if self.depth == 0 && let Some(label) = self.explicit_undo_group.clone() { @@ -244,7 +261,7 @@ impl Session { return false; } doc.limit_depth(); - Self::refresh_text_bounds(&mut doc); + cmd::compositefonts::filter_loaded(&mut doc); doc.adopt_guides(); cmd::dimension::conform(&mut doc); if self.active == Some(index) { @@ -280,6 +297,12 @@ impl Session { if let Some(stash) = &mut self.batch_stash { stash.push(old); } + // Type in the composite fonts of a document in the back is measured again when it comes to + // the front. + if self.active == Some(index) { + cmd::compositefonts::sync(self); + } + self.refresh_document_text_bounds(index); true } @@ -296,6 +319,7 @@ impl Session { stash.push(old); } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; + cmd::compositefonts::sync(self); true } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 352a267b4..5ad84885f 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -157,6 +157,9 @@ impl Session { } if let Some(open) = open { self.open_undo_groups_in_new_documents(&open); + // Type is drawn in the composite fonts of the documents open now, an undone or opened + // document's included. + cmd::compositefonts::sync(self); } let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query)); if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index f841efedc..d0901f30a 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -374,7 +374,7 @@ fn rgb_art_separates_with_gcr() { RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Black".into()]), ..Default::default() }), ..Default::default() }, ) .pixel(50, 50); - assert!(k[0] < 20, "RGB black is mostly K: {k:?}"); + assert!((20..=30).contains(&k[0]), "RGB black carries 90% black: {k:?}"); let c = render( &doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into()]), ..Default::default() }), ..Default::default() }, diff --git a/crates/engine/src/tests/eyedropper.rs b/crates/engine/src/tests/eyedropper.rs index 786c492da..f8363fe93 100644 --- a/crates/engine/src/tests/eyedropper.rs +++ b/crates/engine/src/tests/eyedropper.rs @@ -333,8 +333,8 @@ fn an_rgb_image_sampled_in_a_cmyk_document_separates_through_the_color_settings( let got = node(&s, dst).appearance.fill_paint().color().unwrap(); assert_eq!(got, managed_cmyk(Color::rgb8(83, 170, 223))); let Color::Cmyk { c, m, y, k } = got else { panic!("{got:?}") }; - // Generic CMYK: about C59 M12 Y3 K0, with no black, where the formula gave K13. - assert!((0.5..0.75).contains(&c) && (0.05..0.2).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}"); + // Generic CMYK: about C62 M19 Y0 K0, with no black, where the formula gave K13. + assert!((0.5..0.75).contains(&c) && (0.1..0.25).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}"); // A light blue shows close to the color it was picked from; the formula's C12 M7 showed duller. click(&mut s, 450.0, 50.0, Mods::default()); let got = node(&s, dst).appearance.fill_paint().color().unwrap(); diff --git a/crates/engine/src/tests/fontlist.rs b/crates/engine/src/tests/fontlist.rs index d0804b7d0..6202a8ce7 100644 --- a/crates/engine/src/tests/fontlist.rs +++ b/crates/engine/src/tests/fontlist.rs @@ -14,7 +14,11 @@ fn names(v: &Value) -> Vec { fn the_font_list_names_every_available_family_with_its_styles() { let mut s = Session::new(); let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]); - assert_eq!(fams, *FontDb::global().menu_family_list()); + // Other tests running at the same time add and remove composite fonts, which the lists show + // first; the installed families are compared. + let db = FontDb::global(); + let installed_only = |list: &[String]| list.iter().filter(|f| db.has_installed_family(f)).cloned().collect::>(); + assert_eq!(installed_only(&fams), installed_only(&db.menu_family_list())); for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { assert!(fams.iter().any(|x| x == f), "{f}"); } diff --git a/crates/engine/src/tests/history_document.rs b/crates/engine/src/tests/history_document.rs index 0c78b1109..3c2c9b9eb 100644 --- a/crates/engine/src/tests/history_document.rs +++ b/crates/engine/src/tests/history_document.rs @@ -1,5 +1,6 @@ //! History panel › New Document from State (`edit.newDocumentFromState`). +use std::path::Path; use std::sync::Arc; use serde_json::{Value, json}; @@ -262,7 +263,72 @@ fn a_file_the_source_finds_in_its_folder_is_found_from_the_new_document() { s.execute(CMD, &json!({})).unwrap(); assert_eq!(s.active_index(), Some(1), "the new document"); let c = check(&mut s); - assert_eq!((&c["links"][0]["status"], &c["links"][0]["path"]), (&json!("ok"), &json!(moved)), "{c}"); + assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&moved))), "{c}"); +} + +/// A linked file missing from the moved source keeps the place its relative path names from the +/// source's folder: the new document, saved beside the source, writes the same relative path. +#[test] +fn a_missing_file_keeps_its_relative_path_in_the_new_document() { + let a = Folder::new("history-document-missing-a"); + let b = Folder::new("history-document-missing-b"); + let away = Folder::new("history-document-missing-away"); + write(&a.file("art/photo.png"), &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + s.execute("document.save", &json!({"path": a.file("poster.vectorcraft")})).unwrap(); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + let r = s.execute("document.open", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + assert_eq!(r["missingLinks"][0]["name"], "photo.png", "{r}"); + s.execute(CMD, &json!({})).unwrap(); + let copy = b.file("copy.vectorcraft"); + s.execute("document.save", &json!({"path": copy})).unwrap(); + let saved: Value = serde_json::from_slice(&std::fs::read(©).unwrap()).unwrap(); + let link = &saved["document"]["layers"][0]["kind"]["children"][0]["kind"]["link"]; + assert_eq!(link["relative"].as_str(), Some("art/photo.png"), "{link}"); +} + +/// A linked file that comes back beside the document the source was read from, after the source +/// was saved in another folder, is found from the new document. +#[test] +fn a_file_back_beside_the_source_it_was_read_from_is_found_from_the_new_document() { + let a = Folder::new("history-document-back-a"); + let b = Folder::new("history-document-back-b"); + let away = Folder::new("history-document-back-away"); + write(&a.file("art/photo.png"), &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + s.execute("document.save", &json!({"path": a.file("poster.vectorcraft")})).unwrap(); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + s.execute("document.open", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + s.execute("document.save", &json!({"path": b.file("saved/poster.vectorcraft")})).unwrap(); + std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap(); + let source = s.active_index().unwrap(); + s.execute(CMD, &json!({})).unwrap(); + let c = s.execute("links.check", &json!({})).unwrap(); + let moved = b.file("art/photo.png"); + assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&moved))), "{c}"); + // A file beside the folder the source was last saved in is found too. + std::fs::create_dir_all(b.0.join("saved/art")).unwrap(); + std::fs::rename(&moved, b.file("saved/art/photo.png")).unwrap(); + s.execute("document.activate", &json!({"index": source})).unwrap(); + s.execute(CMD, &json!({})).unwrap(); + let c = s.execute("links.check", &json!({})).unwrap(); + let saved = b.file("saved/art/photo.png"); + assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&saved))), "{c}"); + // Missing again when the new document is saved, the file keeps the place it was found. + std::fs::rename(&saved, away.file("photo.png")).unwrap(); + std::fs::create_dir_all(b.0.join("new")).unwrap(); + let copy = b.file("new/copy.vectorcraft"); + s.execute("document.save", &json!({"path": copy})).unwrap(); + let file: Value = serde_json::from_slice(&std::fs::read(©).unwrap()).unwrap(); + let link = &file["document"]["layers"][0]["kind"]["children"][0]["kind"]["link"]; + assert_eq!(link["relative"].as_str(), Some("../saved/art/photo.png"), "{link}"); } /// The same holds for a linked image only a symbol shows. diff --git a/crates/engine/src/tests/link_paths.rs b/crates/engine/src/tests/link_paths.rs index 76bd7168a..e39cf6689 100644 --- a/crates/engine/src/tests/link_paths.rs +++ b/crates/engine/src/tests/link_paths.rs @@ -85,6 +85,21 @@ fn linked_files_on_other_computers_are_never_read() { assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys()); } +/// A place a link names from the file the document was read from is followed only when the +/// document's own file follows it too: after a document read from a network share is saved on +/// this computer, its links are not looked for on that share. +#[test] +fn places_named_from_a_share_are_not_followed_after_a_save_elsewhere() { + use crate::cmd::links::follow_from; + let resolve = + |stored: &str, doc: Option<&str>| craft_docpath::resolve(stored, doc.map(std::path::Path::new)).map(|p| p.to_string_lossy().into_owned()); + let (link, on_share) = (r"\\server\share\a\art\photo.png", Some(r"\\server\share\a\poster.vectorcraft")); + assert_eq!(follow_from(link, on_share, on_share, resolve).as_deref(), Some(link)); + assert_eq!(follow_from(link, on_share, Some(r"\\server\share\b\poster.vectorcraft"), resolve).as_deref(), Some(link)); + assert_eq!(follow_from(link, on_share, Some("/local/b/poster.vectorcraft"), resolve), None); + assert_eq!(follow_from(link, on_share, Some(r"C:\b\poster.vectorcraft"), resolve), None); +} + /// The final path Windows gives a folder of the drive `Z:` mapped to `\\server\share`. The /// share's root folder has no trailing separator (`\\?\UNC\server\share`). fn on_share(folder: &str) -> Option { diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index cfe43ee8e..f0a0674f0 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -244,6 +244,141 @@ fn the_relative_path_finds_the_file_after_the_folder_moves() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); } +/// A document moved to another folder while its linked file is missing keeps the file's relative +/// path when it is saved again, there or in a subfolder, so the file is found once it is back +/// beside the document. +#[test] +fn a_missing_link_keeps_its_relative_path_when_the_moved_document_is_saved() { + let a = Folder::new("missing-save-a"); + let b = Folder::new("missing-save-b"); + let away = Folder::new("missing-save-away"); + let pic = a.file("art/photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + save(&mut s, &a.file("poster.vectorcraft")); + // Move the document and its links, then take the links away. + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + let r = open(&mut s, &b.file("poster.vectorcraft")); + assert_eq!(r["missingLinks"][0]["name"].as_str(), Some("photo.png"), "{r}"); + save(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("copies")).unwrap(); + save(&mut s, &b.file("copies/poster.vectorcraft")); + let relative = |path: &str| { + let mut saved: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + saved_image(&mut saved)["link"]["relative"].as_str().map(str::to_string) + }; + assert_eq!(relative(&b.file("poster.vectorcraft")).as_deref(), Some("art/photo.png")); + assert_eq!(relative(&b.file("copies/poster.vectorcraft")).as_deref(), Some("../art/photo.png")); + // Saved again where the last save put it, the file is still named from the folder it was read from. + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(relative(&b.file("copies/poster.vectorcraft")).as_deref(), Some("../art/photo.png")); + // The links come back: both saved documents find the file. + std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap(); + let moved = b.file("art/photo.png"); + for doc in [b.file("poster.vectorcraft"), b.file("copies/poster.vectorcraft")] { + let r = open(&mut s, &doc); + assert_eq!(&r["missingLinks"], &json!([]), "{doc}: {r}"); + assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&moved), "{doc}"); + } +} + +/// A file found by name in the document's folder, then missing before the next save, keeps the +/// place it was last found: the saved relative path names that place, not the folder its old +/// relative path named. +#[test] +fn a_file_found_elsewhere_then_missing_keeps_the_place_it_was_found() { + let dir = Folder::new("found-then-missing"); + let pic = dir.file("art/photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let doc = dir.file("poster.vectorcraft"); + save(&mut s, &doc); + let beside = dir.file("photo.png"); + std::fs::rename(&pic, &beside).unwrap(); + let r = open(&mut s, &doc); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); + assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&beside)); + let away = dir.file("away.png"); + std::fs::rename(&beside, &away).unwrap(); + save(&mut s, &doc); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("photo.png")); +} + +/// After a save in another folder, a file Update Links finds by name beside the saved document, +/// then missing before the next save, keeps the place it was found. +#[test] +fn a_file_update_links_finds_after_a_save_elsewhere_keeps_its_place() { + let a = Folder::new("update-after-save-as-a"); + let b = Folder::new("update-after-save-as-b"); + let pic = a.file("art/photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + save(&mut s, &a.file("poster.vectorcraft")); + open(&mut s, &a.file("poster.vectorcraft")); + let doc = b.file("poster.vectorcraft"); + save(&mut s, &doc); + let beside = b.file("photo.png"); + std::fs::rename(&pic, &beside).unwrap(); + let r = s.execute("links.update", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(&r["missing"], &json!([]), "{r}"); + assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&beside)); + std::fs::remove_file(&beside).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("photo.png")); +} + +/// A file found at its relative path from the folder of the last save, away from its path and from +/// the folder the document was read from, is written relative to where it was found. +#[test] +fn a_file_found_beside_the_last_save_is_written_where_it_was_found() { + let a = Folder::new("found-beside-save-a"); + let b = Folder::new("found-beside-save-b"); + let c = Folder::new("found-beside-save-c"); + write(&a.file("art/photo.png"), &png(600, 300, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + save(&mut s, &a.file("poster.vectorcraft")); + open(&mut s, &a.file("poster.vectorcraft")); + save(&mut s, &b.file("poster.vectorcraft")); + std::fs::rename(a.0.join("art"), b.0.join("art")).unwrap(); + save(&mut s, &c.file("poster.vectorcraft")); + let r = open(&mut s, &c.file("poster.vectorcraft")); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); +} + +/// After a moved document with a missing link is saved in another folder, `links.check` finds the +/// file once it is back at the place its relative path names from the folder the document was +/// read from, which is where the saved file looks for it too. +#[test] +fn a_missing_link_comes_back_where_the_document_saved_elsewhere_looks_for_it() { + let a = Folder::new("check-after-save-as-a"); + let b = Folder::new("check-after-save-as-b"); + let c = Folder::new("check-after-save-as-c"); + let away = Folder::new("check-after-save-as-away"); + write(&a.file("art/photo.png"), &png(600, 300, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + save(&mut s, &a.file("poster.vectorcraft")); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + save(&mut s, &c.file("poster.vectorcraft")); + std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap(); + let r = s.execute("links.check", &json!({})).unwrap(); + let found = r["links"][0]["found"].as_str().map(Path::new); + assert_eq!((r["missing"].as_u64(), found), (Some(0), Some(Path::new(&b.file("art/photo.png")))), "{r}"); + let r = open(&mut s, &c.file("poster.vectorcraft")); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); +} + /// Links in symbols, graph designs, patterns and opacity masks are saved with their relative /// paths, as links in the layers are: after the folder moves, each file is found and its link takes /// the new path, which the next save writes relative to the document again. @@ -781,7 +916,7 @@ fn low_res_proxy_eps_export_uses_full_resolution_from_the_linked_file() { assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"].clone()), (json!([]), json!([])), "{r}"); let key = image(&s, id).key; assert!(s.doc().unwrap().doc.images[&key].is_proxy()); - let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None); + let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, Default::default()); assert!(warnings.is_empty(), "{warnings:?}"); assert!(!full.images[&key].is_proxy()); let dim = @@ -812,7 +947,7 @@ fn export_reads_a_found_svg_link_into_its_own_images_only() { let bytes = png(20, 20, RED); write(&dir.file("a.png"), &bytes); - let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None); + let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, Default::default()); assert_eq!(warnings.len(), 1, "b.png is still missing: {warnings:?}"); assert_eq!(*full.images[&a.key].bytes, bytes, "a.png's pixels"); assert_eq!(full.images[&b.key].bytes, placeholder, "b.png keeps its placeholder"); @@ -845,7 +980,7 @@ fn export_reads_a_changed_svg_link_into_its_own_images_only() { let doc = &s.doc().unwrap().doc; assert!(doc.images[&a.key].is_proxy() && doc.images[&b.key].is_proxy(), "previews"); let preview = doc.images[&a.key].bytes.clone(); - let (full, warnings) = cmd::links::full_linked_images(doc, None); + let (full, warnings) = cmd::links::full_linked_images(doc, Default::default()); assert_eq!(warnings.len(), 1, "a.png is missing: {warnings:?}"); assert_eq!(*full.images[&b.key].bytes, blue, "b.png's new pixels"); assert_eq!(full.images[&a.key].bytes, preview, "a.png keeps its preview"); @@ -1002,7 +1137,7 @@ fn relative_paths_are_written_against_the_saved_file() { }; d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap(); let rel = |dest: String| { - let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone()); + let d = cmd::links::with_relative_paths(&d, Default::default(), &dest).unwrap_or_else(|| d.clone()); match &d.node(nid).unwrap().kind { NodeKind::Image(im) => im.link.as_ref().unwrap().relative.clone(), _ => None, @@ -1083,15 +1218,11 @@ fn links_of_an_imported_file_are_read_where_they_are_found() { assert!((im.width, im.height) == (600, 300) && !s.doc().unwrap().doc.images[&im.key].is_proxy(), "{im:?}"); } -/// A placed file of a `.ai` file whose folder moved with the document is found by the folder the -/// file keeps relative to the document's (`src/`), and its link takes the size, modification time -/// and hash of the file found there. Not found, the link keeps no such details. -#[test] -fn links_of_an_imported_file_are_found_by_their_relative_folder() { +/// `poster.ai` in `dir`, a `.ai` file another app wrote, placing `src/photo.png` (relative to it) +/// under a path that no longer exists → its path. +fn imported_with_placed_file(dir: &Folder) -> String { use vectorcraft_testkit::ai; - let dir = Folder::new("imported-relative"); std::fs::create_dir_all(dir.0.join("src")).unwrap(); - let pic = dir.file("src/photo.png").replace('\\', "/"); let place = "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n(/no/such/folder/src/photo.png)`\n\ %%IncludeFile:/no/such/folder/src/photo.png\n%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n(src/)\n\ %AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n%AI26_BeginPlacedObjectPreview\n\ @@ -1099,6 +1230,17 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() { let doc_path = dir.file("poster.ai"); let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", place)); write(&doc_path, &ai::ai(data.as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f")); + doc_path +} + +/// A placed file of a `.ai` file whose folder moved with the document is found by the folder the +/// file keeps relative to the document's (`src/`), and its link takes the size, modification time +/// and hash of the file found there. Not found, the link keeps no such details. +#[test] +fn links_of_an_imported_file_are_found_by_their_relative_folder() { + let dir = Folder::new("imported-relative"); + let pic = dir.file("src/photo.png").replace('\\', "/"); + let doc_path = imported_with_placed_file(&dir); let link = |s: &Session| { let mut found = vec![]; s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone())); @@ -1112,9 +1254,16 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() { let (l, proxy) = link(&s); assert_eq!((l.path.as_str(), l.relative.as_deref()), ("/no/such/folder/src/photo.png", Some("src/photo.png"))); assert!(proxy && l.size.is_none() && l.modified.is_none() && l.hash.is_none(), "{l:?}"); + // Saved beside it while the file is missing, the document keeps the folder the file is in. + let native = dir.file("poster.vectorcraft"); + save(&mut s, &native); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png")); let bytes = png(600, 300, RED); write(&pic, &bytes); + let r = open(&mut s, &native); + assert_eq!(&r["missingLinks"], &json!([]), "the native copy finds the file: {r}"); let r = open(&mut s, &doc_path); assert_eq!(&r["missingLinks"], &json!([]), "{r}"); let (l, proxy) = link(&s); @@ -1123,3 +1272,27 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() { assert_eq!((l.size, l.hash.clone()), (Some(bytes.len() as u64), Some(vectorcraft_doc::links::hash_bytes(&bytes)))); assert!(l.modified.is_some(), "{l:?}"); } + +/// A Data Recovery copy of such a document keeps the file its links' relative paths name places +/// from: restored and saved beside the `.ai` file, it keeps the folder the placed file is in. +#[test] +fn a_recovered_copy_of_an_imported_file_keeps_its_links_folder() { + let dir = Folder::new("imported-recovered"); + let doc_path = imported_with_placed_file(&dir); + let store = std::sync::Arc::new(crate::cmd::recovery::MemoryStore::default()); + let mut s = session(); + s.recovery.set_store(store.clone()); + open(&mut s, &doc_path); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"].as_array().map(Vec::len), Some(1), "{r}"); + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store); + let r = s.execute("file.recovery.restore", &json!({})).unwrap(); + assert_eq!(r["restored"].as_array().map(Vec::len), Some(1), "{r}"); + let native = dir.file("poster.vectorcraft"); + save(&mut s, &native); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png")); +} diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 2113d5a79..1bc75a82a 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -4463,7 +4463,7 @@ fn k_only(c: Color) -> f32 { fn document_color_mode_separates_rgb_greys_through_the_profile() { let (mut s, ids, _, _) = greys_document(); s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap(); - // As Illustrator converts them: four-colour greys and a rich black. + // Through the profile: a mid gray in four inks and a rich black. let Color::Cmyk { c, m, y, k } = fill_of(&s, ids[2]) else { panic!("not CMYK") }; assert!(c > 0.05 && m > 0.05 && y > 0.05 && k > 0.0, "a four-colour mid grey: {c} {m} {y} {k}"); let Color::Cmyk { c, k, .. } = fill_of(&s, ids[0]) else { panic!("not CMYK") }; diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 4210a1d96..6c5c05572 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -925,11 +925,12 @@ fn a_freeform_gradient_expands_with_the_inks_of_its_points() { for c in expanded(&mut s, 1.0) { assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}"); } - // Cyan and black points mix as inks: cyan and black only, adding up to one. + // Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient blends + // the points' display colors and the mesh blends their inks, so the two draw a little apart in between. let mut s = session(); let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0)); freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]); - for c in expanded(&mut s, 5.0) { + for c in expanded(&mut s, 7.5) { let [c, m, y, k] = inks(c); assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}"); } diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index dcf3c6217..e15258bec 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -253,6 +253,103 @@ fn a_missing_link_is_reported_not_copied() { assert!(report.contains("blue.png: not found"), "{report}"); } +/// A file missing from a document that moved keeps the place its relative path names from the +/// document's folder: the packaged document writes its relative path from there. +#[test] +fn a_missing_link_of_a_moved_document_keeps_its_relative_place() { + let a = Folder::new("package-moved-a"); + let b = Folder::new("package-moved-b"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::remove_file(b.file("art/blue.png")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + // Saved in another folder first: the relative path still names the folder the file was read from. + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + save(&mut s, &b.file("saved/poster.vectorcraft")); + let out = b.file("out"); + let r = run(&mut s, "file.package", json!({"folder": out})); + assert_eq!(r["missingLinks"], json!(["blue.png"]), "{r}"); + let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft"); + let doc = vectorcraft_format::load(&std::fs::read(packaged).unwrap()).unwrap(); + let mut rel = vec![]; + doc.visit_images(|_, im| rel.push(im.link.clone().unwrap().relative)); + rel.sort(); + assert_eq!(rel, [Some("../../art/blue.png".to_string()), Some("Links/red.png".to_string())]); +} + +/// A missing link stays missing when another file sits where its relative path, written for the +/// package folder, would name from the folder the document was last saved in. +#[test] +fn a_missing_link_is_not_collected_from_another_place() { + let a = Folder::new("package-other-a"); + let b = Folder::new("package-other-b"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::remove_file(b.file("art/blue.png")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("s1/s2/s3")).unwrap(); + save(&mut s, &b.file("s1/s2/s3/poster.vectorcraft")); + // `../../art/blue.png` from the package folder, read from `s1/s2/s3`. + write(&b.file("s1/art/blue.png"), &png(10, 10, RED)); + let r = run(&mut s, "file.package", json!({"folder": b.file("out")})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(1), json!(["blue.png"])), "{r}"); +} + +/// Without copying, a packaged document links a file found by name beside the document it was read +/// from, after a save in another folder, where it was found. +#[test] +fn a_link_found_by_name_is_kept_where_it_was_found_without_copying() { + let a = Folder::new("package-name-a"); + let b = Folder::new("package-name-b"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + let away = b.file("away.png"); + std::fs::rename(b.file("art/blue.png"), &away).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + save(&mut s, &b.file("saved/poster.vectorcraft")); + std::fs::rename(&away, b.file("blue.png")).unwrap(); + let out = b.file("out"); + run(&mut s, "file.package", json!({"folder": out, "copyLinks": false})); + let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft"); + let r = open(&mut s, &packaged.to_string_lossy()); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); +} + +/// A file that comes back beside the document it was read from, after the document was saved in +/// another folder, is collected from there. +#[test] +fn a_link_that_comes_back_after_a_save_elsewhere_is_collected() { + let a = Folder::new("package-back-a"); + let b = Folder::new("package-back-b"); + let away = Folder::new("package-back-away"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.file("art/blue.png"), away.file("blue.png")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + save(&mut s, &b.file("saved/poster.vectorcraft")); + std::fs::rename(away.file("blue.png"), b.file("art/blue.png")).unwrap(); + let out = b.file("out"); + let r = run(&mut s, "file.package", json!({"folder": out})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(2), json!([])), "{r}"); + let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft"); + let doc = vectorcraft_format::load(&std::fs::read(packaged).unwrap()).unwrap(); + let mut rel = vec![]; + doc.visit_images(|_, im| rel.push(im.link.clone().unwrap().relative)); + rel.sort(); + assert_eq!(rel, [Some("Links/blue.png".to_string()), Some("Links/red.png".to_string())]); + // A file beside the folder the document was last saved in is collected too. + std::fs::create_dir_all(b.0.join("saved/art")).unwrap(); + std::fs::rename(b.file("art/blue.png"), b.file("saved/art/blue.png")).unwrap(); + let r = run(&mut s, "file.package", json!({"folder": b.file("out2")})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(2), json!([])), "{r}"); +} + #[test] fn a_font_named_by_another_of_its_names_is_copied() { let dir = Folder::new("package-alias"); diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 5f989db78..5871b85a3 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -489,7 +489,7 @@ fn a_placed_document_without_its_bytes_or_a_hash_is_reported_not_read() { let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("a placed document") }; p.link.hash = None; p.link.size = None; - let r = crate::cmd::links::resolve(&mut d, None, false, false).to_json(); + let r = crate::cmd::links::resolve(&mut d, crate::cmd::links::LinkBase::file(None), false, false).to_json(); assert_eq!(r["modifiedLinks"].as_array().map(Vec::len), Some(1), "{r}"); assert!(!d.images.contains_key(&key), "not read"); } diff --git a/crates/engine/src/tests/swatchcmds.rs b/crates/engine/src/tests/swatchcmds.rs index 2a74e194d..cbf595d6d 100644 --- a/crates/engine/src/tests/swatchcmds.rs +++ b/crates/engine/src/tests/swatchcmds.rs @@ -503,7 +503,7 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() { // A swatch made from a hex colour is CMYK as well; the document round-trips natively. let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].as_str().unwrap().to_string(); assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0)))); - assert_eq!(name, "C=61 M=0 Y=100 K=0"); + assert_eq!(name, "C=65 M=0 Y=100 K=0"); let json = serde_json::to_string(doc(&s)).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), *doc(&s)); } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 1a3ec8ff9..329c5fb86 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -4498,6 +4498,56 @@ fn aligned_type_round_a_circle_runs_along_its_path_as_the_file_lays_it_out() { } } +/// Type aligned to a circle by its ascender, center or descender, in the default face at 12 pt: the +/// file's baselines lie inside the path by the top of the face's em box (its `BASE` table's +/// `ideo` -170 plus one em), half the height of its H's top (656 units) or the bottom of its +/// bounding box (-295), and laid out, the type's baselines lie where the file has them. +#[test] +fn aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out() { + let k = 40.0 * 0.552_284_75; + let points = [ + (90.0, 50.0), + (90.0, 50.0 - k), + (50.0 + k, 10.0), + (50.0, 10.0), + (50.0 - k, 10.0), + (10.0, 50.0 - k), + (10.0, 50.0), + (10.0, 50.0 + k), + (50.0 - k, 90.0), + (50.0, 90.0), + (50.0 + k, 90.0), + (90.0, 50.0 + k), + (90.0, 50.0), + ]; + let all: Vec<(f64, f64)> = points.iter().map(|(x, y)| canvas(*x, *y)).collect(); + let segment = |i: usize| (0..4).map(|j| format!("{} {}", all[i * 3 + j].0, all[i * 3 + j].1)).collect::>().join(" "); + for (key, align, inside) in [ + (0, vectorcraft_doc::PathAlign::Ascender, 0.83 * 12.0), + (2, vectorcraft_doc::PathAlign::Center, 0.328 * 12.0), + (1, vectorcraft_doc::PathAlign::Descender, -0.295 * 12.0), + ] { + let frame = format!( + "<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 2 /6 [ 2.0 5.99 ] /11 << /2 {key} >> >> >> >>", + (0..4).map(segment).collect::>().join(" ") + ); + let radius = 40.0 - inside; + let doc = document_of(&[frame], &[round_story_with("abcdefgh", 5.0, radius, radius / 48.0, &format!("/0 << /0 [ 0 {inside:.3} ] >>"))]); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{align:?}: {:?}", r.warnings); + assert_eq!(texts[0].path_align, align); + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), texts[0]); + assert_eq!(layout.glyphs.len(), 8); + // The four cubics stray up to 0.011 from the circle. + for g in &layout.glyphs { + let (x, y) = (g.origin.x + g.angle.cos() * g.advance * 0.5 - 50.0, g.origin.y + g.angle.sin() * g.advance * 0.5 - 50.0); + assert!((x.hypot(y) - radius).abs() < 0.02, "{align:?}: {} from the center, want {radius}", x.hypot(y)); + } + } +} + #[test] fn threaded_area_type_flows_through_its_frames() { let frames = [ diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index de2c9b384..c5c1c4c26 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -65,8 +65,8 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, NodeKind, - ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, + AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, + NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2}; @@ -180,6 +180,8 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result 0.0 && board.height() > 0.0) { return Err("the document has no artboard".into()); } + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); // The document is y down; art space y up from the artboard's left edge, its top on a whole point. let (width, height) = (board.width(), board.height()); let top = if (height - height.round()).abs() < 1e-6 { height.round() } else { height.ceil() }; diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index c2346cbb9..8ea787f8a 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -1,7 +1,7 @@ use super::writer::Writer; use super::{ - Affine, AreaOptions, BezPath, CharPosition, CharStyle, Gradient, GradientKind, Paint, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, - TextObject, Write, latin, ps, stroke, + Affine, AreaOptions, BezPath, CharPosition, CharStyle, CompositeFont, Document, Gradient, GradientKind, Paint, ParaStyle, PathAlign, PathEffect, + StrokeLayer, TextKind, TextObject, Write, latin, ps, stroke, }; /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops @@ -78,10 +78,11 @@ pub(super) fn latin_string(name: &str) -> String { ps::bytes(&bytes) } -/// Can type `t` be written as text: 8-bit text can hold it ([`holds_as_text`]) and no character -/// is rotated ([`rotated`])? -pub(super) fn writable(t: &TextObject) -> bool { - holds_as_text(t) && !rotated(t) +/// Can type `t` of document `doc` be written as text: 8-bit text can hold it ([`holds_as_text`]), +/// no character is rotated ([`rotated`]) and no run is set in one of the document's composite +/// fonts, which a reader can't find by its name? +pub(super) fn writable(doc: &Document, t: &TextObject) -> bool { + holds_as_text(t) && !rotated(t) && !t.runs.iter().any(|r| CompositeFont::named(&doc.composite_fonts, &r.style.font_family).is_some()) } /// Has type `t` visible characters with Character Rotation? A legacy `.ai` text object's character diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index b34c127a7..c3ca85c08 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -136,9 +136,9 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document { nodes.extend(layout.glyphs.iter().filter_map(|g| g.placed_colour(t.xf)).map(Arc::new)); Node::group(vectorcraft_doc::NodeId(0), nodes) } - fn plain(nodes: &mut Vec>, o: &LegacyAiOptions, images: &mut std::collections::BTreeMap) { + fn plain(doc: &Document, nodes: &mut Vec>, o: &LegacyAiOptions, images: &mut std::collections::BTreeMap) { nodes.retain(|n| match &n.kind { - NodeKind::Text(t) => o.outline_text || !writable(t), + NodeKind::Text(t) => o.outline_text || !writable(doc, t), NodeKind::Layer { printable, .. } => *printable, _ => true, }); @@ -160,13 +160,13 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document { } } if let Some(c) = n.children_mut() { - plain(c, o, images); + plain(doc, c, o, images); } } } let mut d = doc.clone(); let mut images = std::mem::take(&mut d.images); - plain(&mut d.layers, o, &mut images); + plain(doc, &mut d.layers, o, &mut images); d.images = images; d } diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index ae8fddc70..cf601e939 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -673,7 +673,7 @@ impl<'a> Writer<'a> { let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); let t = &*resolved; if !self.outline_text { - if writable(t) { + if writable(self.doc, t) { return self.text_object(t); } if !holds_as_text(t) { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index bf515de66..e8fd85764 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -224,6 +224,8 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb if ![r.x0, r.y0, r.x1, r.y1, opts.origin.x, opts.origin.y].iter().all(|v| v.is_finite()) { return Err("the export region is not finite".into()); } + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); let baked = vectorcraft_effects::bake_document(doc); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 92607d5c5..c64d4a6f5 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -9,6 +9,8 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { /// Like [`export`], also returning warnings: options accepted but not applied yet (see /// [`crate::PdfSettings::warnings`]) and features approximated or dropped. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); write::guard(|| leaving_out_undecodable(doc, |left_out| export_with_report_inner(doc, opts, left_out))) } diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index c0a2ba32e..578f52488 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -65,8 +65,8 @@ pub fn approved(plugin: &Plugin) -> bool { /// Installs `plugin`, replacing one with the same id. pub fn install(mut plugin: Plugin) -> Arc { - // The revision changes with the set locked, so a reader that sees the new revision then waits - // for the set and finds the plug-in. + // The revision moves while the write lock is held, so a cache that reads the new revision and + // then the plug-ins finds the new plug-in among them. let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner()); plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1); let p = Arc::new(plugin); diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index dee93e903..10bfe03ce 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -563,17 +563,25 @@ mod tests { fn the_ink_table_matches_the_profile() { let cms = cms::active(); let lut = InkLut::build(&cms); - let mut worst = 0.0f32; + let (mut worst, mut worst_dark) = (0.0f32, 0.0f32); // Between the grid points (where interpolation errs most) and on them. for i in 0..=20 { for j in 0..=20 { let inks = [i as f32 / 20.0, j as f32 / 20.0, ((i * 7 + j * 3) % 21) as f32 / 20.0, ((i + j * 5) % 21) as f32 / 20.0]; let (a, b) = (lut.apply(inks), cms.cmyk_to_srgb(inks, false)); - worst = worst.max((0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max)); + let err = (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max); + // Builds darker than the press's black point clip at 0 in one or more XYZ channels, + // so the grid interpolates across that clip in the darkest levels. + if b.iter().all(|v| *v < 0.05) { + worst_dark = worst_dark.max(err); + } else { + worst = worst.max(err); + } } } // Within two 8-bit levels: far below a visible difference. assert!(worst * 255.0 < 2.5, "worst error {} levels", worst * 255.0); + assert!(worst_dark * 255.0 < 5.0, "worst error near black {} levels", worst_dark * 255.0); } #[test] diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 2ca5b5978..5b0f60091 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -119,6 +119,25 @@ fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() { assert!(g.alternate_glyphs(&t).elements().is_empty()); } +/// Type in a composite font draws each class of character in its part's face, which is not a +/// substitute: Document Setup's highlight of substituted glyphs leaves it alone. +#[test] +fn composite_font_parts_are_not_highlighted_as_substituted_glyphs() { + use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts}; + let _adding = adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); + parts.roman = CompositePart::new("JetBrains Mono", "Regular"); + parts.numerals = CompositePart::new("Inter", "Regular"); + db.set_composite_font(&CompositeFont { name: "CF Highlight".into(), parts }); + let style = CharStyle { size: 40.0, font_family: "CF Highlight".into(), ..Default::default() }; + let t = TextObject::point(Point::new(10.0, 60.0), "Ab12", style); + let g = text_geom(&t); + db.remove_composite_font("CF Highlight"); + assert!(!g.all.elements().is_empty()); + assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty()); +} + /// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes /// available: the glyph geometry kept for it is laid out again. #[test] diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index 5e3c9496a..05228eb20 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -191,7 +191,14 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeo if let Some(art) = g.placed_colour(Affine::IDENTITY) { colours.push((g.run, Arc::new(art))); } - let Some(&(missing, face)) = faces.get(g.run) else { continue }; + let Some(&(mut missing, mut face)) = faces.get(g.run) else { continue }; + // Type in a composite font: each character's face is its class's part's. + if let Some(r) = t.runs.get(g.run) + && let Some(c) = plain.get(g.byte..).and_then(|s| s.chars().next()) + && let Some((m, f)) = db.composite_face(&r.style.font_family, c) + { + (missing, face) = (m, Some(f)); + } let Some(line) = layout.lines.get(g.line) else { continue }; let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1); cell.apply_affine(Affine::rotate_about(g.angle, g.origin)); diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index dd956692d..c9d9027b8 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -84,11 +84,14 @@ impl Writer<'_> { let inline = t.runs.iter().any(|r| r.inline.is_some()); // Ruby is written as outlines with its base text: a reader's can't place it. So are // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869), - // and a glyph chosen for a character, which a reader's draws with the default glyph. - let unwritable = - t.runs.iter().any(|r| { - r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal || r.style.glyph_alternate.is_some() - }) || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); + // and a glyph chosen for a character, which a reader's draws with the default glyph. So + // is type in one of the document's composite fonts, which a reader can't find by its name. + let unwritable = t.runs.iter().any(|r| { + r.style.small_caps.is_some() + || r.style.position != vectorcraft_doc::CharPosition::Normal + || r.style.glyph_alternate.is_some() + || vectorcraft_doc::CompositeFont::named(&doc.composite_fonts, &r.style.font_family).is_some() + }) || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); let outlined = self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 1dbc7035b..a3ad7a069 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -367,6 +367,8 @@ pub fn export_with_report(doc: &Document, opts: &ExportOptions) -> (String, Vec< /// Export a document as SVG plus the image files it links to. `native`: the document in the /// native format, embedded when [`ExportOptions::preserve_editing`] is on. pub fn export_full(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output { + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); export::export(doc, opts, native) } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d42e8f351..a95ac2dc5 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -20,6 +20,7 @@ use vectorcraft_doc::color::Color; mod alternates; mod build; mod cache; +mod composite; mod db; mod dirs; mod face; @@ -36,6 +37,7 @@ pub use build::style_weight; #[cfg(test)] use cache::GlyphCache; use cache::{CanonicalNames, Catalog, GlyphColours, GlyphImages, PostScriptNames}; +pub use composite::{CompositeCoverage, LentCompositeFonts}; #[cfg(all(not(target_arch = "wasm32"), test))] pub(crate) use dirs::fonts_outside; #[cfg(test)] @@ -47,6 +49,7 @@ pub use dirs::{ use face::Instances; #[cfg(test)] 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; #[cfg(not(target_arch = "wasm32"))] @@ -107,6 +110,11 @@ pub struct FontDb { stamp: Mutex>, /// Faces instanced at the axis settings of type's Variable Font sliders ([`FontDb::instance`]). instances: Mutex, + /// Composite fonts (Type › Composite Fonts…) by lowercased name ([`FontDb::set_composite_font`]): + /// type naming one as its family is set in its parts' fonts. + composites: RwLock>>, + /// The composite fonts [`FontDb::lend_composite_fonts`] put in, by lowercased name. + composite_loans: Mutex>, } /// Where a database's system font scan looks: folders (read with their subfolders) and font files. diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index 40bcb1df1..4946bbd84 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -75,6 +75,7 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt plans: std::sync::Mutex::new(Vec::new()), ideographic_centre: std::sync::OnceLock::new(), icf_margins: std::sync::OnceLock::new(), + path_align: std::sync::OnceLock::new(), colour_bitmaps, colour_layers, svg_glyphs, diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs new file mode 100644 index 000000000..faee695ca --- /dev/null +++ b/crates/text/src/fontdb/composite.rs @@ -0,0 +1,345 @@ +//! Composite fonts (Type › Composite Fonts…) in the font database: each is listed first in the +//! font menus and resolves to its base (Kanji) font, and layout sets each character of type in one +//! in the font of the character's class ([`FontDb::composite_split`]). + +use unicode_segmentation::UnicodeSegmentation; +use vectorcraft_doc::{CharClass, CompositeFont, TextObject, TextRun}; + +use super::face::FALLBACK_FAMILY; +use super::{Arc, FontDb, FontFace, FontMatch}; + +/// A face [`FontDb::resolve`] found and how it matched. +type Resolved = Option<(Arc, FontMatch)>; + +/// How a composite font draws some text ([`FontDb::composite_coverage`]). +#[derive(Clone, Debug)] +pub struct CompositeCoverage { + /// The worst match among the parts that draw it. + pub status: FontMatch, + /// The face of the first part with that match. + pub face: Option>, + /// The characters whose part's face lacks them. + pub lacking: Vec, +} + +impl FontDb { + /// Make `font` available by its name, in place of a composite font of that name (any case). + /// The font menus and anything else that lists fonts change only when the definition does. + /// Whether it did. + pub fn set_composite_font(&self, font: &CompositeFont) -> bool { + let key = font.name.to_lowercase(); + { + let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); + if map.get(&key).is_some_and(|f| **f == *font) { + return false; + } + map.insert(key, Arc::new(font.clone())); + } + self.changed(); + true + } + + /// Forget the composite font named `name` (any case). Whether there was one. + pub fn remove_composite_font(&self, name: &str) -> bool { + let removed = self.composites.write().unwrap_or_else(|e| e.into_inner()).remove(&name.to_lowercase()).is_some(); + if removed { + self.changed(); + } + removed + } + + /// The composite font named `name` (any case). + pub fn composite_font(&self, name: &str) -> Option> { + let map = self.composites.read().unwrap_or_else(|e| e.into_inner()); + if map.is_empty() { + return None; + } + map.get(&name.to_lowercase()).cloned() + } + + /// The face composite font `name` sets character `c` in (its class's part's), and whether + /// that part's font is missing. `None` when `name` is no composite font. + pub fn composite_face(&self, name: &str, c: char) -> Option<(bool, u32)> { + self.composite_resolve(name, c).map(|(face, m)| (m == FontMatch::Missing, face.id())) + } + + /// [`FontDb::composite_resolve`] for the character at byte `at` of run text `text`: the part of + /// the grapheme cluster that holds it, by the cluster's first character, as layout sets it. + pub fn composite_resolve_at(&self, name: &str, text: &str, at: usize) -> Option<(Arc, FontMatch)> { + let first = text.grapheme_indices(true).take_while(|(i, _)| *i <= at).last().and_then(|(_, g)| g.chars().next())?; + self.composite_resolve(name, first) + } + + /// The face composite font `name` sets character `c` in (its class's part's), and how that + /// part's font matched ([`FontDb::resolve`]). `None` when `name` is no composite font, or no + /// font at all is loaded. + pub fn composite_resolve(&self, name: &str, c: char) -> Option<(Arc, FontMatch)> { + let f = self.composite_font(name)?; + let (family, style) = self.part_font(&f, CharClass::of(c)); + self.resolve(&family, &style) + } + + /// The fonts (family, style) composite font `name` sets `text` in: those of the parts its + /// grapheme clusters fall in (each by its first character, as layout splits them), each once, + /// in the order first used; the Kanji part's for text without characters. `None` when `name` is + /// no composite font. + pub fn composite_fonts_for(&self, name: &str, text: &str) -> Option> { + let f = self.composite_font(name)?; + let mut classes: Vec = Vec::new(); + for g in text.graphemes(true) { + let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + if !classes.contains(&c) { + classes.push(c); + } + } + if classes.is_empty() { + classes.push(CharClass::Kanji); + } + let mut fonts: Vec<(String, String)> = Vec::with_capacity(classes.len()); + for c in classes { + let font = self.part_font(&f, c); + if !fonts.contains(&font) { + fonts.push(font); + } + } + Some(fonts) + } + + /// How composite font `name` draws the runs of text `texts` (each split into grapheme clusters + /// on its own, as layout splits runs): the worst match among the parts their grapheme clusters + /// fall in (missing, then another style, then exact), the face of the first part with that + /// match, and the characters (neither whitespace nor control characters, sorted, each once) + /// that their part's face lacks. `None` when `name` is no composite font. + pub fn composite_coverage(&self, name: &str, texts: &[&str]) -> Option { + let f = self.composite_font(name)?; + let rank = |m: FontMatch| match m { + FontMatch::Exact => 0, + FontMatch::Style => 1, + FontMatch::Missing => 2, + }; + let mut faces: Vec<(CharClass, Resolved)> = Vec::new(); + let mut face_of = |class: CharClass| { + if let Some((_, found)) = faces.iter().find(|(c, _)| *c == class) { + return found.clone(); + } + let (family, style) = self.part_font(&f, class); + let found = self.resolve(&family, &style); + faces.push((class, found.clone())); + found + }; + let mut worst: Option<(FontMatch, Option>)> = None; + let mut lacking: Vec = Vec::new(); + let mut graphemes: Vec<&str> = texts.iter().flat_map(|t| t.graphemes(true)).collect(); + if graphemes.is_empty() { + graphemes.push(""); + } + for g in graphemes { + let class = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let found = face_of(class); + let m = found.as_ref().map_or(FontMatch::Missing, |(_, m)| *m); + if worst.as_ref().is_none_or(|(w, _)| rank(m) > rank(*w)) { + worst = Some((m, found.as_ref().map(|(face, _)| face.clone()))); + } + lacking.extend(g.chars().filter(|c| !c.is_whitespace() && !c.is_control() && !found.as_ref().is_some_and(|(face, _)| face.covers(*c)))); + } + lacking.sort_unstable(); + lacking.dedup(); + let (status, face) = worst.unwrap_or((FontMatch::Exact, None)); + Some(CompositeCoverage { status, face, lacking }) + } + + /// The family type in `family` is stored under when it is set in that font: a composite font's + /// own name, else the family of the face it resolves to (any name of a family, ヒラギノ角ゴシック, + /// is stored as its own, Hiragino Sans). `None` when the family isn't available. + pub fn stored_family(&self, family: &str, style: &str) -> Option { + if let Some(f) = self.composite_font(family) { + return Some(f.name.clone()); + } + match self.resolve(family, style) { + Some((face, m)) if m != FontMatch::Missing => Some(face.family.clone()), + _ => None, + } + } + + /// Make each of the composite fonts `fonts` available while the returned guard lives, in place + /// of a composite font of the same name that the database holds. An app's session registers + /// the composite fonts of its front document; with the guard, a document exported without a + /// session, or one that is not in front, draws its type in its own. Dropping the guard puts + /// back the font each one replaced, or forgets it, unless another guard still lends the same + /// definition or a font of that name has been set since. While one guard lends a font, another + /// guard's different definition of that name is not lent. + pub fn lend_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> { + let mut lent = Vec::new(); + let mut changed = false; + // A font a document can't use (its name an installed family's, or a value out of range) + // is not lent: an app leaves it out when it opens the document. + let usable: Vec<&CompositeFont> = fonts.iter().filter(|f| f.check().is_ok() && !self.has_installed_family(&f.name)).collect(); + if !usable.is_empty() { + let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); + let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + for f in usable { + let key = f.name.to_lowercase(); + // A loan still in force: lend the same definition again, and leave another as it is. + if let Some(loan) = loans.get_mut(&key) + && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) + { + if *loan.font == *f { + loan.guards += 1; + lent.push((key, loan.font.clone())); + } + continue; + } + if map.get(&key).is_some_and(|have| **have == *f) { + continue; + } + let font = Arc::new((*f).clone()); + let previous = map.insert(key.clone(), font.clone()); + loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1 }); + lent.push((key, font)); + changed = true; + } + } + if changed { + self.changed(); + } + LentCompositeFonts { db: self, lent } + } + + /// The family and style composite font `f` sets characters of `class` in: its part's, or the + /// fallback font's when that part names a composite font. + fn part_font(&self, f: &CompositeFont, class: CharClass) -> (String, String) { + let p = f.parts.get(class); + if self.composite_font(&p.font).is_some() { (FALLBACK_FAMILY.to_string(), "Regular".to_string()) } else { (p.font.clone(), p.style.clone()) } + } + + /// The names of the composite fonts, sorted. + pub fn composite_font_names(&self) -> Vec { + let mut v: Vec = self.composites.read().unwrap_or_else(|e| e.into_inner()).values().map(|f| f.name.clone()).collect(); + v.sort_by_key(|n| n.to_lowercase()); + v + } + + /// The family and style that stand for composite font `name` where one font is needed (its + /// metrics, whether it is missing): its Kanji part's, or the fallback font's when that part + /// names a composite font too. `None` when `name` is no composite font. + pub(super) fn composite_base(&self, name: &str) -> Option<(String, String)> { + let f = self.composite_font(name)?; + Some(self.part_font(&f, CharClass::Kanji)) + } + + /// `t` with each run set in a composite font split into runs of one [`CharClass`] each, set in + /// that class's font: its size and baseline shift from the part's size and baseline (in + /// percent of the run's size), its scales multiplied by the part's, and Kana scaled at the + /// center raised so the em box's center stays where it was. Each grapheme cluster goes with + /// its first character. Also the index of the run of `t` each run came from. `None` when no run + /// is set in a composite font. + pub fn composite_split(&self, t: &TextObject) -> Option<(TextObject, Vec)> { + if self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty() { + return None; + } + let fonts: Vec>> = + t.runs.iter().map(|r| if r.inline.is_some() { None } else { self.composite_font(&r.style.font_family) }).collect(); + if fonts.iter().all(Option::is_none) { + return None; + } + let mut runs = Vec::with_capacity(t.runs.len()); + let mut from = Vec::with_capacity(t.runs.len()); + for (i, (r, font)) in t.runs.iter().zip(&fonts).enumerate() { + let Some(font) = font else { + runs.push(r.clone()); + from.push(i); + continue; + }; + let mut start = 0; + let mut class = None; + for (at, g) in r.text.grapheme_indices(true) { + let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + if let Some(k) = class + && k != c + { + runs.push(self.composite_run(r.text.get(start..at).unwrap_or_default(), r, font, k)); + from.push(i); + start = at; + } + class = Some(c); + } + runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(CharClass::Kanji))); + from.push(i); + } + Some((TextObject { runs, ..t.clone() }, from)) + } + + /// Characters `text` of run `r` (set in composite font `font`), all of class `class`, as a run + /// set in that class's part. + fn composite_run(&self, text: &str, r: &TextRun, font: &CompositeFont, class: CharClass) -> TextRun { + let p = font.parts.get(class); + let mut st = r.style.clone(); + let base = st.size; + let (family, style) = self.part_font(font, class); + st.font_family = family; + st.font_style = style; + st.font_version = None; + st.font_axes.clear(); + st.size = base * p.size / 100.0; + st.baseline_shift += base * p.baseline / 100.0; + st.h_scale *= p.h_scale / 100.0; + st.v_scale *= p.v_scale / 100.0; + if class == CharClass::Kana && p.scale_at_center && p.v_scale != 100.0 { + let centre = self.style_face(&st).map_or(0.38, |f| f.ideographic_centre()); + st.baseline_shift += st.size * centre * (1.0 - p.v_scale / 100.0); + } + TextRun { text: text.to_string(), style: st, inline: None } + } +} + +/// A composite font [`FontDb::lend_composite_fonts`] put in the database. +pub(super) struct CompositeLoan { + /// The font lent. + font: Arc, + /// The font of that name it replaced, put back when the last guard lending it drops. + previous: Option>, + /// How many guards lend it. + guards: usize, +} + +/// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops. +#[must_use = "the fonts are given back when the guard drops"] +pub struct LentCompositeFonts<'a> { + db: &'a FontDb, + /// The lowercased names this guard lends, each with the font lent. + lent: Vec<(String, Arc)>, +} + +impl Drop for LentCompositeFonts<'_> { + fn drop(&mut self) { + if self.lent.is_empty() { + return; + } + let mut changed = false; + { + let mut map = self.db.composites.write().unwrap_or_else(|e| e.into_inner()); + let mut loans = self.db.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + for (key, font) in &self.lent { + // A later loan of that name is another guard's. + let Some(loan) = loans.get_mut(key).filter(|l| Arc::ptr_eq(&l.font, font)) else { continue }; + loan.guards -= 1; + if loan.guards > 0 { + continue; + } + let previous = loan.previous.take(); + loans.remove(key); + // Given back unless a font of that name has been set or removed since. + if map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { + match previous { + Some(p) => map.insert(key.clone(), p), + None => map.remove(key), + }; + changed = true; + } + } + } + if changed { + self.db.changed(); + } + } +} diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 9cc32eeee..902a3e5aa 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -69,6 +69,8 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] stamp: Mutex::new(None), instances: Mutex::new(Instances::default()), + composites: RwLock::new(HashMap::new()), + composite_loans: Mutex::new(HashMap::new()), } } @@ -150,7 +152,8 @@ impl FontDb { /// The families font menus and lists show: [`FontDb::family_list`] without the system's hidden /// families, whose names start with "." (macOS keeps ".SF NS", ".LastResort" and others for its /// own interface, and Mac apps don't list them). Those still resolve by name, for documents and - /// fallbacks that name them. Cheap to call every frame. + /// fallbacks that name them. The composite fonts ([`FontDb::composite_font_names`]) come first. + /// Cheap to call every frame. pub fn menu_family_list(&self) -> Arc<[String]> { let all = self.family_list(); let mut cache = self.menu_cache.lock().unwrap_or_else(|e| e.into_inner()); @@ -159,7 +162,8 @@ impl FontDb { { return list.clone(); } - let list: Arc<[String]> = all.iter().filter(|f| !f.starts_with('.')).cloned().collect(); + let composites = self.composite_font_names(); + let list: Arc<[String]> = composites.into_iter().chain(all.iter().filter(|f| !f.starts_with('.')).cloned()).collect(); *cache = Some((all, list.clone())); list } @@ -187,7 +191,7 @@ impl FontDb { let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).filter(|f| !f.starts_with('.')).collect(); v.sort_by_key(|a| a.to_lowercase()); v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - v.into() + self.composite_font_names().into_iter().chain(v).collect() } /// How a font menu labels `family`: the English name when `english` is on (Preferences › Type › diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 5ab31eeae..fa59ff8ee 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -193,6 +193,8 @@ pub struct FontFace { pub(super) ideographic_centre: std::sync::OnceLock, /// [`Self::icf_margins`], read once. pub(super) icf_margins: std::sync::OnceLock, + /// [`Self::path_align_heights`], read once. + pub(super) path_align: std::sync::OnceLock, /// Has `sbix` or `CBDT` strikes: colour bitmap glyphs ([`FontDb::glyph_image`]). pub(super) colour_bitmaps: bool, /// Has `COLR` colour glyphs ([`FontDb::glyph_colour`]). @@ -212,6 +214,26 @@ pub struct IcfMargins { pub left: f64, } +/// The heights above the baseline, in ems, that Type on a Path's Align to Path runs along the +/// path ([`FontFace::path_align_heights`]). These are VectorCraft's own values. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct PathAlignHeights { + /// Ascender: the top of the face's em box ([`FontFace::em_box_top`]). + pub(crate) top: f64, + /// Descender (negative): the bottom of the font's bounding box (`head`), else the face's + /// descent. + pub(crate) bottom: f64, + /// Center: half the height of the top of the H above the baseline in whole thousandths of an + /// em, else half the OS/2 cap height, else the middle of the em box. + pub(crate) center: f64, +} + +/// The characters whose ink an ideographic face's em box is centered on, the first the face has. +const EM_BOX_IDEOGRAPHS: [char; 4] = ['永', '鬱', '곬', '・']; + +/// A box-drawing character whose ink, in an ideographic face, is one em tall: the em box. +const EM_BOX_CROSS: char = '┼'; + impl std::fmt::Debug for FontFace { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "FontFace({} {})", self.family, self.style) @@ -462,6 +484,111 @@ impl FontFace { *self.icf_margins.get_or_init(|| self.icf_from_base().or_else(|| self.icf_from_glyphs()).unwrap_or_default()) } + /// The lines Type on a Path's Align to Path runs along the path, in ems above the baseline + /// ([`PathAlignHeights`]): the top of the em box ([`Self::em_box_top`], else 0.88 em), the + /// bottom of the font's bounding box, and half the height of the top of the H. + pub(crate) fn path_align_heights(&self) -> PathAlignHeights { + *self.path_align.get_or_init(|| { + use skrifa::raw::TableProvider; + let em = self.upem; + let top = self.em_box_top().unwrap_or(0.88); + let bottom = + self.skrifa().and_then(|f| f.head().ok()).map(|h| f64::from(h.y_min()) / em).filter(|b| b.is_finite() && *b <= 0.0 && *b > -4.0); + // Measured from the baseline to the top of the H, as a cap height is, wherever its ink starts. + let cap = self.glyph_ink('H').map(|(_, top)| top / em).or_else(|| self.os2_cap_height()).filter(|c| c.is_finite() && *c < 2.0); + PathAlignHeights { + top, + bottom: bottom.unwrap_or(-self.descent / em), + center: cap.map_or(top - 0.5, |cap| (cap * 1000.0).round() / 2000.0), + } + }) + } + + /// The top of the face's em box, in ems above the baseline. A `BASE` table with an `ideo` + /// baseline gives it: its `idtp`, else one em above `ideo`. In a face whose OS/2 code pages + /// include Chinese, Japanese or Korean, it is the typographic ascender of the typographic box + /// (else, in a TrueType face, of the `hhea` box) when that box holds the ink of the first of 永, + /// 鬱, 곬 and ・ the face has, and is one em tall or leaves equal margins above and below the + /// ink; a face with none of them needs the box to be one em tall. Else it is the top of a ┼ + /// one em tall, else the top of an em box centered on the ink. In any other face the em box is + /// centered on the cap height: the OS/2 value, else the lower of the tops of H and O in whole + /// thousandths of an em. `None` when none of these applies. + pub(crate) fn em_box_top(&self) -> Option { + use skrifa::raw::TableProvider; + let f = self.skrifa()?; + let em = self.upem; + let sane = |v: f64| (v.is_finite() && v.abs() < 4.0).then_some(v); + // A roman baseline below the ideographic one is a damaged table: the baselines are ignored. + if let Some(h) = f.base().ok().and_then(|b| b.horiz_axis()).and_then(Result::ok).and_then(base_values) + && let Some(&ideo) = h.get(b"ideo") + && h.get(b"romn").is_none_or(|&romn| romn >= ideo) + { + return sane(h.get(b"idtp").copied().unwrap_or(ideo + em) / em); + } + // ulCodePageRange1 bits 17 to 21: JIS/Japan, Chinese Simplified, Korean Wansung, Chinese + // Traditional and Korean Johab. + let cjk = f.os2().ok().and_then(|o| o.ul_code_page_range_1()).is_some_and(|r| r & (0b1_1111 << 17) != 0); + if !cjk { + let lower_top = || { + let ((_, h), (_, o)) = (self.glyph_ink('H')?, self.glyph_ink('O')?); + Some((h.min(o) / em * 1000.0).round() / 1000.0) + }; + let cap = self.os2_cap_height().or_else(lower_top).filter(|c| c.is_finite() && *c > 0.0 && *c < 2.0)?; + return Some((cap + 1.0) * 0.5); + } + // Heights in 65,536ths of an em, as the em box is kept. + let units = |v: f64| (v / em * 65536.0).round(); + let ink = EM_BOX_IDEOGRAPHS.into_iter().find_map(|c| self.glyph_ink(c)); + // A variable font's MVAR table moves the OS/2 typographic ascender (`hasc`) and descender + // (`hdsc`); it has no tags for the `hhea` ascender and descender, which do not vary: + // https://learn.microsoft.com/en-us/typography/opentype/spec/mvar + let varied = |tag: &[u8; 4]| { + f.mvar().ok().and_then(|m| m.metric_delta(skrifa::Tag::new(tag), self.location().coords()).ok()).map_or(0.0, |d| d.to_f64()) + }; + let typo = f + .os2() + .ok() + .map(|o| (f64::from(o.s_typo_ascender()) + varied(b"hasc"), f64::from(o.s_typo_descender()) + varied(b"hdsc"))) + .filter(|(ascent, descent)| ascent > descent) + .or_else(|| { + let truetype = f.table_data(skrifa::Tag::new(b"glyf")).is_some(); + f.hhea().ok().filter(|_| truetype).map(|h| (f64::from(h.ascender().to_i16()), f64::from(h.descender().to_i16()))) + }) + .filter(|(ascent, descent)| ascent > descent); + if let Some((ascent, descent)) = typo { + let one_em = units(ascent - descent) == 65536.0; + let holds = match ink { + None => one_em, + Some((lo, hi)) => hi <= ascent && lo >= descent && (one_em || units((ascent - hi) - (lo - descent)).abs() <= 197.0), + }; + if holds { + return sane(ascent / em); + } + } + if let Some((lo, hi)) = self.glyph_ink(EM_BOX_CROSS) + && (65535.0..=65537.0).contains(&units(hi - lo)) + { + return sane(hi / em); + } + ink.and_then(|(lo, hi)| sane((lo + hi) / em * 0.5 + 0.5)) + } + + /// The bottom and top of the ink of `c`'s glyph, in font units; `None` when the face has no + /// glyph for it, or the glyph has no ink. + fn glyph_ink(&self, c: char) -> Option<(f64, f64)> { + let g = self.glyph_for(c); + if g == 0 { + return None; + } + let b = self.skrifa()?.glyph_metrics(Size::unscaled(), self.location()).bounds(GlyphId::new(g))?; + Some((f64::from(b.y_min), f64::from(b.y_max))).filter(|(lo, hi)| hi > lo) + } + + /// The cap height of the OS/2 table, in ems; `None` without one. + fn os2_cap_height(&self) -> Option { + self.skrifa()?.metrics(Size::unscaled(), self.location()).cap_height.map(|c| f64::from(c) / self.upem).filter(|c| *c > 0.0) + } + /// The height of the face that a font size measures with `reference`, in ems: 1 for the em /// box; the cap height or x height of its OS/2 table; the height of its ideographic character /// face ([`Self::icf_margins`]), or across a `vertical` line its width. None for a face whose diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index 010f96ffa..71e8e7ab8 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -82,8 +82,11 @@ impl FontDb { /// The kind of `family` (whether it sets Japanese, its class), for font menu filters: from a /// loaded face, else from the system font scan. Variable when any of its faces, loaded or /// scanned, is: a family may install static files beside a variable one. Cheap: no font file - /// is read, and a loaded family doesn't start the scan. + /// is read, and a loaded family doesn't start the scan. A composite font takes its base font's. pub fn family_traits(&self, family: &str) -> FontTraits { + if let Some((base, _)) = self.composite_base(family) { + return self.family_traits(&base); + } let loaded = { let faces = self.read_faces(); let mut of_family = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)); @@ -101,8 +104,11 @@ impl FontDb { } /// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how - /// it matched. + /// it matched. A composite font matches as its base font does ([`Self::composite_base`]). pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc, FontMatch)> { + if let Some((base, base_style)) = self.composite_base(family) { + return self.resolve(&base, &base_style); + } let (family, style) = self.canonical(family, style); let f = self.face(&family, &style)?; let m = if !f.family.eq_ignore_ascii_case(&family) { @@ -159,8 +165,12 @@ impl FontDb { /// Resolve a family + style to a face, falling back to the closest style of the family, then to /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. `None` /// only if no font at all is loaded (the bundled fonts failed to parse, or were left out with - /// the `bundled-fonts` feature and none was added), in which case text has no glyphs. + /// the `bundled-fonts` feature and none was added), in which case text has no glyphs. A + /// composite font gives its base font's face ([`Self::composite_base`]). pub fn face(&self, family: &str, style: &str) -> Option> { + if let Some((base, base_style)) = self.composite_base(family) { + return self.face(&base, &base_style); + } let (family, style) = self.canonical(family, style); let (family, style) = (family.as_str(), style.as_str()); let found = self.find(family, style); @@ -352,8 +362,16 @@ impl FontDb { self.family_list().iter().find(|f| norm_chars(f).eq(norm_chars(name))).cloned() } - /// Is `family` available (loaded, or installed on the system)? + /// Is `family` available (loaded, installed on the system, or a composite font)? pub fn has_family(&self, family: &str) -> bool { + if self.composite_font(family).is_some() { + return true; + } + self.has_installed_family(family) + } + + /// Is `family` available as a loaded or installed font, excluding composite fonts? + pub fn has_installed_family(&self, family: &str) -> bool { let (family, _) = self.canonical(family, ""); self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 6e9334041..053cd2981 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -115,6 +115,10 @@ struct Ctx<'a> { db: &'a FontDb, text: &'a str, runs: Vec<(Range, &'a CharStyle)>, + /// The runs as the text sets them, when `runs` are the pieces a composite font splits them + /// into ([`FontDb::composite_split`]); empty otherwise. Ruby readings take their style from + /// these, so a reading in a composite font is set in its own parts. + set_runs: Vec<(Range, &'a CharStyle)>, /// The inline graphic of each run that is one (parallel to `runs`). inlines: Vec>, /// The face of each run (parallel to `runs`), looked up once ([`run_face`]). @@ -151,6 +155,16 @@ impl Ctx<'_> { self.run_at(b).and_then(|i| self.runs.get(i)).map_or(&self.default, |(_, s)| *s) } + /// The style the text sets byte `b` in: [`Self::style_at`], or for text in a composite font + /// the run's style before it was split by class ([`Self::set_runs`]). + fn set_style_at(&self, b: usize) -> &CharStyle { + let i = self.set_runs.partition_point(|(r, _)| r.end <= b); + match self.set_runs.get(i).or_else(|| self.set_runs.last()) { + Some((_, s)) => s, + None => self.style_at(b), + } + } + /// The face of [`Self::style_at`] `b` ([`FontDb::style_face`]). fn face_at(&self, b: usize) -> Option> { match self.run_at(b).and_then(|i| self.runs.get(i).map(|(_, s)| (i, *s))) { diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index f55750e95..75796e265 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -276,8 +276,27 @@ pub(crate) fn layout_faces_per_call(db: &FontDb, t: &TextObject) -> TextLayout { l } -/// One layout pass, without fitting. +/// One layout pass, without fitting. Runs set in a composite font are laid out as the runs of one +/// class each that [`FontDb::composite_split`] makes of them; the glyphs, ruby and inline +/// graphics still name the runs of `t`. fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { + let Some((split, from)) = db.composite_split(t) else { return layout_runs(db, t, opts, None) }; + let mut l = layout_runs(db, &split, opts, Some(t)); + let original = |run: &mut usize| *run = from.get(*run).copied().unwrap_or(*run); + l.glyphs.iter_mut().chain(l.ruby.iter_mut()).for_each(|g| original(&mut g.run)); + l.inlines.iter_mut().for_each(|i| original(&mut i.run)); + l +} + +/// [`layout_once`] for text set in no composite font: `t` itself, or with `set`, the pieces a +/// composite font splits `set` into (ruby readings take their style from `set`'s runs). +fn layout_runs(db: &FontDb, t: &TextObject, opts: &LayoutOptions, set: Option<&TextObject>) -> TextLayout { + let mut set_runs = Vec::new(); + let mut off = 0; + for r in set.map(|s| s.runs.as_slice()).unwrap_or_default() { + set_runs.push((off..off + r.text.len(), &r.style)); + off += r.text.len(); + } let text = t.plain_text(); let mut runs = Vec::with_capacity(t.runs.len()); let mut inlines = Vec::with_capacity(t.runs.len()); @@ -315,6 +334,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout text: &text, faces, runs, + set_runs, inlines, default: CharStyle::default(), opts, diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index 14bc3ef4b..aafe17531 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -6,6 +6,7 @@ use super::{ Affine, ArcPath, BezPath, Ctx, GlyphBend, Justify, LineInfo, ListMarker, ParamCurve, PathAlign, PathData, PathEffect, PathEl, Point, Range, SGlyph, Shape, TextObject, Vec2, style_metrics, }; +use crate::fontdb::PathAlignHeights; /// The distance along type on a path each glyph takes: its advance measured `spacing` points above /// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and @@ -315,16 +316,25 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, cx.out.overflow = true; fits = sg.get(fit..).and_then(|rest| rest.iter().map(|g| g.byte).min()).unwrap_or(text_len); } - // Align to Path: how far down (glyph space) the type moves to run its ascender, centre or - // descender along the path instead of its baseline. - let rise = match t.path_align { - PathAlign::Baseline => 0.0, - PathAlign::Ascender => m.0, - PathAlign::Descender => -m.1, - PathAlign::Center => (m.0 - m.1) * 0.5, + // Align to Path: how far down (glyph space) a glyph moves to run the top of its em box, half the + // height of the top of its H or the bottom of its font's bounding box along the path instead of its + // baseline, each glyph by its own face, size and vertical scale. In vertical type the glyph + // moves half its em (with its horizontal scale) for Ascender and Descender, and stays for Center. + // An inline graphic moves as a character of its run would, by its run's vertical scale in both. + let vertical = cx.vertical; + let rise_of = |g: &SGlyph| { + let em = g.face.units_per_em() * if vertical { g.sx } else { g.sy }; + let h = if vertical { PathAlignHeights { top: 0.5, bottom: -0.5, center: 0.0 } } else { g.face.path_align_heights() }; + match t.path_align { + PathAlign::Baseline => 0.0, + PathAlign::Ascender => h.top * em, + PathAlign::Descender => h.bottom * em, + PathAlign::Center => h.center * em, + } }; let mut x = 0.0; for (g, &step) in sg.iter().zip(&steps).take(fit) { + let rise = rise_of(g); let s = s0 + x; let (p, dir) = ap.at(s + step * 0.5); let angle = dir.y.atan2(dir.x); diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index 1cf34e24c..34cb71f09 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -127,7 +127,10 @@ fn ruby_piece_for_line( ) -> Option { let mut x0 = f64::INFINITY; let mut x1 = f64::NEG_INFINITY; + // The size the base is drawn at (where the reading goes), and the size the text sets it in (what + // the reading is scaled from: a composite font's part scales the reading itself). let mut base_size: f64 = 0.0; + let mut set_size: f64 = 0.0; let mut run = 0; let mut byte = start; for (index, &glyph_index) in glyph_indices.iter().enumerate() { @@ -135,6 +138,7 @@ fn ruby_piece_for_line( x0 = x0.min(glyph.origin.x); x1 = x1.max(glyph.origin.x + glyph.advance); base_size = base_size.max(cx.style_at(glyph.byte).size); + set_size = set_size.max(cx.set_style_at(glyph.byte).size); if index == 0 { run = glyph.run; byte = glyph.byte; @@ -143,8 +147,9 @@ fn ruby_piece_for_line( if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { return None; } - let mut style = cx.style_at(byte).clone(); - style.size = base_size * ratio; + // The base's style as the text sets it: a reading in a composite font is set in its parts. + let mut style = cx.set_style_at(byte).clone(); + style.size = set_size * ratio; style.leading = None; style.baseline_shift = 0.0; style.tracking = 0.0; diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index aec815641..e54a49144 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -114,6 +114,7 @@ fn composition_trim_stays_along_the_line_when_a_character_rotates() { db: &db, text: "A", runs: vec![(0..1, &style)], + set_runs: Vec::new(), inlines: vec![None], faces: vec![std::cell::OnceCell::new()], default: style.clone(), diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ce315645e..2dc80a3e9 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -36,9 +36,9 @@ pub use bitmap::GlyphImage; 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 fontdb::{ - FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, 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, 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, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; pub use layout::{envelope_ink, register_envelope_ink}; diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 00c994467..cbb2c20d2 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -1073,6 +1073,160 @@ pub fn tall_ideograph_font() -> Option> { Some(data) } +/// A family of [`path_align_font`]: OS/2 cap height 660, H and O. +pub const PATH_ALIGN_ROMAN_FAMILY: &str = "Pathalign Roman Sans"; +/// A family of [`path_align_font`]: no cap height, no H or O, and a `head` box above the baseline. +pub const PATH_ALIGN_BARE_FAMILY: &str = "Pathalign Bare Sans"; +/// A family of [`path_align_font`]: a `BASE` table with `ideo` at -150 and no `idtp`. +pub const PATH_ALIGN_BASE_FAMILY: &str = "Pathalign Base Sans"; +/// A family of [`path_align_font`]: Japanese code page, 永 on the ⌠ outline (ink -51 to 949). +pub const PATH_ALIGN_IDEOGRAPH_FAMILY: &str = "Pathalign Ideograph Sans"; +/// A family of [`path_align_font`]: as [`PATH_ALIGN_IDEOGRAPH_FAMILY`], with ┼ on the | outline +/// (ink -250 to 750, one em). +pub const PATH_ALIGN_CROSS_FAMILY: &str = "Pathalign Cross Sans"; +/// A family of [`path_align_font`]: Japanese code page, 永 on the H outline (ink 0 to 656), and a +/// typographic box from -100 to 900. +pub const PATH_ALIGN_TYPO_FAMILY: &str = "Pathalign Typo Sans"; +/// A family of [`path_align_font`]: as [`PATH_ALIGN_TYPO_FAMILY`], with a typographic box of 0 +/// to 0 and an `hhea` box from -100 to 900. +pub const PATH_ALIGN_HHEA_FAMILY: &str = "Pathalign Hhea Sans"; +/// A family of [`path_align_font`]: as [`PATH_ALIGN_TYPO_FAMILY`], as a variable font with one +/// weight axis (400–700, named instances Regular and Bold) whose `MVAR` table raises the +/// typographic ascender and descender by 100 at 700: a box from 0 to 1,000 in Bold. +pub const PATH_ALIGN_VARIABLE_FAMILY: &str = "Pathalign Variable Sans"; +/// A family of [`path_align_font`]: Japanese code page, with no reference ideograph and no ┼. +pub const PATH_ALIGN_NONE_FAMILY: &str = "Pathalign None Sans"; +/// A family of [`path_align_font`]: no cap height, and H and O swapped (H top 668, O top 656). +pub const PATH_ALIGN_LOWER_FAMILY: &str = "Pathalign Lower Sans"; +/// A family of [`path_align_font`]: OS/2 cap height 660, O and no H, and a `BASE` table as +/// [`PATH_ALIGN_BASE_FAMILY`]'s. +pub const PATH_ALIGN_CAPITAL_FAMILY: &str = "Pathalign Capital Sans"; +/// A family of [`path_align_font`]: as [`PATH_ALIGN_IDEOGRAPH_FAMILY`], with a typographic box +/// from -101 to 1,002: margins of 53 and 50 round 永's ink. +pub const PATH_ALIGN_MARGINS_FAMILY: &str = "Pathalign Margins Sans"; +/// A family of [`path_align_font`]: as [`PATH_ALIGN_IDEOGRAPH_FAMILY`], with a typographic box +/// from -101 to 1,003: margins of 54 and 50 round 永's ink. +pub const PATH_ALIGN_UNEVEN_FAMILY: &str = "Pathalign Uneven Sans"; +/// A family of [`path_align_font`]: a `BASE` table with `ideo` at -150 and `romn` below it at +/// -200. +pub const PATH_ALIGN_DAMAGED_FAMILY: &str = "Pathalign Damaged Sans"; + +/// The bundled Source Sans 3 Regular (1,000 units to the em, typographic box -326 to 1,000, `head` +/// box bottom -295, H top 656, O top 668) without its `BASE` table, as one of the +/// `PATH_ALIGN_*_FAMILY` faces, which map x, and H and O except where the family says otherwise. +/// `None` when it can't be built. +pub fn path_align_font(family: &str) -> Option> { + use write_fonts::tables::base::{Axis, Base, BaseCoord, BaseScript, BaseScriptList, BaseScriptRecord, BaseTagList, BaseValues}; + use write_fonts::tables::mvar::{Mvar, ValueRecord}; + use write_fonts::tables::variations::{ItemVariationData, ItemVariationStore, RegionAxisCoordinates, VariationRegion, VariationRegionList}; + use write_fonts::types::MajorMinor; + + let typo = family == PATH_ALIGN_TYPO_FAMILY || family == PATH_ALIGN_VARIABLE_FAMILY; + let ideograph = [PATH_ALIGN_IDEOGRAPH_FAMILY, PATH_ALIGN_CROSS_FAMILY, PATH_ALIGN_MARGINS_FAMILY, PATH_ALIGN_UNEVEN_FAMILY].contains(&family); + let japanese = typo || ideograph || [PATH_ALIGN_HHEA_FAMILY, PATH_ALIGN_NONE_FAMILY].contains(&family); + let mut mapping = vec![('x', 'x')]; + match family { + PATH_ALIGN_BARE_FAMILY => {} + PATH_ALIGN_LOWER_FAMILY => mapping.extend([('H', 'O'), ('O', 'H')]), + PATH_ALIGN_CAPITAL_FAMILY => mapping.push(('O', 'O')), + _ => mapping.extend([('H', 'H'), ('O', 'O')]), + } + if ideograph { + mapping.push(('永', '⌠')); + } + if family == PATH_ALIGN_CROSS_FAMILY { + mapping.push(('┼', '|')); + } + if typo || family == PATH_ALIGN_HHEA_FAMILY { + mapping.push(('永', 'H')); + } + let mut data = remapped_source_sans(family, &format!("{}-Regular", family.replace(' ', "")), &mapping)?; + let tables: Vec<(Tag, usize)> = { + let font = skrifa::FontRef::new(&data).ok()?; + font.table_directory.table_records().iter().map(|r| (Tag::new(&r.tag().to_be_bytes()), r.offset() as usize)).collect() + }; + for (tag, at) in tables { + if tag == Tag::new(b"OS/2") { + if japanese { + // ulCodePageRange1 at bytes 78 to 82: bit 17, JIS/Japan. + data.get_mut(at + 78..at + 82)?.copy_from_slice(&(1_u32 << 17).to_be_bytes()); + } + if typo { + // sTypoAscender and sTypoDescender at bytes 68 to 72. + data.get_mut(at + 68..at + 72)?.copy_from_slice(&[900_i16.to_be_bytes(), (-100_i16).to_be_bytes()].concat()); + } + if family == PATH_ALIGN_HHEA_FAMILY { + data.get_mut(at + 68..at + 72)?.copy_from_slice(&[0, 0, 0, 0]); + } + if family == PATH_ALIGN_MARGINS_FAMILY || family == PATH_ALIGN_UNEVEN_FAMILY { + let top: i16 = if family == PATH_ALIGN_MARGINS_FAMILY { 1002 } else { 1003 }; + data.get_mut(at + 68..at + 72)?.copy_from_slice(&[top.to_be_bytes(), (-101_i16).to_be_bytes()].concat()); + } + if family == PATH_ALIGN_BARE_FAMILY || family == PATH_ALIGN_LOWER_FAMILY { + // sCapHeight at bytes 88 to 90. + data.get_mut(at + 88..at + 90)?.copy_from_slice(&0_i16.to_be_bytes()); + } + } else if tag == Tag::new(b"hhea") && family == PATH_ALIGN_HHEA_FAMILY { + // ascender and descender at bytes 4 to 8. + data.get_mut(at + 4..at + 8)?.copy_from_slice(&[900_i16.to_be_bytes(), (-100_i16).to_be_bytes()].concat()); + } else if tag == Tag::new(b"head") && family == PATH_ALIGN_BARE_FAMILY { + // yMin at bytes 38 to 40. + data.get_mut(at + 38..at + 40)?.copy_from_slice(&10_i16.to_be_bytes()); + } + } + // Source Sans 3's own `BASE` table (`ideo` -170) is left out. + let font = skrifa::FontRef::new(&data).ok()?; + let mut builder = FontBuilder::new(); + if [PATH_ALIGN_BASE_FAMILY, PATH_ALIGN_CAPITAL_FAMILY, PATH_ALIGN_DAMAGED_FAMILY].contains(&family) { + let romn = if family == PATH_ALIGN_DAMAGED_FAMILY { -200 } else { 0 }; + let tags = BaseTagList::new([b"ideo", b"romn"].map(Tag::new).to_vec()); + let values = BaseValues::new(1, [-150, romn].map(BaseCoord::format_1).to_vec()); + let latn = BaseScriptRecord::new(Tag::new(b"latn"), BaseScript::new(Some(values), None, vec![])); + let axis = Axis::new(Some(tags), BaseScriptList::new(vec![latn])); + builder.add_table(&Base::new(Some(axis), None)).ok()?; + } + if family == PATH_ALIGN_VARIABLE_FAMILY { + let region = VariationRegion::new(vec![RegionAxisCoordinates::new(F2Dot14::from_f32(0.0), F2Dot14::from_f32(1.0), F2Dot14::from_f32(1.0))]); + // Two delta sets (hasc, hdsc) of one 16-bit delta each for the one region. + let data = ItemVariationData::new(2, 1, vec![0], [100_i16.to_be_bytes(), 100_i16.to_be_bytes()].concat()); + let store = ItemVariationStore::new(VariationRegionList::new(1, vec![region]), vec![Some(data)]); + let records = vec![ValueRecord::new(Tag::new(b"hasc"), 0, 0), ValueRecord::new(Tag::new(b"hdsc"), 0, 1)]; + let axis = VariationAxisRecord { + axis_tag: Tag::new(b"wght"), + min_value: Fixed::from_i32(400), + default_value: Fixed::from_i32(400), + max_value: Fixed::from_i32(700), + flags: 0, + axis_name_id: NameId::new(256), + }; + let instance = |name: u16, wght: i32| InstanceRecord { + subfamily_name_id: NameId::new(name), + flags: 0, + coordinates: vec![Fixed::from_i32(wght)], + post_script_name_id: None, + }; + let fvar = Fvar::new(AxisInstanceArrays::new(vec![axis], vec![instance(257, 400), instance(258, 700)])); + let name = name_table(&[ + (1, family), + (2, "Regular"), + (4, "Pathalign Variable Sans Regular"), + (6, "PathalignVariableSans-Regular"), + (256, "Weight"), + (257, "Regular"), + (258, "Bold"), + ])?; + builder.add_raw(Tag::new(b"name"), name); + builder.add_table(&fvar).ok()?.add_table(&Mvar::new(MajorMinor::VERSION_1_0, Some(store), records)).ok()?; + } + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) && tag != Tag::new(b"BASE") { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`soft_hyphen_outline_font`]. pub const SOFT_HYPHEN_OUTLINE_FAMILY: &str = "Soft Hyphen Profile Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 6183278ed..ef171316e 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -9,6 +9,7 @@ mod bidi; mod bitmaps; mod colr; mod combos; +mod composite; mod devanagari; mod embed; mod fit; @@ -454,6 +455,158 @@ fn on_path_effects_orient_glyphs() { assert_eq!(PathEffect::parse("stair step"), Some(PathEffect::StairStep)); } +/// Type on a straight path from (0, 50) to (500, 50), one run per style: how far each glyph's +/// baseline lies below the path under each Align to Path option, or with `ink` the middle of its +/// drawn outline. +fn path_align_drops(db: &FontDb, styles: &[CharStyle], vertical: bool, ink: bool) -> Vec<(vectorcraft_doc::PathAlign, Vec)> { + use vectorcraft_doc::PathAlign; + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let mut t = point("", style(12.0)); + t.runs = styles.iter().map(|st| TextRun::new("x", st.clone())).collect(); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.vertical = vertical; + [PathAlign::Baseline, PathAlign::Ascender, PathAlign::Center, PathAlign::Descender] + .into_iter() + .map(|align| { + t.path_align = align; + let drop = |g: &PositionedGlyph| { + let b = g.outline.bounding_box(); + if ink { (b.y0 + b.y1) * 0.5 - 50.0 } else { g.origin.y - 50.0 } + }; + (align, layout(db, &t).glyphs.iter().map(drop).collect()) + }) + .collect() +} + +/// Align to Path moves each glyph's baseline off the path by its own face, size and vertical +/// scale: Ascender by the top of an em box centered on the cap height, Center by half the height +/// of the top of the H in whole thousandths of an em, Descender up by the bottom of the font's +/// bounding box. Without an OS/2 cap height, the em box is centered on the lower of the tops of H +/// and O; without an H, Center takes half the OS/2 cap height. A face with no cap height and no H +/// or O takes an em box from 0.12 em below the baseline to 0.88 em above it and, with a bounding +/// box above the baseline, its descent. Leading and horizontal scale do not count. +#[test] +fn on_path_align_runs_each_glyph_by_its_own_metrics() { + use crate::test_fonts::{PATH_ALIGN_BARE_FAMILY, PATH_ALIGN_CAPITAL_FAMILY, PATH_ALIGN_LOWER_FAMILY, PATH_ALIGN_ROMAN_FAMILY, path_align_font}; + use vectorcraft_doc::PathAlign; + let db = FontDb::with_font_dirs(vec![]); + for family in [PATH_ALIGN_ROMAN_FAMILY, PATH_ALIGN_BARE_FAMILY, PATH_ALIGN_LOWER_FAMILY, PATH_ALIGN_CAPITAL_FAMILY] { + db.add_font(path_align_font(family).unwrap()); + } + let bare = db.face(PATH_ALIGN_BARE_FAMILY, "Regular").unwrap(); + let bare_descent = bare.vertical_metrics().1 / bare.units_per_em(); + let st = |family: &str, size: f64| CharStyle { font_family: family.into(), ..style(size) }; + let styles = [ + st(PATH_ALIGN_ROMAN_FAMILY, 20.0), + st(PATH_ALIGN_ROMAN_FAMILY, 40.0), + CharStyle { v_scale: 200.0, ..st(PATH_ALIGN_ROMAN_FAMILY, 20.0) }, + CharStyle { h_scale: 200.0, leading: Some(90.0), ..st(PATH_ALIGN_ROMAN_FAMILY, 20.0) }, + st(PATH_ALIGN_BARE_FAMILY, 20.0), + st(PATH_ALIGN_LOWER_FAMILY, 20.0), + st(PATH_ALIGN_CAPITAL_FAMILY, 20.0), + ]; + // Per em: the cap height is 0.66 em, the H 0.656 em tall, the bounding box's bottom -0.295 em; + // the lower face's H is 0.668 em tall and its O 0.656 em; the capital face's em box runs from + // its `BASE` table's `ideo` at -0.15 em. + for (align, drops) in path_align_drops(&db, &styles, false, false) { + let (roman, bare, lower, capital) = match align { + PathAlign::Baseline => (0.0, 0.0, 0.0, 0.0), + PathAlign::Ascender => ((0.66 + 1.0) / 2.0, 0.88, (0.656 + 1.0) / 2.0, 0.85), + PathAlign::Center => (0.328, 0.38, 0.334, 0.33), + PathAlign::Descender => (-0.295, -bare_descent, -0.295, -0.295), + }; + let want = [roman * 20.0, roman * 40.0, roman * 40.0, roman * 20.0, bare * 20.0, lower * 20.0, capital * 20.0]; + assert!(drops.len() == 7 && drops.iter().zip(want).all(|(d, w)| (d - w).abs() < 1e-6), "{align:?}: {drops:?}, want {want:?}"); + } +} + +/// The em box whose top Ascender runs along the path, by the kind of face: a `BASE` table's +/// ideographic baseline, unless its roman baseline lies below it; in a face with a Chinese, +/// Japanese or Korean code page, a typographic box that holds the ink of 永 and is one em tall or +/// leaves margins within 197/65,536 em of each other, else a ┼ one em tall, else an em box +/// centered on the ink of 永; else 0.88 em. Center measures the H, not the OS/2 cap height (0.7 em +/// in the heights face). +#[test] +fn on_path_align_finds_the_em_box_of_each_kind_of_face() { + use crate::test_fonts::{ + HEIGHT_FAMILY, PATH_ALIGN_BASE_FAMILY, PATH_ALIGN_CROSS_FAMILY, PATH_ALIGN_DAMAGED_FAMILY, PATH_ALIGN_HHEA_FAMILY, + PATH_ALIGN_IDEOGRAPH_FAMILY, PATH_ALIGN_MARGINS_FAMILY, PATH_ALIGN_NONE_FAMILY, PATH_ALIGN_TYPO_FAMILY, PATH_ALIGN_UNEVEN_FAMILY, + height_font, path_align_font, + }; + use vectorcraft_doc::PathAlign; + let db = FontDb::with_font_dirs(vec![]); + let families = [ + PATH_ALIGN_BASE_FAMILY, + PATH_ALIGN_IDEOGRAPH_FAMILY, + PATH_ALIGN_CROSS_FAMILY, + PATH_ALIGN_TYPO_FAMILY, + PATH_ALIGN_HHEA_FAMILY, + PATH_ALIGN_NONE_FAMILY, + PATH_ALIGN_MARGINS_FAMILY, + PATH_ALIGN_UNEVEN_FAMILY, + PATH_ALIGN_DAMAGED_FAMILY, + ]; + for family in families { + db.add_font(path_align_font(family).unwrap()); + } + db.add_font(height_font(true).unwrap()); + let styles: Vec = families.iter().chain([&HEIGHT_FAMILY]).map(|f| CharStyle { font_family: (*f).into(), ..style(10.0) }).collect(); + let drops = path_align_drops(&db, &styles, false, false); + let of = |align: PathAlign| drops.iter().find(|(a, _)| *a == align).map(|(_, d)| d.clone()).unwrap(); + // BASE -150 + one em; Japanese: the ⌠'s ink -51 to 949 centered, the | from -250 to 750, the + // typographic box -100 to 900, the `hhea` box -100 to 900 under an empty typographic box, + // nothing, the typographic box -101 to 1,002 (margins 3/1,000 em apart), the ink centered + // under a box -101 to 1,003 (4/1,000 em apart); a damaged BASE table's face by its cap height + // 660; the heights face's BASE `idtp` 880. + let want = [0.85, 0.949, 0.75, 0.9, 0.9, 0.88, 1.002, 0.949, 0.83, 0.88].map(|em| em * 10.0); + let ascender = of(PathAlign::Ascender); + assert!(ascender.len() == 10 && ascender.iter().zip(want).all(|(d, w)| (d - w).abs() < 1e-6), "{ascender:?}, want {want:?}"); + let center = of(PathAlign::Center); + assert!((center[9] - 3.28).abs() < 1e-6, "{center:?}"); +} + +/// A variable font's instance takes its em box from the typographic box its `MVAR` table varies: +/// from -100 to 900 in Regular, from 0 to 1,000 in Bold. +#[test] +fn on_path_align_takes_a_variable_font_instances_em_box() { + use crate::test_fonts::{PATH_ALIGN_VARIABLE_FAMILY, path_align_font}; + use vectorcraft_doc::PathAlign; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(path_align_font(PATH_ALIGN_VARIABLE_FAMILY).unwrap()); + let st = |font_style: &str| CharStyle { font_family: PATH_ALIGN_VARIABLE_FAMILY.into(), font_style: font_style.into(), ..style(10.0) }; + let drops = path_align_drops(&db, &[st("Regular"), st("Bold")], false, false); + let ascender = drops.iter().find(|(a, _)| *a == PathAlign::Ascender).map(|(_, d)| d.clone()).unwrap(); + assert!(ascender.len() == 2 && (ascender[0] - 9.0).abs() < 1e-6 && (ascender[1] - 10.0).abs() < 1e-6, "{ascender:?}"); +} + +/// In vertical type, Align to Path moves the glyphs half an em, with their horizontal scale and not +/// their vertical scale, for Ascender and Descender, and leaves them on the path for Center: their +/// baselines and their drawn outlines both. +#[test] +fn on_path_align_in_vertical_type_moves_half_an_em() { + use crate::test_fonts::{PATH_ALIGN_ROMAN_FAMILY, path_align_font}; + use vectorcraft_doc::PathAlign; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(path_align_font(PATH_ALIGN_ROMAN_FAMILY).unwrap()); + let st = CharStyle { font_family: PATH_ALIGN_ROMAN_FAMILY.into(), ..style(20.0) }; + let styles = [st.clone(), CharStyle { h_scale: 200.0, ..st.clone() }, CharStyle { v_scale: 200.0, ..st }]; + for ink in [false, true] { + let baseline = path_align_drops(&db, &styles, true, ink).first().map(|(_, d)| d.clone()).unwrap(); + for (align, drops) in path_align_drops(&db, &styles, true, ink) { + let half = match align { + PathAlign::Ascender => 10.0, + PathAlign::Descender => -10.0, + _ => 0.0, + }; + let want = [half, half * 2.0, half]; + let moved: Vec = drops.iter().zip(&baseline).map(|(d, b)| d - b).collect(); + assert!(moved.len() == 3 && moved.iter().zip(want).all(|(m, w)| (m - w).abs() < 1e-6), "{align:?}, ink {ink}: {moved:?}, want {want:?}"); + } + } +} + #[test] fn on_path_circle_and_overflow() { let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1); diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs new file mode 100644 index 000000000..370fa4557 --- /dev/null +++ b/crates/text/src/tests/composite.rs @@ -0,0 +1,196 @@ +//! Composite fonts: each class of character set in its part's font. + +use crate::*; +use vectorcraft_doc::{CharStyle, CompositeFont, TextRun}; + +/// Base (Kanji) Source Sans 3; Roman in JetBrains Mono at half size, raised by a tenth of the size; +/// Numerals in Inter, scaled to 80% across; Kana scaled to 50% up from the center. +fn mixed(name: &str) -> CompositeFont { + let mut f = CompositeFont::new(name, "Source Sans 3", "Regular"); + f.parts.roman.font = "JetBrains Mono".into(); + f.parts.roman.size = 50.0; + f.parts.roman.baseline = 10.0; + f.parts.numerals.font = "Inter".into(); + f.parts.numerals.h_scale = 80.0; + f.parts.kana.v_scale = 50.0; + f.parts.kana.scale_at_center = true; + f +} + +fn style(family: &str, size: f64) -> CharStyle { + CharStyle { font_family: family.into(), size, ..CharStyle::default() } +} + +fn face_id(db: &FontDb, family: &str) -> u32 { + db.face(family, "Regular").unwrap().id() +} + +/// Each run in a composite font splits where the class changes; a grapheme cluster stays with its +/// first character, runs in other fonts are kept, and each piece remembers its run. +#[test] +fn composite_runs_split_by_class() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Mixed")); + let mut t = TextObject::point(Point::ZERO, "Ae\u{301}12\u{304B}\u{3099}国", style("Mixed", 20.0)); + t.runs.insert(0, TextRun::new("x", style("Inter", 12.0))); + let (split, from) = db.composite_split(&t).expect("split"); + let pieces: Vec<(&str, &str)> = split.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str())).collect(); + assert_eq!( + pieces, + [("x", "Inter"), ("Ae\u{301}", "JetBrains Mono"), ("12", "Inter"), ("\u{304B}\u{3099}", "Source Sans 3"), ("国", "Source Sans 3")] + ); + assert_eq!(from, [0, 1, 1, 1, 1]); + assert_eq!(split.plain_text(), t.plain_text()); + let roman = &split.runs[1].style; + assert_eq!((roman.size, roman.baseline_shift, roman.h_scale, roman.v_scale), (10.0, 2.0, 100.0, 100.0)); + let numerals = &split.runs[2].style; + assert_eq!((numerals.size, numerals.h_scale), (20.0, 80.0)); + // Kana at half height, raised by half the em box's center so the center stays put. + let kana = &split.runs[3].style; + let centre = db.face("Source Sans 3", "Regular").unwrap().ideographic_centre(); + assert_eq!(kana.v_scale, 50.0); + assert!((kana.baseline_shift - 20.0 * centre * 0.5).abs() < 1e-9, "{}", kana.baseline_shift); + assert_eq!(split.runs[4].style.baseline_shift, 0.0); + // Text in no composite font is not split. + assert!(db.composite_split(&TextObject::point(Point::ZERO, "Ab", style("Inter", 12.0))).is_none()); +} + +/// Laid out, each character is drawn in its class's font, sized by its part, and the glyphs still +/// name the run of the text they belong to. +#[test] +fn composite_type_draws_each_class_in_its_font() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Mixed")); + let mut t = TextObject::point(Point::ZERO, "x", style("Inter", 12.0)); + t.runs.push(TextRun::new("A1", style("Mixed", 20.0))); + let l = layout(&db, &t); + let fonts: Vec<(usize, u32)> = l.glyphs.iter().map(|g| (g.run, g.font_id)).collect(); + assert_eq!(fonts, [(0, face_id(&db, "Inter")), (1, face_id(&db, "JetBrains Mono")), (1, face_id(&db, "Inter"))]); + // `A` is set as JetBrains Mono at 10 pt would set it. + let alone = layout(&db, &TextObject::point(Point::ZERO, "A", style("JetBrains Mono", 10.0))); + assert!((l.glyphs[1].advance - alone.glyphs[0].advance).abs() < 1e-9); +} + +/// A ruby reading over type in a composite font is set in the parts its own characters fall in. +#[test] +fn ruby_over_composite_type_is_set_in_its_own_parts() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Ruby Parts")); + let mut t = TextObject::point(Point::ZERO, "\u{56FD}", style("Ruby Parts", 20.0)); + t.ruby = vec![vectorcraft_doc::text::RubySpan::new(0, 3, "A")]; + let l = layout(&db, &t); + let fonts: Vec = l.ruby.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face_id(&db, "JetBrains Mono")]); + assert_eq!(l.ruby[0].run, 0); + // Over a Roman base (a 50% part), the reading is half the 20 pt type, scaled once by its part: + // 5 pt, not the base's 10 pt halved twice. + let mut t = TextObject::point(Point::ZERO, "A", style("Ruby Parts", 20.0)); + t.ruby = vec![vectorcraft_doc::text::RubySpan::new(0, 1, "a")]; + let l = layout(&db, &t); + let alone = layout(&db, &TextObject::point(Point::ZERO, "a", style("JetBrains Mono", 5.0))); + assert_eq!(l.ruby.len(), 1); + // The width each glyph is drawn at, in text space. + let width = |g: &crate::PositionedGlyph| { + use kurbo::Shape; + g.outline.bounding_box().width() + }; + let (reading, expected) = (width(&l.ruby[0]), width(&alone.glyphs[0])); + assert!(expected > 0.0 && (reading - expected).abs() < 1e-6, "{reading} vs {expected}"); + // It sits above the base as drawn (10 pt), not above the 20 pt the text sets it in. + let rise = l.glyphs[0].origin.y - l.ruby[0].origin.y; + assert!(rise > 8.0 && rise < 15.0, "the reading is {rise} pt above the base's baseline"); +} + +/// Lent composite fonts are available while a guard lends them: a second guard lends one again, a +/// font of the same name the database holds is replaced and put back afterwards, and a font named +/// like an installed family is not lent. +#[test] +fn lent_composite_fonts_last_as_long_as_a_guard() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Held")); + let mut held = mixed("Held"); + held.parts.roman.size = 75.0; + let fonts = [mixed("Lent"), held, CompositeFont::new("Inter", "Source Sans 3", "Regular")]; + let first = db.lend_composite_fonts(&fonts); + assert!(db.composite_font("lent").is_some()); + assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 75.0); + assert!(db.composite_font("Inter").is_none()); + let second = db.lend_composite_fonts(&fonts); + drop(first); + assert!(db.composite_font("Lent").is_some()); + assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 75.0); + drop(second); + assert!(db.composite_font("Lent").is_none()); + assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 50.0); + // A font set in its place while lent stays when the guard drops. + let lent = db.lend_composite_fonts(&fonts[..1]); + let mut replaced = mixed("Lent"); + replaced.parts.roman.size = 60.0; + db.set_composite_font(&replaced); + drop(lent); + assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0); + // A guard whose font was removed doesn't give back a later guard's loan of that name. + assert!(db.remove_composite_font("Lent")); + let old = db.lend_composite_fonts(&fonts[..1]); + assert!(db.remove_composite_font("Lent")); + let new = db.lend_composite_fonts(&fonts[..1]); + drop(old); + assert!(db.composite_font("Lent").is_some()); + drop(new); + assert!(db.composite_font("Lent").is_none()); +} + +/// The fonts a composite font sets text in are those of the parts its characters fall in, and +/// coverage is checked against each character's part. +#[test] +fn composite_fonts_for_text_follow_each_characters_part() { + let db = FontDb::with_font_dirs(vec![]); + let mut f = mixed("Parts"); + f.parts.kana.font = "No Such Kana Font".into(); + db.set_composite_font(&f); + let fonts = |text: &str| db.composite_fonts_for("parts", text).unwrap(); + let pair = |family: &str| (family.to_string(), "Regular".to_string()); + assert_eq!(fonts("A1"), [pair("JetBrains Mono"), pair("Inter")]); + assert_eq!(fonts(""), [pair("Source Sans 3")]); + assert!(db.composite_fonts_for("Inter", "A").is_none()); + let exact = db.composite_coverage("Parts", &["A1"]).unwrap(); + assert_eq!((exact.status, exact.lacking.len()), (FontMatch::Exact, 0)); + let missing = db.composite_coverage("Parts", &["A\u{304B}"]).unwrap(); + assert_eq!(missing.status, FontMatch::Missing); + assert_eq!(missing.face.map(|f| f.family.clone()).as_deref(), Some(FALLBACK_FAMILY)); + // Each run is split on its own: a combining mark that starts a run is a Roman character. + f.parts.kana.font = "Source Sans 3".into(); + f.parts.roman.font = "No Such Roman Font".into(); + db.set_composite_font(&f); + assert_eq!(db.composite_coverage("Parts", &["\u{304B}\u{301}"]).unwrap().status, FontMatch::Exact); + assert_eq!(db.composite_coverage("Parts", &["\u{304B}", "\u{301}"]).unwrap().status, FontMatch::Missing); + assert_eq!(db.stored_family("parts", "Regular").as_deref(), Some("Parts")); + assert_eq!(db.stored_family("No Such Kana Font", "Regular"), None); +} + +/// A composite font resolves to its base font as an exact match, lists first in the font menus, +/// and is gone from them once removed. +#[test] +fn composite_fonts_resolve_to_their_base_and_list_first() { + let db = FontDb::with_font_dirs(vec![]); + let generation = db.generation(); + db.set_composite_font(&mixed("Zeta Mixed")); + assert_ne!(db.generation(), generation); + let (face, how) = db.resolve("zeta mixed", "Regular").unwrap(); + assert_eq!((face.family.as_str(), how), ("Source Sans 3", FontMatch::Exact)); + assert!(db.has_family("Zeta Mixed")); + assert_eq!(db.menu_family_list().first().map(String::as_str), Some("Zeta Mixed")); + // Registering the same definition again changes nothing. + let generation = db.generation(); + db.set_composite_font(&mixed("Zeta Mixed")); + assert_eq!(db.generation(), generation); + // A base naming a composite font falls back rather than looping. + let mut odd = mixed("Odd"); + odd.parts.kanji.font = "Zeta Mixed".into(); + db.set_composite_font(&odd); + assert_eq!(db.face("Odd", "Regular").unwrap().family, FALLBACK_FAMILY); + assert!(db.remove_composite_font("ZETA MIXED")); + assert!(!db.remove_composite_font("Zeta Mixed")); + assert!(!db.menu_family_list().iter().any(|f| f == "Zeta Mixed")); + assert!(!db.has_family("Zeta Mixed")); +} diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index 04b5db626..45b559f77 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -72,7 +72,7 @@ pub fn inflate_meshes( options: Inflate, view: View, ) -> impl Iterator> + use<> { - inflate_faces(shapes, bounds, options, view, true).into_iter().map(|r| r.and_then(|(_, faces)| mesh(&faces))) + inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces)| mesh(&faces))) } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 4ee38fe1d..1247926de 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -262,6 +262,23 @@ fn inflate_puffs_the_front_toward_the_viewer() { assert!(hi[2] > 0.0 && lo[2] < hi[2], "the front puffs toward the viewer: {lo:?} {hi:?}"); } +#[test] +fn inflate_builds_each_solid_as_it_is_reached() { + let (mut a, mut b) = (BezPath::new(), BezPath::new()); + square(&mut a, 0.0, 0.0, 100.0, false); + square(&mut b, 120.0, 0.0, 100.0, false); + let ((pa, _), (pb, _)) = (shape(&a), shape(&b)); + let built = || crate::solid::INFLATED.with(|n| n.get()); + let start = built(); + let mut meshes = + inflate_meshes(&[(&pa, FillRule::NonZero), (&pb, FillRule::NonZero)], Rect::new(0.0, 0.0, 220.0, 100.0), Inflate::default(), front()); + assert_eq!(built(), start, "no solid is built before the first mesh is asked for"); + assert!(meshes.next().is_some_and(|m| m.is_ok_and(|m| !m.is_empty()))); + assert_eq!(built(), start + 1, "the second solid waits until it is reached"); + assert!(meshes.next().is_some_and(|m| m.is_ok_and(|m| !m.is_empty()))); + assert_eq!(built(), start + 2); +} + #[test] fn an_empty_shape_makes_an_empty_mesh() { let p = PathData::new(vec![]); diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 20beda4ec..e51e259c4 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -22,6 +22,8 @@ mod rings; use build::{Camera, cuts, edge_distance, newell, section}; pub(crate) use build::{Face3, extrude_faces, plane_faces}; pub use build::{extrude, plane}; +#[cfg(test)] +pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; pub use inflate::{inflate, inflate_together}; #[cfg(test)] diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 40d70a79d..984017593 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -51,29 +51,42 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, /// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest /// puff needs with Perspective. One result for each shape, in order. pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, false).into_iter().map(|r| r.map(|(facets, _)| facets)).collect() + inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, _)| facets)).collect() } /// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. -pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, mesh: bool) -> Vec> { +/// Every shape's membrane is solved at once (the eye depends on the highest puff); each solid is +/// built as the iterator reaches it. +pub(crate) fn inflate_faces( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, + mesh: bool, +) -> impl Iterator> + use<> { let o = options.sanitized(); let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); - puffs - .into_iter() - .map(|p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), - Ok(None) => Ok((vec![], vec![])), - Err(e) => Err(e), - }) - .collect() + puffs.into_iter().map(move |p| match p { + Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), + Ok(None) => Ok((vec![], vec![])), + Err(e) => Err(e), + }) +} + +#[cfg(test)] +thread_local! { + /// How many inflated solids this thread has built, for the tests. + pub(crate) static INFLATED: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the /// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. With `mesh`, /// its faces before projection instead. fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: bool) -> Result { + #[cfg(test)] + INFLATED.with(|n| n.set(n.get() + 1)); let Puff { rings, edges, cell, nx, ny, m, height } = p; let outline: Vec<(Rect, vectorcraft_geom::SubPath)> = rings .iter() diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 985f98c34..de227b748 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -250,7 +250,7 @@ impl VectorcraftApp { let to = ¶ms["to"]; to["family"].as_str().and_then(|family| { let style = to["style"].as_str().unwrap_or("Regular"); - vectorcraft_text::FontDb::global().resolve(family, style).map(|(face, _)| face.family.clone()) + vectorcraft_text::FontDb::global().stored_family(family, style) }) } _ => None, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 002aac537..612fd9fef 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -22,6 +22,7 @@ pub mod color_balance; pub mod color_guide_options; mod color_picker; mod command; +pub mod composite_fonts; pub mod confirm; pub mod corners; pub mod dimension; @@ -348,6 +349,7 @@ registry! { Hyphenation: [hyphenation::KIND] => hyphenation::SPEC, Justification: [justification::KIND] => justification::SPEC, TateChuYokoSettings: [tate_chu_yoko::KIND] => tate_chu_yoko::SPEC, + CompositeFonts: [composite_fonts::KIND] => composite_fonts::SPEC, SavePdf: [save_pdf::KIND] => save_pdf::SPEC, SvgOptions: [svg_options::KIND] => svg_options::SPEC, JavaScriptFiles: [javascript_files::KIND] => javascript_files::SPEC, diff --git a/crates/ui-egui/src/dialogs/composite_fonts.rs b/crates/ui-egui/src/dialogs/composite_fonts.rs new file mode 100644 index 000000000..cc71cc32d --- /dev/null +++ b/crates/ui-egui/src/dialogs/composite_fonts.rs @@ -0,0 +1,275 @@ +//! Type › Composite Fonts… (with Show East Asian Options): the active document's composite fonts, +//! the one picked in Font shown as a table with a row for each class of character (Kanji, Kana, +//! Punctuation, Symbols, Roman, Numerals) and its font, style, size, baseline and scales. New adds +//! a font based on the one picked, Delete Font takes it out, Import… adds the fonts of a file and +//! Export… writes the one picked to a file. Nothing changes in the document until OK, which runs +//! `type.compositeFonts.set` with every font as one undo step; Cancel leaves them as they were. +//! +//! Fields: `fonts` (the composite fonts as `type.compositeFonts.set` takes them), `selected` (the +//! index of the one picked) and `name` (its name as typed). +//! +//! Behavior reference (public documentation): +//! . + +use serde_json::{Value, json}; +use vectorcraft_doc::text::{COMPOSITE_BASELINE, COMPOSITE_SCALE, COMPOSITE_SIZE}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart}; +use vectorcraft_engine::cmd::compositefonts::EXTS; + +use super::{DialogSpec, form}; +use crate::picks::PickRequest; +use crate::state::Dialog; +use crate::{FilePick, VectorcraftApp, widgets}; + +/// The dialog kind of Composite Fonts. +pub const KIND: &str = "compositeFonts"; + +/// The command OK runs, and the menu item's. +pub const COMMAND: &str = "type.compositeFonts.set"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Composite Fonts").into(), body, confirm, min_width: 760.0, ..DialogSpec::FORM }; + +/// Open Composite Fonts on the active document's composite fonts, the first one picked. +pub fn open(app: &mut VectorcraftApp) -> Result { + let fonts = app.session.active().ok_or("no document open")?.doc.composite_fonts.to_vec(); + let mut d = Dialog::new(KIND, json!({ "fonts": fonts, "selected": 0 })); + show_selected(&mut d); + app.ui.dialog = Some(d); + Ok(Value::Null) +} + +/// The fonts the dialog holds. +fn fonts(d: &Dialog) -> Vec { + d.fields.get("fonts").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() +} + +fn put_fonts(d: &mut Dialog, fonts: &[CompositeFont]) { + d.fields.insert("fonts".into(), json!(fonts)); +} + +/// The index of the font picked, within the fonts (0 when there are none). +fn selected(d: &Dialog, count: usize) -> usize { + d.fields.get("selected").and_then(Value::as_u64).map_or(0, |i| i as usize).min(count.saturating_sub(1)) +} + +/// Pick font `i`: its name goes into Name. +fn select(d: &mut Dialog, i: usize) { + d.fields.insert("selected".into(), json!(i)); + show_selected(d); +} + +fn show_selected(d: &mut Dialog) { + let fonts = fonts(d); + let name = fonts.get(selected(d, fonts.len())).map(|f| f.name.clone()).unwrap_or_default(); + d.fields.insert("name".into(), json!(name)); +} + +/// The name of a class's row. +fn class_label(c: CharClass) -> &'static str { + match c { + CharClass::Kanji => tl!("Kanji"), + CharClass::Kana => tl!("Kana"), + CharClass::Punctuation => tl!("Punctuation"), + CharClass::Symbols => tl!("Symbols"), + CharClass::Roman => tl!("Roman"), + CharClass::Numerals => tl!("Numerals"), + } +} + +/// "Composite Font N", the first N no font in `fonts` and no installed font is named. +fn new_name(fonts: &[CompositeFont]) -> String { + let db = vectorcraft_text::FontDb::global(); + (1..) + .map(|n| format!("Composite Font {n}")) + .find(|n| !fonts.iter().any(|f| CompositeFont::same_name(&f.name, n)) && (db.composite_font(n).is_some() || !db.has_family(n))) + .unwrap_or_default() +} + +/// What a button asked for, done once the table is drawn. +enum Action { + New, + Delete, + Import, + Export, +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let mut list = fonts(d); + let i = selected(d, list.len()); + let mut act = None; + let names: Vec<&str> = list.iter().map(|f| f.name.as_str()).collect(); + let pick = FilePick { filters: vec![FilePick::filter("Composite fonts", EXTS)], ..Default::default() }; + let can_import = crate::picks::can(app, &PickRequest::Open(pick)); + let mut picked = None; + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Font:")); + if names.is_empty() { + widgets::dim_label(ui, tl!("None")); + } else if let Some(k) = widgets::dropdown_names(ui, "composite-fonts-font", names.get(i).copied().unwrap_or_default(), &names, 220.0) { + picked = Some(k); + } + ui.add_space(8.0); + ui.spacing_mut().item_spacing.x = 4.0; + if widgets::flat_button(ui, tl!("New"), 48.0).clicked() { + act = Some(Action::New); + } + if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Delete Font"), 84.0)).inner.clicked() { + act = Some(Action::Delete); + } + if can_import && widgets::flat_button(ui, tl!("Import…"), 64.0).clicked() { + act = Some(Action::Import); + } + if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Export…"), 64.0)).inner.clicked() { + act = Some(Action::Export); + } + }); + if let Some(k) = picked { + select(d, k); + return false; + } + ui.add_space(8.0); + if let Some(f) = list.get_mut(i) { + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Name:")); + form::text(ui, d, "name", 260.0); + }); + f.name = d.str("name"); + ui.add_space(8.0); + parts_table(app, ui, f); + } + put_fonts(d, &list); + if let Some(a) = act { + // Import…'s file dialog, shown off the UI thread, imports into the dialog as it is then. + let r = match a { + Action::Import => crate::picks::in_dialog(app, d, |app, d| run(app, d, Action::Import)), + a => run(app, d, a), + }; + if let Err(e) = r { + app.status(e); + } + } + false +} + +/// Do what button `act` asks for on the dialog's fonts. +fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action) -> Result<(), String> { + let mut list = fonts(d); + let i = selected(d, list.len()); + match act { + Action::New => { + let mut f = list.get(i).cloned().unwrap_or_else(|| { + let st = vectorcraft_doc::CharStyle::default(); + CompositeFont::new("", st.font_family, st.font_style) + }); + f.name = new_name(&list); + list.push(f); + put_fonts(d, &list); + select(d, list.len() - 1); + } + Action::Delete => { + if i < list.len() { + list.remove(i); + } + put_fonts(d, &list); + select(d, i.min(list.len().saturating_sub(1))); + } + Action::Import => import(app, d)?, + Action::Export => { + let f = list.get(i).ok_or("no composite font")?; + crate::io::save_command_output(app, "type.compositeFonts.export", EXTS[0], json!({ "fonts": [f] }))?; + } + } + Ok(()) +} + +/// The table of `f`'s parts: a row for each class with its font, style, size, baseline, scales and +/// (Kana) Scale at Center; the values a class can't take are not shown. +fn parts_table(app: &VectorcraftApp, ui: &mut egui::Ui, f: &mut CompositeFont) { + let db = vectorcraft_text::FontDb::global(); + egui::Grid::new("composite-fonts-parts").num_columns(8).spacing([8.0, 6.0]).show(ui, |ui| { + for h in ["", tl!("Font"), tl!("Style"), tl!("Size"), tl!("Baseline"), tl!("Horizontal Scale"), tl!("Vertical Scale"), ""] { + widgets::dim_label(ui, h); + } + ui.end_row(); + for class in CharClass::ALL { + let p: &mut CompositePart = f.parts.get_mut(class); + ui.label(class_label(class)); + let id = ("composite-font", class.key()); + if let Some(crate::font_menu::FontPick::Chosen(family, style)) = + crate::font_menu::font_menu(ui, id, &p.font, 190.0, None, crate::font_menu::MenuLook::of(app)) + && db.composite_font(&family).is_none() + { + p.style = style.unwrap_or_else(|| db.styles(&family).first().cloned().unwrap_or_else(|| "Regular".into())); + p.font = family; + } + let styles = db.styles(&p.font); + let style_names: Vec<&str> = styles.iter().map(String::as_str).collect(); + if let Some(k) = widgets::dropdown_names(ui, ("composite-font-style", class.key()), &p.style, &style_names, 120.0) + && let Some(s) = styles.get(k) + { + p.style.clone_from(s); + } + let percent = |ui: &mut egui::Ui, key: &str, v: &mut f64, range: &std::ops::RangeInclusive| { + if let Some(n) = widgets::range_field(ui, ("composite-font", class.key(), key), *v, range.clone(), "%", 1, 64.0) { + *v = n; + } + }; + percent(ui, "size", &mut p.size, &COMPOSITE_SIZE); + percent(ui, "baseline", &mut p.baseline, &COMPOSITE_BASELINE); + if class.scales() { + percent(ui, "hScale", &mut p.h_scale, &COMPOSITE_SCALE); + percent(ui, "vScale", &mut p.v_scale, &COMPOSITE_SCALE); + } else { + ui.label(""); + ui.label(""); + } + if class == CharClass::Kana { + ui.checkbox(&mut p.scale_at_center, tl!("Scale at Center")); + } else { + ui.label(""); + } + ui.end_row(); + } + }); +} + +/// Import…: the fonts of a picked file join those in the dialog, a font of the same name replaced, +/// and the first of them is picked. +fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { + let pick = FilePick { filters: vec![FilePick::filter("Composite fonts", EXTS)], ..Default::default() }; + let path = crate::picks::open(app, &pick).ok_or("cancelled")?; + let r = app.run("type.compositeFonts.import", json!({ "path": path, "apply": false }))?; + let read: Vec = serde_json::from_value(r["fonts"].clone()).map_err(|e| e.to_string())?; + let mut list = fonts(d); + let mut first = None; + for f in read { + let at = match list.iter_mut().enumerate().find(|(_, g)| CompositeFont::same_name(&g.name, &f.name)) { + Some((k, g)) => { + *g = f; + k + } + None => { + list.push(f); + list.len() - 1 + } + }; + first.get_or_insert(at); + } + put_fonts(d, &list); + if let Some(k) = first { + select(d, k); + } + Ok(()) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let r = app.run(COMMAND, json!({ "fonts": fonts(d) })); + // A name two fonts share, or a value out of range, keeps the dialog open to be changed. + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/composite_fonts/tests.rs b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs new file mode 100644 index 000000000..64a031e04 --- /dev/null +++ b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs @@ -0,0 +1,174 @@ +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use super::*; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app +} + +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn dialog(app: &mut VectorcraftApp) -> &mut Dialog { + app.ui.dialog.as_mut().expect("Composite Fonts open") +} + +fn names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.composite_fonts.iter().map(|f| f.name.clone()).collect() +} + +fn steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// Do `act` with the dialog as it is, as its button does. +fn press(app: &mut VectorcraftApp, act: Action) { + let mut d = app.ui.dialog.clone().unwrap(); + run(app, &mut d, act).unwrap(); + app.ui.dialog = Some(d); +} + +/// Type › Composite Fonts… opens on the document's fonts; New adds one, its parts edited in the +/// table; nothing changes until OK, which sets them as one undo step; Cancel leaves them. +#[test] +fn composite_fonts_change_on_ok_as_one_undo_step() { + let mut app = app(); + crate::menus::invoke(&mut app, COMMAND, json!({})); + assert_eq!(dialog(&mut app).kind, KIND); + frame(&mut app); + press(&mut app, Action::New); + frame(&mut app); + let d = dialog(&mut app); + assert_eq!(d.str("name"), "Composite Font 1"); + let mut list = fonts(d); + list[0].parts.roman.font = "JetBrains Mono".into(); + list[0].parts.roman.size = 80.0; + list[0].parts.kana.scale_at_center = true; + put_fonts(d, &list); + d.fields.insert("name".into(), json!("UI Composite A")); + frame(&mut app); + assert!(names(&app).is_empty()); + let before = steps(&app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(names(&app), ["UI Composite A"]); + assert_eq!(steps(&app), before + 1); + let f = &app.session.active().unwrap().doc.composite_fonts[0]; + assert_eq!((f.parts.roman.font.as_str(), f.parts.roman.size, f.parts.kana.scale_at_center), ("JetBrains Mono", 80.0, true)); + // OK with nothing changed adds no step; Cancel leaves the fonts. + open(&mut app).unwrap(); + super::super::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), before + 1); + open(&mut app).unwrap(); + press(&mut app, Action::Delete); + super::super::cancel(&mut app); + assert_eq!(names(&app), ["UI Composite A"]); + // Delete Font and OK takes it out. + open(&mut app).unwrap(); + press(&mut app, Action::Delete); + assert_eq!(dialog(&mut app).str("name"), ""); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(names(&app).is_empty()); +} + +/// New is based on the font picked and takes the next free name; a name two fonts share, or one +/// left empty, keeps the dialog open and changes nothing. +#[test] +fn shared_or_empty_names_keep_the_dialog_open() { + let mut app = app(); + open(&mut app).unwrap(); + press(&mut app, Action::New); + let d = dialog(&mut app); + let mut list = fonts(d); + list[0].parts.numerals.font = "Inter".into(); + put_fonts(d, &list); + press(&mut app, Action::New); + let list = fonts(dialog(&mut app)); + assert_eq!(list.iter().map(|f| f.name.as_str()).collect::>(), ["Composite Font 1", "Composite Font 2"]); + assert_eq!(list[1].parts.numerals.font, "Inter"); + for bad in ["composite font 1", " "] { + dialog(&mut app).fields.insert("name".into(), json!(bad)); + frame(&mut app); + assert!(super::super::confirm(&mut app).is_err(), "{bad:?}"); + assert!(app.ui.dialog.is_some()); + assert!(names(&app).is_empty()); + } + dialog(&mut app).fields.insert("name".into(), json!("UI Composite B")); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(names(&app), ["Composite Font 1", "UI Composite B"]); +} + +/// The menu item shows only with Show East Asian Options on, and needs a document. +#[test] +fn the_menu_item_shows_with_east_asian_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.show_east_asian_options = true; + assert_eq!(crate::menus::shown_state(&app, COMMAND, &json!({})).map(|s| s.0), Some(false)); + app.run("file.new", json!({})).unwrap(); + assert_eq!(crate::menus::shown_state(&app, COMMAND, &json!({})).map(|s| s.0), Some(true)); + app.session.prefs.show_east_asian_options = false; + assert_eq!(crate::menus::shown_state(&app, COMMAND, &json!({})), None); +} + +/// Export… writes the font picked to a file; Import… adds the fonts of a file to the dialog's, +/// replacing one of the same name, and opening the file adds them to the document. +#[test] +fn composite_fonts_go_through_files() { + let dir = std::env::temp_dir().join(format!("vc-composite-fonts-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("fonts.vccomposite").to_string_lossy().to_string(); + let (saved, picked) = (file.clone(), file.clone()); + let services = crate::Services { + pick_save: Some(Box::new(move |_: &FilePick| Some(saved.clone()))), + pick_open: Some(Box::new(move |_: &FilePick| Some(picked.clone()))), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({})).unwrap(); + open(&mut app).unwrap(); + press(&mut app, Action::New); + let d = dialog(&mut app); + let mut list = fonts(d); + list[0].name = "UI Composite IO".into(); + list[0].parts.roman.baseline = -12.0; + put_fonts(d, &list); + press(&mut app, Action::Export); + assert!(std::fs::read_to_string(&file).unwrap().contains("UI Composite IO")); + // Import into the dialog: the same name is replaced, not added. + let d = dialog(&mut app); + let mut list = fonts(d); + list[0].parts.roman.baseline = 0.0; + put_fonts(d, &list); + press(&mut app, Action::Import); + let list = fonts(dialog(&mut app)); + assert_eq!((list.len(), list[0].parts.roman.baseline), (1, -12.0)); + super::super::cancel(&mut app); + assert!(names(&app).is_empty()); + // Opening the file adds its fonts to the document. + let bytes = std::fs::read(&file).unwrap(); + crate::io::open_bytes(&mut app, &file, &bytes, Some(file.clone())).unwrap(); + assert_eq!(names(&app), ["UI Composite IO"]); + let _ = std::fs::remove_dir_all(dir); +} + +/// The dialog lists the fonts and draws its table without a document font picked too. +#[test] +fn the_dialog_draws_with_and_without_fonts() { + let mut app = app(); + open(&mut app).unwrap(); + frame(&mut app); + assert_eq!(dialog(&mut app).fields.get("fonts"), Some(&Value::Array(vec![]))); + press(&mut app, Action::New); + frame(&mut app); + assert_eq!(fonts(dialog(&mut app)).len(), 1); +} diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index 00ae1abe7..051669523 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -19,7 +19,7 @@ //! at first when a code is not known), and `__query` and `__attention` (the filter of the rows shown). use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::sync::OnceLock; +use std::sync::{Arc, Mutex}; use craft_keymap::import::namematch::{Candidate, Matcher, Proposal}; use craft_keymap::import::{MAX_IMPORT_TEXT, MAX_PATH_SEGMENTS}; @@ -363,10 +363,12 @@ fn single_key(chord: &str) -> bool { } /// What a name can stand for: Keyboard Shortcuts' entries, by their English labels and the labels in -/// the interface language. +/// the interface language. A filter plug-in's row is left out: a row of the file reaches it only +/// when the user picks it as the target. pub fn candidates() -> Vec { shortcut_editor::entries() .iter() + .filter(|e| !e.key.starts_with("plugin:")) .map(|e| { let shown = tl!(&e.label); let labels = if shown == e.label { vec![e.label.clone()] } else { vec![e.label.clone(), shown.to_string()] }; @@ -375,10 +377,17 @@ pub fn candidates() -> Vec { .collect() } -/// Keyboard Shortcuts' entry `key`. -fn entry(key: &str) -> Option<&'static Entry> { - static BY_KEY: OnceLock> = OnceLock::new(); - BY_KEY.get_or_init(|| shortcut_editor::entries().iter().map(|e| (e.key.as_str(), e)).collect()).get(key).copied() +/// Keyboard Shortcuts' entry `key`, among the entries for the plug-ins installed now. +fn entry(key: &str) -> Option { + type Index = (Arc<[Entry]>, HashMap); + static BY_KEY: Mutex> = Mutex::new(None); + let entries = shortcut_editor::entries(); + let mut by = BY_KEY.lock().unwrap_or_else(|e| e.into_inner()); + if by.as_ref().is_none_or(|(e, _)| !Arc::ptr_eq(e, &entries)) { + let index = entries.iter().enumerate().map(|(i, e)| (e.key.clone(), i)).collect(); + *by = Some((entries, index)); + } + by.as_ref().and_then(|(e, index)| index.get(key).and_then(|&i| e.get(i)).cloned()) } /// A tool, command or panel that Keyboard Shortcuts can give a shortcut. @@ -388,7 +397,7 @@ fn known(key: &str) -> bool { /// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu. fn shown(e: &Entry) -> String { - if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) } + shortcut_editor::shown_name(e) } /// What a row's target shows. @@ -396,7 +405,7 @@ fn target_label(target: Option<&str>) -> String { match target { None => tl!("Choose…").to_string(), Some("") => tl!("No equivalent").to_string(), - Some(t) => entry(t).map_or_else(|| t.to_string(), shown), + Some(t) => entry(t).map_or_else(|| t.to_string(), |e| shown(&e)), } } @@ -934,6 +943,7 @@ pub(super) fn target_picker(ui: &mut egui::Ui, i: usize, r: &Row) -> Option Option = if q.is_empty() { for e in r.choices.iter().filter_map(|c| entry(c)) { - picked = option(ui, &e.key, &shown(e)).or(picked); + picked = option(ui, &e.key, &shown(&e)).or(picked); } ui.separator(); - shortcut_editor::entries().iter().collect() + all.iter().collect() } else { - shortcut_editor::entries().iter().filter(|e| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)).collect() + all.iter().filter(|e| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)).collect() }; ui.data_mut(|d| d.insert_temp(header_id, ui.min_rect().height())); (list, picked) diff --git a/crates/ui-egui/src/dialogs/tests_shortcut_import.rs b/crates/ui-egui/src/dialogs/tests_shortcut_import.rs index 77842ec8d..066f7f468 100644 --- a/crates/ui-egui/src/dialogs/tests_shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/tests_shortcut_import.rs @@ -596,6 +596,30 @@ fn long_names_match_and_apply() { assert_eq!((out["applied"].clone(), ov), (json!(1), json!({"edit.copy": "Cmd+Alt+Shift+F11"}))); } +/// A filter plug-in's row takes an imported shortcut when the user picks it, stored under its +/// `plugin.dialog {id}` key. A file's row with the plug-in's name does not match it by itself, and +/// a removed plug-in is not a target. +#[test] +fn an_imported_shortcut_reaches_a_filter_plugin_picked_as_its_target() { + use vectorcraft_plugins::wat as tpl; + let id = "test.UI.import_filter-one"; + let key = format!("plugin:{id}"); + let manifest = format!(r#"{{"id":"{id}","name":"Acme Import Filter","version":"1","kind":"filter","params":{{}}}}"#); + vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap(); + assert!(shortcut_editor::entry(&key).is_some(), "a target the picker lists"); + assert!(super::shortcut_import::candidates().iter().all(|c| c.id != key), "a name alone does not match it"); + + let mut app = app(); + let (out, ov) = apply_rows(&mut app, "defaults", &[("Anything", "Menus", &key, "Cmd+Alt+Shift+F11")]); + assert_eq!(out["applied"], 1); + assert_eq!(ov, json!({ key.as_str(): "Cmd+Alt+Shift+F11" }), "not a binding of plugin.dialog without its id"); + let reloaded = crate::keymap::stored::from_value(ov); + assert_eq!(reloaded.get(&crate::keymap::stored_key(&key)).map(<[String]>::len), Some(1), "read back under the filter's id"); + + vectorcraft_plugins::registry::remove(id); + assert!(shortcut_editor::entries().iter().all(|e| e.key != key), "a removed filter is no target"); +} + /// A search in a row's target picker that matches nothing shrinks its menu to the search field /// and "No equivalent"; clearing the search lists every entry again, and the menu grows back to /// its height before the search (egui#5225). The search field stays where it was throughout. diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index d63905e48..0c17c6e92 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -1087,7 +1087,8 @@ mod tests { let system = replacement_rows(&app, "system", &used); assert!(system.iter().any(|r| r.family == "Source Sans 3" && r.style == "Regular"), "{system:?}"); assert!(!system.iter().any(|r| r.family.starts_with('.')), "the font menu's hidden families stay out"); - assert!(system.iter().all(|r| !r.style.is_empty()), "no nameless rows: {system:?}"); + // A composite font is a family without styles of its own, listed by its name alone. + assert!(system.iter().all(|r| !r.style.is_empty() || db.styles(&r.family).is_empty()), "no nameless rows: {system:?}"); let menu = db.menu_family_list(); let listed: std::collections::BTreeSet<&str> = system.iter().map(|r| r.family.as_str()).collect(); assert_eq!(listed.len(), menu.len(), "every family the font menu shows is listed"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 0c6ae4e1c..c5167b0b9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3375,6 +3375,15 @@ digits Malayalam Малаялам Tate-chu-yoko Тате-чу-йоко Tate-chu-yoko Settings… Настройки на Тате-чу-йоко… Tate-chu-yoko Settings Настройки на Тате-чу-йоко + Composite Fonts… Съставни шрифтове… + Composite Fonts Съставни шрифтове + Export Composite Fonts Експортиране на съставни шрифтове + Import Composite Fonts Импортиране на съставни шрифтове + Delete Font Изтриване на шрифт + Kanji Канджи + Kana Кана + Numerals Цифри + Scale at Center Мащабиране от центъра Up/Down: Нагоре/надолу: Left/Right: Наляво/надясно: Temperature Температура diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 01a263f6a..cba0c99dd 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1501,6 +1501,15 @@ digits Malayalam Malajalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Postavke za Tate-chu-yoko… Tate-chu-yoko Settings Postavke za Tate-chu-yoko + Composite Fonts… Kompozitni fontovi… + Composite Fonts Kompozitni fontovi + Export Composite Fonts Izvezi kompozitne fontove + Import Composite Fonts Uvezi kompozitne fontove + Delete Font Izbriši font + Kanji Kanji + Kana Kana + Numerals Brojevi + Scale at Center Skaliraj od centra Up/Down: Gore/dolje: Left/Right: Lijevo/desno: Underline Podvučeno diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f333dc0ab..3cf2625b8 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4318,6 +4318,15 @@ digits Malayalam Malaiàlam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Configuració de Tate-chu-yoko… Tate-chu-yoko Settings Configuració de Tate-chu-yoko + Composite Fonts… Tipus de lletra compostos… + Composite Fonts Tipus de lletra compostos + Export Composite Fonts Exporta tipus de lletra compostos + Import Composite Fonts Importa tipus de lletra compostos + Delete Font Suprimeix el tipus de lletra + Kanji Kanji + Kana Kana + Numerals Xifres + Scale at Center Escala des del centre Up/Down: Amunt/avall: Left/Right: Esquerra/dreta: Technical Drawing Dibuix tècnic diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d6c840c29..1ed699c65 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1525,6 +1525,15 @@ digits Malayalam Malajálamské Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Nastavení Tate-chu-yoko… Tate-chu-yoko Settings Nastavení Tate-chu-yoko + Composite Fonts… Složená písma… + Composite Fonts Složená písma + Export Composite Fonts Exportovat složená písma + Import Composite Fonts Importovat složená písma + Delete Font Odstranit písmo + Kanji Kandži + Kana Kana + Numerals Číslice + Scale at Center Měřítko od středu Up/Down: Nahoru/dolů: Left/Right: Vlevo/vpravo: Underline Podtržení diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 148f26266..094e3a30c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1435,6 +1435,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Indstillinger for Tate-chu-yoko… Tate-chu-yoko Settings Indstillinger for Tate-chu-yoko + Composite Fonts… Sammensatte skrifttyper… + Composite Fonts Sammensatte skrifttyper + Export Composite Fonts Eksportér sammensatte skrifttyper + Import Composite Fonts Importér sammensatte skrifttyper + Delete Font Slet skrifttype + Kanji Kanji + Kana Kana + Numerals Tal + Scale at Center Skalér fra midten Up/Down: Op/ned: Left/Right: Venstre/højre: Underline Understreget diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 539b9a0e5..89aa5eef5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3407,6 +3407,15 @@ effect Points Punkte Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko-Einstellungen… Tate-chu-yoko Settings Tate-chu-yoko-Einstellungen + Composite Fonts… Zusammengesetzte Schriften… + Composite Fonts Zusammengesetzte Schriften + Export Composite Fonts Zusammengesetzte Schriften exportieren + Import Composite Fonts Zusammengesetzte Schriften importieren + Delete Font Schrift löschen + Kanji Kanji + Kana Kana + Numerals Ziffern + Scale at Center Von der Mitte aus skalieren Up/Down: Oben/Unten: Left/Right: Links/Rechts: Temperature Temperatur diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f91344f15..91dfd9c70 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3809,6 +3809,15 @@ digits Malayalam Μαλαγιάλαμ Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Ρυθμίσεις Tate-chu-yoko… Tate-chu-yoko Settings Ρυθμίσεις Tate-chu-yoko + Composite Fonts… Σύνθετες γραμματοσειρές… + Composite Fonts Σύνθετες γραμματοσειρές + Export Composite Fonts Εξαγωγή σύνθετων γραμματοσειρών + Import Composite Fonts Εισαγωγή σύνθετων γραμματοσειρών + Delete Font Διαγραφή γραμματοσειράς + Kanji Κάντζι + Kana Κάνα + Numerals Αριθμοί + Scale at Center Κλιμάκωση από το κέντρο Up/Down: Πάνω/Κάτω: Left/Right: Αριστερά/Δεξιά: Temperature/Tint… Θερμοκρασία/Απόχρωση… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1190a8cf4..5e399d774 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3463,6 +3463,15 @@ digits Malayalam Malayálam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Ajustes de Tate-chu-yoko… Tate-chu-yoko Settings Ajustes de Tate-chu-yoko + Composite Fonts… Fuentes compuestas… + Composite Fonts Fuentes compuestas + Export Composite Fonts Exportar fuentes compuestas + Import Composite Fonts Importar fuentes compuestas + Delete Font Eliminar fuente + Kanji Kanji + Kana Kana + Numerals Números + Scale at Center Escalar desde el centro Up/Down: Arriba/abajo: Left/Right: Izquierda/derecha: Temperature Temperatura diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 77efc8e94..3f71e87ae 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1498,6 +1498,15 @@ digits Malayalam Malajalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko-asetukset… Tate-chu-yoko Settings Tate-chu-yoko-asetukset + Composite Fonts… Yhdistelmäfontit… + Composite Fonts Yhdistelmäfontit + Export Composite Fonts Vie yhdistelmäfontit + Import Composite Fonts Tuo yhdistelmäfontit + Delete Font Poista fontti + Kanji Kanji + Kana Kana + Numerals Numerot + Scale at Center Skaalaa keskeltä Up/Down: Ylös/alas: Left/Right: Vasen/oikea: Underline Alleviivaus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index faec51ca8..08db55750 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3455,6 +3455,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Paramètres Tate-chu-yoko… Tate-chu-yoko Settings Paramètres Tate-chu-yoko + Composite Fonts… Polices composites… + Composite Fonts Polices composites + Export Composite Fonts Exporter des polices composites + Import Composite Fonts Importer des polices composites + Delete Font Supprimer la police + Kanji Kanji + Kana Kana + Numerals Chiffres + Scale at Center Mettre à l’échelle depuis le centre Up/Down: Haut/bas : Left/Right: Gauche/droite : Temperature Température diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index c3a4295fd..05760152b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4694,6 +4694,15 @@ Tate-chu-yoko ताते-चू-योको Tate-chu-yoko Settings ताते-चू-योको सेटिंग्स Tate-chu-yoko Settings… ताते-चू-योको सेटिंग्स… + Composite Fonts… कंपोज़िट फ़ॉन्ट… + Composite Fonts कंपोज़िट फ़ॉन्ट + Export Composite Fonts कंपोज़िट फ़ॉन्ट एक्सपोर्ट करें + Import Composite Fonts कंपोज़िट फ़ॉन्ट इम्पोर्ट करें + Delete Font फ़ॉन्ट हटाएँ + Kanji कांजी + Kana काना + Numerals अंक + Scale at Center केंद्र से आकार बदलें Technical Drawing तकनीकी ड्राइंग Telugu तेलुगु Temperature तापमान diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 55c65acb0..2814e77e3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4116,6 +4116,15 @@ mark-style Japanese Japán Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko beállításai… Tate-chu-yoko Settings Tate-chu-yoko beállításai + Composite Fonts… Összetett betűtípusok… + Composite Fonts Összetett betűtípusok + Export Composite Fonts Összetett betűtípusok exportálása + Import Composite Fonts Összetett betűtípusok importálása + Delete Font Betűtípus törlése + Kanji Kandzsi + Kana Kana + Numerals Számjegyek + Scale at Center Méretezés középről Up/Down: Fel/le: Left/Right: Balra/jobbra: Technical Drawing Műszaki rajz diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5f3cd5930..581e66a15 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1435,6 +1435,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Pengaturan Tate-chu-yoko… Tate-chu-yoko Settings Pengaturan Tate-chu-yoko + Composite Fonts… Font komposit… + Composite Fonts Font komposit + Export Composite Fonts Ekspor font komposit + Import Composite Fonts Impor font komposit + Delete Font Hapus font + Kanji Kanji + Kana Kana + Numerals Angka + Scale at Center Skalakan dari tengah Up/Down: Atas/bawah: Left/Right: Kiri/kanan: Underline Garis bawah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e03516a8d..00f21ad32 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3499,6 +3499,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Impostazioni Tate-chu-yoko… Tate-chu-yoko Settings Impostazioni Tate-chu-yoko + Composite Fonts… Font compositi… + Composite Fonts Font compositi + Export Composite Fonts Esporta font compositi + Import Composite Fonts Importa font compositi + Delete Font Elimina font + Kanji Kanji + Kana Kana + Numerals Numeri + Scale at Center Ridimensiona dal centro Up/Down: Su/giù: Left/Right: Sinistra/destra: Temperature Temperatura diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e94efc4b2..7829ba95c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3823,6 +3823,15 @@ file-filter Templates テンプレート Tate-chu-yoko 縦中横 Tate-chu-yoko Settings… 縦中横の設定… Tate-chu-yoko Settings 縦中横の設定 + Composite Fonts… 合成フォント… + Composite Fonts 合成フォント + Export Composite Fonts 合成フォントを書き出し + Import Composite Fonts 合成フォントを読み込み + Delete Font フォントを削除 + Kanji 漢字 + Kana かな + Numerals 数字 + Scale at Center 中心から拡大・縮小 Up/Down: 上下: Left/Right: 左右: Temperature 色温度 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 76bff4127..8fdc1b6ee 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1360,6 +1360,15 @@ digits Malayalam მალაიალამური Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko პარამეტრები… Tate-chu-yoko Settings Tate-chu-yoko პარამეტრები + Composite Fonts… კომპოზიტური შრიფტები… + Composite Fonts კომპოზიტური შრიფტები + Export Composite Fonts კომპოზიტური შრიფტების ექსპორტი + Import Composite Fonts კომპოზიტური შრიფტების იმპორტი + Delete Font შრიფტის წაშლა + Kanji კანძი + Kana კანა + Numerals ციფრები + Scale at Center ცენტრიდან მასშტაბირება Up/Down: ზემოთ/ქვემოთ: Left/Right: მარცხნივ/მარჯვნივ: Underline ხაზგასმული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f9ab1763c..07114a987 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3811,6 +3811,15 @@ file-filter Templates 템플릿 Tate-chu-yoko 다테추요코 Tate-chu-yoko Settings… 다테추요코 설정… Tate-chu-yoko Settings 다테추요코 설정 + Composite Fonts… 합성 글꼴… + Composite Fonts 합성 글꼴 + Export Composite Fonts 합성 글꼴 내보내기 + Import Composite Fonts 합성 글꼴 가져오기 + Delete Font 글꼴 삭제 + Kanji 한자 + Kana 가나 + Numerals 숫자 + Scale at Center 중심에서 크기 조정 Up/Down: 위/아래: Left/Right: 왼쪽/오른쪽: Temperature 색온도 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 15aa4078e..5882addb5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1449,6 +1449,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko-instellingen… Tate-chu-yoko Settings Tate-chu-yoko-instellingen + Composite Fonts… Samengestelde lettertypen… + Composite Fonts Samengestelde lettertypen + Export Composite Fonts Samengestelde lettertypen exporteren + Import Composite Fonts Samengestelde lettertypen importeren + Delete Font Lettertype verwijderen + Kanji Kanji + Kana Kana + Numerals Cijfers + Scale at Center Schalen vanuit het midden Up/Down: Omhoog/omlaag: Left/Right: Links/rechts: Underline Onderstrepen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3764c9657..c78b85da7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1437,6 +1437,15 @@ digits Malayalam Malajalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Ustawienia Tate-chu-yoko… Tate-chu-yoko Settings Ustawienia Tate-chu-yoko + Composite Fonts… Fonty złożone… + Composite Fonts Fonty złożone + Export Composite Fonts Eksportuj fonty złożone + Import Composite Fonts Importuj fonty złożone + Delete Font Usuń font + Kanji Kanji + Kana Kana + Numerals Cyfry + Scale at Center Skaluj od środka Up/Down: W górę/w dół: Left/Right: W lewo/w prawo: Underline Podkreślenie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index db09fa622..f018008d8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4914,6 +4914,15 @@ axis Both Ambos Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Configurações de Tate-chu-yoko… Tate-chu-yoko Settings Configurações de Tate-chu-yoko + Composite Fonts… Fontes compostas… + Composite Fonts Fontes compostas + Export Composite Fonts Exportar fontes compostas + Import Composite Fonts Importar fontes compostas + Delete Font Excluir fonte + Kanji Kanji + Kana Kana + Numerals Números + Scale at Center Dimensionar a partir do centro Up/Down: Para Cima/Para Baixo: Left/Right: Esquerda/Direita: Temperature Temperatura diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 43d042781..7d609304a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3494,6 +3494,15 @@ digits Malayalam Малаялам Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Параметры Tate-chu-yoko… Tate-chu-yoko Settings Параметры Tate-chu-yoko + Composite Fonts… Составные шрифты… + Composite Fonts Составные шрифты + Export Composite Fonts Экспорт составных шрифтов + Import Composite Fonts Импорт составных шрифтов + Delete Font Удалить шрифт + Kanji Кандзи + Kana Кана + Numerals Цифры + Scale at Center Масштабировать от центра Up/Down: Вверх/вниз: Left/Right: Влево/вправо: Temperature Температура diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2ad7a484d..1e8793ead 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3385,6 +3385,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Inställningar för Tate-chu-yoko… Tate-chu-yoko Settings Inställningar för Tate-chu-yoko + Composite Fonts… Sammansatta teckensnitt… + Composite Fonts Sammansatta teckensnitt + Export Composite Fonts Exportera sammansatta teckensnitt + Import Composite Fonts Importera sammansatta teckensnitt + Delete Font Ta bort teckensnitt + Kanji Kanji + Kana Kana + Numerals Siffror + Scale at Center Skala från mitten Up/Down: Upp/ned: Left/Right: Vänster/höger: Temperature Temperatur diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7ceb6b3f8..b60caed0f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3276,6 +3276,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko Ayarları… Tate-chu-yoko Settings Tate-chu-yoko Ayarları + Composite Fonts… Bileşik Fontlar… + Composite Fonts Bileşik Fontlar + Export Composite Fonts Bileşik Fontları Dışa Aktar + Import Composite Fonts Bileşik Fontları İçe Aktar + Delete Font Fontu Sil + Kanji Kanji + Kana Kana + Numerals Rakamlar + Scale at Center Merkezden Ölçekle Up/Down: Yukarı/Aşağı: Left/Right: Sol/Sağ: Temperature Sıcaklık diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2cd5d1560..add053e44 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3492,6 +3492,15 @@ effect Points Точки Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Параметри Tate-chu-yoko… Tate-chu-yoko Settings Параметри Tate-chu-yoko + Composite Fonts… Складені шрифти… + Composite Fonts Складені шрифти + Export Composite Fonts Експорт складених шрифтів + Import Composite Fonts Імпорт складених шрифтів + Delete Font Видалити шрифт + Kanji Кандзі + Kana Кана + Numerals Цифри + Scale at Center Масштабувати від центру Up/Down: Вгору/вниз: Left/Right: Ліворуч/праворуч: Temperature Температура diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a9f0c3b4..134a2126c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3826,6 +3826,15 @@ mark-style Japanese Kiểu Nhật Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Cài đặt Tate-chu-yoko… Tate-chu-yoko Settings Cài đặt Tate-chu-yoko + Composite Fonts… Phông chữ tổ hợp… + Composite Fonts Phông chữ tổ hợp + Export Composite Fonts Xuất phông chữ tổ hợp + Import Composite Fonts Nhập phông chữ tổ hợp + Delete Font Xóa phông chữ + Kanji Kanji + Kana Kana + Numerals Chữ số + Scale at Center Co giãn từ tâm Up/Down: Lên/Xuống: Left/Right: Trái/Phải: Temperature Nhiệt độ màu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d194bb74c..63e4f4690 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3331,6 +3331,15 @@ digits Malayalam 马拉雅拉姆数字 Tate-chu-yoko 纵中横 Tate-chu-yoko Settings… 纵中横设置… Tate-chu-yoko Settings 纵中横设置 + Composite Fonts… 复合字体… + Composite Fonts 复合字体 + Export Composite Fonts 导出复合字体 + Import Composite Fonts 导入复合字体 + Delete Font 删除字体 + Kanji 汉字 + Kana 假名 + Numerals 数字 + Scale at Center 从中心缩放 Up/Down: 上/下: Left/Right: 左/右: Temperature 色温 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a799dd0b1..13870461f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4371,6 +4371,15 @@ digits Malayalam 馬來亞拉姆數字 Tate-chu-yoko 縱中橫 Tate-chu-yoko Settings… 縱中橫設定… Tate-chu-yoko Settings 縱中橫設定 + Composite Fonts… 複合字體… + Composite Fonts 複合字體 + Export Composite Fonts 匯出複合字體 + Import Composite Fonts 匯入複合字體 + Delete Font 刪除字體 + Kanji 漢字 + Kana 假名 + Numerals 數字 + Scale at Center 從中心縮放 Up/Down: 上/下: Left/Right: 左/右: Temperature 色溫 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index f74a0da01..43d6ef6cb 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -101,6 +101,7 @@ pub fn opens_as(name: &str) -> Option { (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), (fileio::SCREEN_EXPORT_PRESET_EXTS, "document.exportPresets.import", EXPORT_PRESETS), + (vectorcraft_engine::cmd::compositefonts::EXTS, "type.compositeFonts.import", "composite fonts"), ]; if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { Some(OpensAs::Plugin) diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 98e52a2c3..e9860e99a 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -129,6 +129,7 @@ const FILE_TYPES: &[(&str, &[&str])] = &[ ("Print presets", vectorcraft_engine::cmd::printpresets::PRESET_EXTS), ("Action sets", &[crate::panels::actions::ACTIONS_EXT]), ("Export presets", fileio::SCREEN_EXPORT_PRESET_EXTS), + ("Composite fonts", vectorcraft_engine::cmd::compositefonts::EXTS), ("CSS", &["css"]), ("PostScript", &["ps"]), ]; diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index d481ba389..d32ae53d5 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -3,16 +3,18 @@ //! user's overrides. //! //! Entry keys are VectorCraft's everywhere outside this module: a command id, `tool:`, or -//! `panel:` for the Window menu item showing a panel (`window.panel {panel}`). Chord texts -//! outside it are VectorCraft's too (`Cmd+Shift+]`, `Comma`, `Period`, `~` for the backtick key, -//! `=` for Plus with modifiers). Inside an [`Overrides`] keys and texts are the crate's. +//! `panel:` for the Window menu item showing a panel (`window.panel {panel}`), `effect:` +//! for an Effect menu item, or `plugin:` for a filter plug-in's Object › Plug-ins item +//! (`plugin.dialog {id}`). Chord texts outside it are VectorCraft's too (`Cmd+Shift+]`, `Comma`, +//! `Period`, `~` for the backtick key, `=` for Plus with modifiers). Inside an [`Overrides`] keys +//! and texts are the crate's. -use std::sync::{OnceLock, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Lookup, Mods, Overrides, Platform, Scope, Scopes}; use serde_json::{Value, json}; -use crate::shortcut_editor::{self, Entry}; +use crate::shortcut_editor::{self, EffectItem, Entry, Snapshot}; /// Menu commands whose label flips between Show and Hide (see `menus::dynamic_label`). pub const TOGGLES: &[&str] = &[ @@ -38,37 +40,95 @@ pub const TOGGLES: &[&str] = &[ /// The command an entry key runs. pub fn command_of(key: &str) -> CommandRef { + if key.starts_with("effect:") { + return command_in(key, &shortcut_editor::effect_items()); + } + command_in(key, &[]) +} + +/// The command an entry key runs while the Effect menu holds `effects`. +fn command_in(key: &str, effects: &[EffectItem]) -> CommandRef { if let Some(panel) = key.strip_prefix("panel:") { return CommandRef::with_params("window.panel", json!({ "panel": panel })); } - // An Effect menu item (#3296): what its item runs, its dialog unless it has no options. + // An Effect menu item (#3296): what its item runs, its dialog unless it has no options. An + // effect the menu does not hold (a plug-in removed for now) is named by its dialog. if let Some(effect) = key.strip_prefix("effect:") { - let command = shortcut_editor::effect_items().iter().find(|e| e.effect == effect).map_or("effect.dialog", |e| e.command); + let command = effects.iter().find(|e| e.effect == effect).map_or("effect.dialog", |e| e.command); return CommandRef::with_params(command, json!({ "effect": effect })); } + // A filter plug-in's Object › Plug-ins item, installed or not: what its item runs. + if let Some(id) = key.strip_prefix("plugin:") { + return CommandRef::with_params("plugin.dialog", json!({ "id": id })); + } CommandRef::new(key) } -/// The entry key of a command: `panel:` for `window.panel {panel}`. +/// `overrides` with each Effect menu item's shortcut under the command its item runs while the +/// menu holds `effects`: a shortcut saved while a plug-in effect was missing, or while it had +/// options it no longer has, stays that effect's. +pub(crate) fn effect_keys_in(overrides: &Overrides, effects: &[EffectItem]) -> Overrides { + let mut out = overrides.clone(); + for (k, texts) in overrides.iter() { + let entry = entry_of_stored(k); + if !entry.starts_with("effect:") { + continue; + } + let now = command_in(&entry, effects).key(); + if now != k && out.get(&now).is_none() { + out.remove(k); + let _ = out.insert(now, texts.to_vec()); + } + } + out +} + +/// The entry key of a command: `panel:` for `window.panel {panel}`, `effect:` for an +/// Effect menu item and `plugin:` for `plugin.dialog {id}`. pub fn entry_key(command: &CommandRef) -> String { match (command.id.as_str(), command.params.get("panel").and_then(Value::as_str)) { ("window.panel", Some(panel)) => format!("panel:{panel}"), ("effect.dialog" | "effect.apply", _) if command.params.get("effect").and_then(Value::as_str).is_some() => { format!("effect:{}", command.params.get("effect").and_then(Value::as_str).unwrap_or_default()) } + ("plugin.dialog", _) if command.params.get("id").and_then(Value::as_str).is_some() => { + format!("plugin:{}", command.params.get("id").and_then(Value::as_str).unwrap_or_default()) + } _ => command.key(), } } /// The [`Overrides`] key of an entry key. pub fn stored_key(key: &str) -> String { - if key.starts_with("panel:") || key.starts_with("effect:") { command_of(key).key() } else { key.to_string() } + if key.starts_with("panel:") || key.starts_with("effect:") || key.starts_with("plugin:") { command_of(key).key() } else { key.to_string() } +} + +/// An Effect menu item's [`Overrides`] key under the command its item does not run now +/// (`effect.apply` for one that opens its dialog, and the other way round). +fn other_stored_key(key: &str) -> Option { + let effect = key.strip_prefix("effect:")?; + let other = if command_of(key).id == "effect.apply" { "effect.dialog" } else { "effect.apply" }; + Some(CommandRef::with_params(other, json!({ "effect": effect })).key()) +} + +/// The texts of an entry's override, saved under its [`stored_key`] or, for an Effect menu item, +/// under its [`other_stored_key`]. +pub(crate) fn stored_texts<'a>(overrides: &'a Overrides, key: &str) -> Option<&'a [String]> { + overrides.get(&stored_key(key)).or_else(|| other_stored_key(key).and_then(|k| overrides.get(&k))) +} + +/// Removes an entry's override, under either key an Effect menu item's can have. +pub(crate) fn remove_override(overrides: &mut Overrides, key: &str) { + overrides.remove(&stored_key(key)); + if let Some(k) = other_stored_key(key) { + overrides.remove(&k); + } } /// The entry key of an [`Overrides`] key. pub fn entry_of_stored(key: &str) -> String { CommandRef::parse_key(key) - .filter(|c| matches!(c.id.as_str(), "window.panel" | "effect.dialog" | "effect.apply")) + .filter(|c| matches!(c.id.as_str(), "window.panel" | "effect.dialog" | "effect.apply" | "plugin.dialog")) .map_or_else(|| key.to_string(), |c| entry_key(&c)) } @@ -132,7 +192,7 @@ fn vc_binding(text: &str) -> Option { /// Every listed entry as the crate sees it, in dispatch priority: commands (engine, then UI), /// panels, tools. Each tool is its own group: tools never cycle. -fn catalogue(p: Platform) -> Vec { +fn catalogue(snapshot: &Snapshot, p: Platform) -> Vec { let rank = |e: &Entry| { if e.is_tool { 2 @@ -142,14 +202,14 @@ fn catalogue(p: Platform) -> Vec { 0 } }; - let mut entries: Vec<&Entry> = shortcut_editor::entries().iter().collect(); + let mut entries: Vec<&Entry> = snapshot.entries.iter().collect(); entries.sort_by_key(|e| rank(e)); entries .into_iter() .map(|e| { let mut path: Vec = e.group.split(" › ").filter(|s| !s.is_empty()).map(str::to_string).collect(); path.push(e.label.clone()); - let mut b = Bindable::new(command_of(&e.key), e.label.clone(), Scope::app()).with_path(path); + let mut b = Bindable::new(command_in(&e.key, &snapshot.effects), e.label.clone(), Scope::app()).with_path(path); if let Some(tool) = e.key.strip_prefix("tool:") { b = b.with_kind(Kind::Tool { group: Some(tool.to_string()) }); } @@ -208,34 +268,54 @@ pub fn note_os(ctx: &egui::Context) { } } -/// The value for `p` in `cells`, made on first use. Each platform has its own cell, because the -/// web build learns the browser's platform only after the first frame. -pub(crate) fn per_platform(cells: &'static [OnceLock; 3], p: Platform, make: impl FnOnce() -> T) -> &'static T { - let [mac, windows, other] = cells; - let cell = match p { - Platform::Mac => mac, - Platform::Windows => windows, - _ => other, - }; - cell.get_or_init(make) +/// The keymap with the default shortcuts for [`platform`] and the plug-ins installed now. +pub fn template() -> Arc { + template_for(&shortcut_editor::snapshot()) } -/// The keymap with the default shortcuts for [`platform`], built once per platform. -pub fn template() -> &'static Keymap { - static TEMPLATES: [OnceLock; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; +/// The keymap with the default shortcuts for [`platform`] and the entries of `snapshot`, built +/// once per platform and plug-in revision. Each platform has its own cell, because the web build +/// learns the browser's platform only after the first frame. +pub(crate) fn template_for(snapshot: &Snapshot) -> Arc { + /// A template and the plug-in revision it was built for. + type Built = Option<(u64, Arc)>; + static TEMPLATES: Mutex<[Built; 3]> = Mutex::new([None, None, None]); let p = platform(); - per_platform(&TEMPLATES, p, || keymap_for(p)) + let at = match p { + Platform::Mac => 0, + Platform::Windows => 1, + _ => 2, + }; + let cached = TEMPLATES.lock().unwrap_or_else(|e| e.into_inner())[at].clone(); + if let Some((_, km)) = cached.filter(|(revision, _)| *revision == snapshot.revision) { + return km; + } + let km = Arc::new(keymap_for(p, snapshot)); + let mut cells = TEMPLATES.lock().unwrap_or_else(|e| e.into_inner()); + if cells[at].as_ref().is_none_or(|(revision, _)| *revision <= snapshot.revision) { + cells[at] = Some((snapshot.revision, km.clone())); + } + km } -/// The default keymap of `p`. The chords the operating system takes can't be assigned. -pub(crate) fn keymap_for(p: Platform) -> Keymap { - Keymap::new(catalogue(p), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p)) +/// The default keymap of `p` with the entries of `snapshot`. The chords the operating system +/// takes can't be assigned. +pub(crate) fn keymap_for(p: Platform, snapshot: &Snapshot) -> Keymap { + Keymap::new(catalogue(snapshot, p), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p)) } /// The keymap with `overrides` applied. pub fn keymap_with(overrides: &Overrides) -> Keymap { - let defaults = template(); - defaults.clone().with_overrides(edit_alias_overrides(overrides, defaults.platform())) + keymap_in(&shortcut_editor::snapshot(), overrides) +} + +/// The keymap of `snapshot`'s entries with `overrides` applied, each Effect menu item's shortcut +/// under the command its item runs now. +pub(crate) fn keymap_in(snapshot: &Snapshot, overrides: &Overrides) -> Keymap { + let defaults = template_for(snapshot); + let moved = effect_keys_in(overrides, &snapshot.effects); + let resolved = edit_alias_overrides(&moved, defaults.platform()); + defaults.as_ref().clone().with_overrides(resolved) } /// When a saved override assigns F2, F3 or F4 elsewhere without changing its edit command, @@ -277,7 +357,7 @@ pub fn lookup(km: &Keymap, chord: &Chord) -> Option { pub fn effective_with(km: &Keymap, key: &str) -> Option { let command = command_of(key); if km.bindable(&command).is_none() { - return match km.overrides().get(&stored_key(key)) { + return match stored_texts(km.overrides(), key) { Some(texts) => texts.first().filter(|t| !t.trim().is_empty()).map(|t| crate_to_vc(t)), None => shortcut_editor::default_of(key).map(str::to_string), }; @@ -297,6 +377,9 @@ pub fn effective_with(km: &Keymap, key: &str) -> Option { /// Sets an entry's shortcut in VectorCraft's terms: `""` for none. pub fn set_override(overrides: &mut Overrides, key: &str, text: &str) { let texts = if text.trim().is_empty() { vec![] } else { vec![vc_to_crate(text)] }; + if let Some(k) = other_stored_key(key) { + overrides.remove(&k); + } // Only an over-long key or text is refused; such an entry could not be stored anyway. let _ = overrides.insert(stored_key(key), texts); } @@ -312,11 +395,11 @@ pub fn overrides_from(pairs: &[(&str, &str)]) -> Overrides { /// An entry's stored shortcut in VectorCraft's terms (`""` for none), when it has an override. pub fn override_text(overrides: &Overrides, key: &str) -> Option { - overrides.get(&stored_key(key)).map(|t| t.first().map(|t| crate_to_vc(t)).unwrap_or_default()) + stored_texts(overrides, key).map(|t| t.first().map(|t| crate_to_vc(t)).unwrap_or_default()) } pub fn has_override(overrides: &Overrides, key: &str) -> bool { - overrides.get(&stored_key(key)).is_some() + stored_texts(overrides, key).is_some() } /// Serde for [`Overrides`] in VectorCraft's own form, which every earlier version reads: a map @@ -428,7 +511,7 @@ mod tests { #[test] fn curvature_keeps_shift_tilde_and_plus_is_one_key() { - let km = template(); + let km = &*template(); let curvature = vc_chord("Shift+~").unwrap(); assert_eq!(curvature, Chord::parse("Shift+`").unwrap()); assert_eq!(lookup(km, &curvature), Some(CommandRef::new("tool:curvature"))); @@ -448,17 +531,21 @@ mod tests { #[test] fn the_default_shortcuts_do_not_conflict() { - assert_eq!(template().conflicts(), vec![]); - assert_eq!(template().problems(), vec![]); - assert_eq!(template().catalog().len(), shortcut_editor::entries().len(), "every entry is bindable"); + // One snapshot: tests running beside this one install and remove plug-ins. + let snapshot = shortcut_editor::snapshot(); + let template = template_for(&snapshot); + assert_eq!(template.conflicts(), vec![]); + assert_eq!(template.problems(), vec![]); + assert_eq!(template.catalog().len(), snapshot.entries.len(), "every entry is bindable"); // A default the bridge can't read would show in the menus but never run. - let unbound: Vec<(&str, &str)> = shortcut_editor::entries() + let unbound: Vec<(&str, &str)> = snapshot + .entries .iter() .filter_map(|e| shortcut_editor::default_of(&e.key).map(|d| (e.key.as_str(), d))) .filter(|(key, d)| { let mut expected: Vec = vc_binding(d).into_iter().collect(); expected.extend(default_aliases(key, platform()).iter().filter_map(|alias| vc_binding(alias))); - expected != template().default_bindings(&command_of(key)) + expected != template.default_bindings(&command_in(key, &snapshot.effects)) }) .collect(); assert_eq!(unbound, vec![], "every default text becomes the entry's binding"); @@ -471,10 +558,10 @@ mod tests { let defaults = template().default_bindings(&command); assert_eq!(defaults, vec![vc_binding(plain).unwrap(), vc_binding(shifted).unwrap()], "{key}"); assert_eq!( - shortcut_editor::entry(key).map(|e| e.label.as_str()), + shortcut_editor::entry(key).as_ref().map(|e| e.label.as_str()), Some(if key == "paint.lastColor" { "Apply Last Color" } else { "Apply Last Gradient" }) ); - assert_eq!(effective_with(template(), key).as_deref(), Some(plain)); + assert_eq!(effective_with(&template(), key).as_deref(), Some(plain)); let mut overrides = Overrides::new(); set_override(&mut overrides, key, "Shift+;"); assert_eq!(keymap_with(&overrides).bindings(&command), vec![vc_binding("Shift+;").unwrap()]); @@ -487,7 +574,7 @@ mod tests { #[test] fn edit_function_keys_follow_platform_and_reset_restores_both_bindings() { for p in [Platform::Windows, Platform::Linux, Platform::Mac] { - let km = keymap_for(p); + let km = keymap_for(p, &shortcut_editor::snapshot()); for (key, primary, alternate) in [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")] { let command = command_of(key); let mut expected = vec![vc_binding(primary).unwrap()]; @@ -514,16 +601,16 @@ mod tests { for (alias, edit, primary) in [("F2", "edit.cut", "Cmd+X"), ("F3", "edit.copy", "Cmd+C"), ("F4", "edit.paste", "Cmd+V")] { let mut saved = Overrides::new(); set_override(&mut saved, "tool:pen", alias); - let assigned = keymap_for(p).with_overrides(edit_alias_overrides(&saved, p)); + let assigned = keymap_for(p, &shortcut_editor::snapshot()).with_overrides(edit_alias_overrides(&saved, p)); assert_eq!(lookup(&assigned, &vc_chord(alias).unwrap()), Some(command_of("tool:pen")), "{p:?} saved {alias} assignment"); assert_eq!(lookup(&assigned, &vc_chord(primary).unwrap()), Some(command_of(edit)), "{p:?} primary {edit} binding"); shortcut_editor::reset_one(&mut saved, "tool:pen"); - let reset = keymap_for(p).with_overrides(edit_alias_overrides(&saved, p)); + let reset = keymap_for(p, &shortcut_editor::snapshot()).with_overrides(edit_alias_overrides(&saved, p)); assert_eq!(lookup(&reset, &vc_chord(alias).unwrap()), (!p.is_mac()).then_some(command_of(edit)), "{p:?} reset saved {alias}"); } let mut unknown = Overrides::new(); set_override(&mut unknown, "missing.command", "F2"); - let km = keymap_for(p).with_overrides(edit_alias_overrides(&unknown, p)); + let km = keymap_for(p, &shortcut_editor::snapshot()).with_overrides(edit_alias_overrides(&unknown, p)); assert_eq!(lookup(&km, &vc_chord("F2").unwrap()), (!p.is_mac()).then_some(command_of("edit.cut")), "{p:?} unknown saved entry"); } } @@ -531,10 +618,12 @@ mod tests { /// The chords the operating system takes are refused, and no default uses one. #[test] fn system_shortcuts_are_refused() { + let snapshot = shortcut_editor::snapshot(); for (p, os_chord) in [(Platform::Mac, "Cmd+Tab"), (Platform::Windows, "Alt+F4"), (Platform::Linux, "Ctrl+Alt+F2")] { - let mut km = keymap_for(p); + let mut km = keymap_for(p, &snapshot); assert_eq!((km.conflicts(), km.problems()), (vec![], vec![]), "{p:?}"); - let reserved: Vec = shortcut_editor::entries() + let reserved: Vec = snapshot + .entries .iter() .filter(|e| km.bindings(&command_of(&e.key)).iter().any(|b| km.is_reserved(&b.trigger.chord))) .map(|e| e.key.clone()) @@ -545,7 +634,7 @@ mod tests { // A saved override on a reserved chord keeps the default. let mut ov = Overrides::new(); let _ = ov.insert(stored_key("edit.undo"), vec![os_chord.to_string()]); - let km = keymap_for(p).with_overrides(ov); + let km = keymap_for(p, &snapshot).with_overrides(ov); assert_eq!(km.bindings(&command_of("edit.undo")), km.default_bindings(&command_of("edit.undo")), "{p:?}"); } } @@ -557,7 +646,8 @@ mod tests { assert_eq!(entry_key(&c), "panel:layers"); assert_eq!(entry_of_stored(&stored_key("panel:layers")), "panel:layers"); assert_eq!(stored_key("tool:pen"), "tool:pen"); - let paths = &template().bindable(&c).unwrap().paths; + let template = template(); + let paths = &template.bindable(&c).unwrap().paths; assert_eq!(paths, &vec![vec!["Window".to_string(), "Layers".to_string()]], "import matches panels by name"); } @@ -569,7 +659,8 @@ mod tests { for id in TOGGLES { let label = crate::menus::dynamic_label(&app, id, ""); assert!(label.starts_with("Show ") || label.starts_with("Hide "), "{id}: {label}"); - let listed = template().bindable(&CommandRef::new(*id)); + let template = template(); + let listed = template.bindable(&CommandRef::new(*id)); assert!(listed.is_none_or(|b| b.is_toggle), "{id}"); } } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index a29714b1a..2594b6842 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -1,4 +1,4 @@ -use super::state::{SUBJECT_COMMANDS, has_subject, waits_for_ime}; +use super::state::{SUBJECT_COMMANDS, enabled, has_subject, waits_for_ime}; use super::{Value, VectorcraftApp, json}; /// What a menu click runs: items whose label ends with "…" and that carry default params open a @@ -138,6 +138,10 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { if waits_for_ime(app, id) { return; } + // A shortcut or a palette pick on a filter plug-in does nothing while its menu item is dimmed. + if id == "plugin.dialog" && !enabled(app, id) { + return; + } if SUBJECT_COMMANDS.contains(&id) && p.get("ids").is_none_or(|ids| ids.as_array().is_some_and(Vec::is_empty)) && p.get("id").is_none() @@ -267,6 +271,13 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}})); return true; } + // Composite Fonts…: the document's composite fonts, to edit before OK sets them. + if id == crate::dialogs::composite_fonts::COMMAND && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::composite_fonts::open(app) { + app.status(e); + } + return true; + } // Edit Views…: the saved views in a list, to rename or delete. if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { if let Err(e) = crate::dialogs::edit_views::open(app) { diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index fb92f2c8d..6c0128f9f 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -213,11 +213,13 @@ pub(super) fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang: } /// The shortcut a menu item shows: its command's (items without params), a panel's for the -/// Window menu's `window.panel {panel}` items, or an effect's for the Effect menu's items. +/// Window menu's `window.panel {panel}` items, an effect's for the Effect menu's items, or a filter +/// plug-in's for Object › Plug-ins' `plugin.dialog {id}` items. pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> { match (id, p.get("panel").and_then(Value::as_str)) { ("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel), ("effect.dialog" | "effect.apply", _) => p.get("effect").and_then(Value::as_str).and_then(crate::shortcut_editor::effect_shortcut), + ("plugin.dialog", _) => p.get("id").and_then(Value::as_str).and_then(crate::shortcut_editor::filter_shortcut), // The Artboard tool's canvas menu passes the point its Paste goes to. ("edit.paste", _) => shortcut_of(id), // That menu's Cut and Copy: with the tool chosen, the keys run `edit.cut` and `edit.copy`, which do the same. diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index ff5ccda13..1c09d3899 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -135,12 +135,19 @@ fn swapped_out(app: &VectorcraftApp, id: &str) -> bool { enabled(app, "object.envelope.release") != reset } +/// Menu items shown only with Preferences › Type › Show East Asian Options on: Type › Composite +/// Fonts…. +fn east_asian_hidden(app: &VectorcraftApp, id: &str) -> bool { + id == crate::dialogs::composite_fonts::COMMAND && !app.session.prefs.show_east_asian_options +} + /// How the menus show item `id` (params `p`) now: (enabled, checked: `Some` for an item that is on -/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]) or an item -/// another stands in for ([`swapped_out`]). +/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]), an item +/// another stands in for ([`swapped_out`]) or an East Asian item with those options hidden +/// ([`east_asian_hidden`]). pub fn shown_state(app: &VectorcraftApp, id: &str, p: &Value) -> Option<(bool, Option)> { let en = enabled(app, id) && (id != crate::addons::select::link_status::COMMAND || crate::addons::select::link_status::enabled(app, p)); - if (!en && hidden_when_disabled(id)) || swapped_out(app, id) { + if (!en && hidden_when_disabled(id)) || swapped_out(app, id) || east_asian_hidden(app, id) { return None; } Some((en, checked(app, id, p))) diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 1c35da877..caff8fc02 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -407,6 +407,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Fit Headline", "text.fitHeadline"), Sep, c("Find Font…", "type.findFont"), + c("Composite Fonts…", crate::dialogs::composite_fonts::COMMAND), sub( "Change Case", vec![ diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 4f893e686..14f0b8a9d 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -16,7 +16,8 @@ pub fn items() -> Vec<(String, String, String)> { let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) }; items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string())); } - for c in menus::UI_COMMANDS { + // `plugin.dialog` runs nothing without an id: [`build`] lists each filter instead. + for c in menus::UI_COMMANDS.iter().filter(|c| c.0 != "plugin.dialog") { items.push((c.1.to_string(), c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string())); } for tool in vectorcraft_tools::catalog::all_tools() { @@ -32,7 +33,7 @@ fn shown_label(lang: crate::i18n::Lang, label: &str) -> String { } /// One palette entry, with what it shows and what a query matches worked out once. -struct Entry { +pub(crate) struct Entry { id: String, shortcut: String, /// The label in the UI language. @@ -59,20 +60,47 @@ fn entries(ctx: &egui::Context, lang: crate::i18n::Lang) -> Arc> { { return v; } - let built: Arc> = Arc::new( - items() - .into_iter() - .map(|(label, id, shortcut)| { - let shown = shown_label(lang, &label); - let haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase()); - Entry { id, shortcut, shown, haystack } - }) - .collect(), - ); + let built: Arc> = Arc::new(build(lang)); ctx.data_mut(|d| d.insert_temp::(id, (key, built.clone()))); built } +/// The palette's entries in `lang`: [`items`], then one per filter plug-in's Object › Plug-ins +/// item (`plugin:`), whose name is not translated. +fn build(lang: crate::i18n::Lang) -> Vec { + let entry = |label: String, shown: String, id: String, shortcut: String| { + let haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase()); + Entry { id, shortcut, shown, haystack } + }; + let mut out: Vec = items() + .into_iter() + .map(|(label, id, shortcut)| { + let shown = shown_label(lang, &label); + entry(label, shown, id, shortcut) + }) + .collect(); + out.extend(crate::shortcut_editor::filter_items().iter().map(|f| { + let shortcut = crate::shortcut_editor::filter_shortcut(&f.id).unwrap_or("").to_string(); + let label = format!("{} › {}", f.group, f.label); + let shown = format!("{} › {}", shown_label(lang, &f.group), f.label); + entry(label, shown, format!("plugin:{}", f.id), shortcut) + })); + out +} + +/// Runs the palette result `id`: selects a tool, or invokes a command with the params its entry +/// stands for. +pub(crate) fn run_pick(app: &mut VectorcraftApp, id: &str) { + if let Some(tool) = id.strip_prefix("tool:") { + app.select_tool(tool); + } else if id.starts_with("plugin:") { + let command = crate::keymap::command_of(id); + menus::invoke(app, &command.id, command.params); + } else { + menus::invoke(app, id, json!({})); + } +} + pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if !app.ui.palette_open { return; @@ -137,11 +165,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.data_mut(|d| d.insert_temp(sel_id, (sel, q))); if let Some(id) = run { app.ui.palette_open = false; - if let Some(tool) = id.strip_prefix("tool:") { - app.select_tool(tool); - } else { - menus::invoke(app, &id, json!({})); - } + run_pick(app, &id); } } @@ -264,4 +288,39 @@ mod tests { app.ui.palette_query = "zoom in".into(); assert_eq!(frame(&mut app, vec![]), 0); } + + /// Each filter plug-in has one result, also when two share a name: its menu path translates, + /// the plug-in's name does not, and a pick runs that filter (a dialog for one with parameters, + /// nothing with no document open). + #[test] + fn each_filter_plugin_is_a_result_of_its_own() { + use vectorcraft_plugins::wat as tpl; + let (one, two) = ("test.UI.palette_filter-one", "test.UI.palette_filter-two"); + let (key_one, key_two) = (format!("plugin:{one}"), format!("plugin:{two}")); + let install = |id: &str, params: &str| { + let manifest = format!(r#"{{"id":"{id}","name":"Group","version":"1","kind":"filter","params":{params}}}"#); + vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap(); + }; + install(one, "{}"); + install(two, r#"{"wiggle":{"type":"bool"}}"#); + let zh = Lang::from_code("zh-hant").unwrap(); + let built = build(zh); + let of = |key: &str| built.iter().filter(|e| e.id == key).collect::>(); + assert_eq!((of(&key_one).len(), of(&key_two).len()), (1, 1)); + assert!(built.iter().all(|e| e.id != "plugin.dialog"), "no result without a filter's id"); + let path = format!("{} › {}", crate::i18n::tr(zh, "Object"), crate::i18n::tr(zh, "Plug-ins")); + assert_eq!(of(&key_two)[0].shown, format!("{path} › Group…")); + assert_eq!(of(&key_one)[0].shown, format!("{path} › Group")); + assert!(of(&key_two)[0].haystack.contains("object › plug-ins › group…")); + + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + run_pick(&mut app, &key_two); + assert!(app.ui.dialog.is_none(), "no document: the dimmed item's pick does nothing"); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + run_pick(&mut app, &key_two); + let d = app.ui.dialog.take().expect("the filter's dialog opens"); + assert_eq!((d.kind.as_str(), d.str("__plugin")), ("plugin", two.to_string())); + vectorcraft_plugins::registry::remove(one); + vectorcraft_plugins::registry::remove(two); + } } diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 2bee024da..5dcf390fd 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -65,13 +65,17 @@ pub(crate) use ui_mirror; /// The keymap with the user's overrides, and the effective text of every entry they change. pub(crate) struct Mirror { pub keymap: Keymap, - source_overrides: Overrides, text: HashMap>, + /// The overrides this was built from, as the UI state holds them. + source: Overrides, + /// The plug-in revision of the entries the keymap holds ([`Snapshot::revision`]). + revision: u64, } impl Mirror { fn new(overrides: Overrides) -> Mirror { - let keymap = crate::keymap::keymap_with(&overrides); + let snapshot = snapshot(); + let keymap = crate::keymap::keymap_in(&snapshot, &overrides); let text = overrides .iter() .map(|(k, _)| { @@ -80,20 +84,41 @@ impl Mirror { (key, t) }) .collect(); - Mirror { keymap, source_overrides: overrides, text } + Mirror { keymap, text, source: overrides, revision: snapshot.revision } + } + + /// Was this built from `overrides` for the platform and the plug-ins in effect now? + fn is_for(&self, overrides: &Overrides) -> bool { + self.source == *overrides && self.revision == menus::plugin_revision() && self.keymap.platform() == crate::keymap::platform() } } ui_mirror!(fn store() -> Option>); -/// The mirror: the keymap in effect. +/// The mirror: the keymap in effect. One the plug-ins installed or removed since it was built +/// left behind is built again, with the overrides it was built from. pub(crate) fn current() -> Arc { - static DEFAULTS: [OnceLock>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; - store() - .read() - .ok() - .and_then(|m| m.clone()) - .unwrap_or_else(|| crate::keymap::per_platform(&DEFAULTS, crate::keymap::platform(), || Arc::new(Mirror::new(Overrides::new()))).clone()) + static DEFAULT: Mutex>> = Mutex::new(None); + if let Some(m) = store().read().ok().and_then(|m| m.clone()) { + if m.is_for(&m.source) { + return m; + } + let fresh = Arc::new(Mirror::new(m.source.clone())); + if let Ok(mut s) = store().write() + && s.as_ref().is_some_and(|s| Arc::ptr_eq(s, &m)) + { + *s = Some(fresh.clone()); + } + return fresh; + } + let none = Overrides::new(); + let cached = DEFAULT.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(m) = cached.filter(|m| m.is_for(&none)) { + return m; + } + let fresh = Arc::new(Mirror::new(none)); + *DEFAULT.lock().unwrap_or_else(|e| e.into_inner()) = Some(fresh.clone()); + fresh } /// Leak-once interning for override strings (bounded by the chords a user ever assigns). @@ -113,7 +138,7 @@ pub fn sync(ui: &UiState) { let same = store() .read() .map(|m| match &*m { - Some(m) => m.source_overrides == ui.shortcut_overrides && m.keymap.platform() == crate::keymap::platform(), + Some(m) => m.is_for(&ui.shortcut_overrides), None => ui.shortcut_overrides.is_empty(), }) .unwrap_or(false); @@ -334,9 +359,54 @@ pub struct Entry { pub is_tool: bool, } +/// The editor's entries, the Effect menu's effect items and Object › Plug-ins' filter items while +/// the installed plug-ins are those of one [`menus::plugin_revision`]. +pub(crate) struct Snapshot { + /// The plug-in revision read before the menus were walked. + pub revision: u64, + pub effects: Arc<[EffectItem]>, + pub filters: Arc<[FilterItem]>, + pub entries: Arc<[Entry]>, +} + +/// The entries for the plug-ins installed now: rebuilt when a plug-in is installed or removed, so +/// a live-effect or filter plug-in installed later has its row, and one removed has none. +pub(crate) fn snapshot() -> Arc { + static LAST: Mutex>> = Mutex::new(None); + let revision = menus::plugin_revision(); + let last = LAST.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(s) = last.filter(|s| s.revision == revision) { + return s; + } + // Built without the lock held: the Effect menu is walked anew. + let effects: Arc<[EffectItem]> = build_effect_items().into(); + let filters: Arc<[FilterItem]> = build_filter_items().into(); + let (head, tail) = fixed_entries(); + let entries: Arc<[Entry]> = head + .iter() + .cloned() + .chain(effects.iter().map(|e| Entry { key: format!("effect:{}", e.effect), label: e.label.clone(), group: e.group.clone(), is_tool: false })) + .chain(filters.iter().map(|f| Entry { key: format!("plugin:{}", f.id), label: f.label.clone(), group: f.group.clone(), is_tool: false })) + .chain(tail.iter().cloned()) + .collect(); + let s = Arc::new(Snapshot { revision, effects, filters, entries }); + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + // A newer snapshot another thread stored meanwhile stays. + if last.as_ref().is_none_or(|l| l.revision <= revision) { + *last = Some(s.clone()); + } + s +} + /// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists. -pub fn entries() -> &'static [Entry] { - static E: OnceLock> = OnceLock::new(); +pub fn entries() -> Arc<[Entry]> { + snapshot().entries.clone() +} + +/// The entries no plug-in changes: (tools, commands and panels; UI commands). The Effect menu's +/// items and Object › Plug-ins' filters go between them. +fn fixed_entries() -> &'static (Vec, Vec) { + static E: OnceLock<(Vec, Vec)> = OnceLock::new(); E.get_or_init(|| { let mut v: Vec = vectorcraft_tools::catalog::all_tools() .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true }) @@ -362,22 +432,17 @@ pub fn entries() -> &'static [Entry] { group: "Window".into(), is_tool: false, })); - // Each effect of the Effect menu, by its own key: they all run `effect.dialog` (#3296). - v.extend(effect_items().iter().map(|e| Entry { - key: format!("effect:{}", e.effect), - label: e.label.clone(), - group: e.group.clone(), - is_tool: false, - })); + // 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 { continue; } - v.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false }); + tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false }); } - v + (v, tail) }) } @@ -391,38 +456,41 @@ pub struct EffectItem { pub group: String, } -/// The Effect menu's effect items, in menu order, each once. -pub fn effect_items() -> &'static [EffectItem] { - static E: OnceLock> = OnceLock::new(); - E.get_or_init(|| { - fn walk(items: &[menus::Item], path: &mut Vec, seen: &mut HashSet, out: &mut Vec) { - for it in items { - match it { - menus::Item::Cmd(label, id @ ("effect.dialog" | "effect.apply"), p) => { - if let Some(effect) = p.get("effect").and_then(serde_json::Value::as_str) - && seen.insert(effect.to_string()) - { - out.push(EffectItem { effect: effect.to_string(), command: id, label: label.to_string(), group: path.join(" › ") }); - } +/// The Effect menu's effect items, in menu order, each once, with the live-effect plug-ins +/// installed now. +pub fn effect_items() -> Arc<[EffectItem]> { + snapshot().effects.clone() +} + +/// Walks the Effect menu for [`effect_items`]. +fn build_effect_items() -> Vec { + fn walk(items: &[menus::Item], path: &mut Vec, seen: &mut HashSet, out: &mut Vec) { + for it in items { + match it { + menus::Item::Cmd(label, id @ ("effect.dialog" | "effect.apply"), p) => { + if let Some(effect) = p.get("effect").and_then(serde_json::Value::as_str) + && seen.insert(effect.to_string()) + { + out.push(EffectItem { effect: effect.to_string(), command: id, label: label.to_string(), group: path.join(" › ") }); } - menus::Item::Sub(name, children) => { - path.push(name.to_string()); - walk(children, path, seen, out); - path.pop(); - } - _ => {} } + menus::Item::Sub(name, children) => { + path.push(name.to_string()); + walk(children, path, seen, out); + path.pop(); + } + _ => {} } } - let mut out = vec![]; - let mut seen = HashSet::new(); - for (name, items) in menus::menu_tree() { - if name == "Effect" { - walk(&items, &mut vec![name.to_string()], &mut seen, &mut out); - } + } + let mut out = vec![]; + let mut seen = HashSet::new(); + for (name, items) in menus::menu_tree() { + if name == "Effect" { + walk(&items, &mut vec![name.to_string()], &mut seen, &mut out); } - out - }) + } + out } /// Effective shortcut of the Effect menu item of `effect` (`effect:`). @@ -430,6 +498,74 @@ pub fn effect_shortcut(effect: &str) -> Option<&'static str> { effective(&format!("effect:{effect}")) } +/// One filter plug-in's Object › Plug-ins item (`plugin.dialog {id}`) a shortcut can be given: the +/// plug-in's id, its label (the plug-in's name, with an ellipsis when it opens a dialog) and its +/// menu path. +#[derive(Clone, Debug)] +pub struct FilterItem { + pub id: String, + pub label: String, + pub group: String, +} + +/// Object › Plug-ins' filter items, in menu order, each once, with the filter plug-ins installed +/// now. +pub fn filter_items() -> Arc<[FilterItem]> { + snapshot().filters.clone() +} + +/// Walks the Object menu for [`filter_items`]. +fn build_filter_items() -> Vec { + fn walk(items: &[menus::Item], path: &mut Vec, seen: &mut HashSet, out: &mut Vec) { + for it in items { + match it { + menus::Item::Cmd(label, "plugin.dialog", p) => { + if let Some(id) = p.get("id").and_then(serde_json::Value::as_str) + && seen.insert(id.to_string()) + { + out.push(FilterItem { id: id.to_string(), label: label.to_string(), group: path.join(" › ") }); + } + } + menus::Item::Sub(name, children) => { + path.push(name.to_string()); + walk(children, path, seen, out); + path.pop(); + } + _ => {} + } + } + } + let mut out = vec![]; + let mut seen = HashSet::new(); + for (name, items) in menus::menu_tree() { + if name == "Object" { + walk(&items, &mut vec![name.to_string()], &mut seen, &mut out); + } + } + out +} + +/// Effective shortcut of filter plug-in `id`'s Object › Plug-ins item (`plugin:`). +pub fn filter_shortcut(id: &str) -> Option<&'static str> { + effective(&format!("plugin:{id}")) +} + +/// Entry `e`'s label in the UI language. A plug-in's own name is not translated. +pub(crate) fn shown_label(e: &Entry) -> &str { + let plugin = e.key.starts_with("plugin:") || e.key.strip_prefix("effect:").and_then(vectorcraft_plugins::effect::plugin_id).is_some(); + if plugin { &e.label } else { tl!(&e.label) } +} + +/// A menu path in the UI language, one segment at a time. +pub(crate) fn shown_group(group: &str) -> String { + group.split(" › ").map(|s| tl!(s)).collect::>().join(" › ") +} + +/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu. +pub(crate) fn shown_name(e: &Entry) -> String { + if e.is_tool || e.group.is_empty() { shown_label(e).to_string() } else { format!("{} › {}", shown_group(&e.group), shown_label(e)) } +} + /// The group of a command no menu holds: the first part of its id, capitalised (`edit.x` → "Edit"). fn id_group(id: &str) -> String { let mut c = id.split('.').next().unwrap_or_default().chars(); @@ -447,7 +583,7 @@ fn runs_without_params(params: &str) -> bool { /// Does the editor list `e` under `overrides`? An entry that [`needs_a_value`] is listed only while /// the user has given it a shortcut. fn listed(overrides: &Overrides, e: &Entry) -> bool { - !needs_a_value(&e.key) || overrides.get(&crate::keymap::stored_key(&e.key)).is_some_and(|t| !t.is_empty()) + !needs_a_value(&e.key) || crate::keymap::stored_texts(overrides, &e.key).is_some_and(|t| !t.is_empty()) } /// Commands whose params are all optional that do nothing, or fail, when a key press runs them @@ -583,17 +719,19 @@ fn names_a_required_field(params: &str) -> bool { false } -pub fn entry(key: &str) -> Option<&'static Entry> { - entries().iter().find(|e| e.key == key) +pub fn entry(key: &str) -> Option { + entries().iter().find(|e| e.key == key).cloned() } /// Entries (other than `except`) whose effective shortcut equals `chord`. pub fn conflicts_with(overrides: &Overrides, chord: &str, except: &str) -> Vec { let Some(c) = vc_chord(chord) else { return vec![] }; - let km = keymap_with(overrides); + let snapshot = snapshot(); + let km = crate::keymap::keymap_in(&snapshot, overrides); let p = km.platform(); let want = Trigger::single(c).effective(p); - entries() + snapshot + .entries .iter() .filter(|e| e.key != except) .filter(|e| km.bindings(&command_of(&e.key)).iter().any(|b| b.trigger.effective(p) == want)) @@ -699,7 +837,7 @@ pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) - } pub fn reset_one(overrides: &mut Overrides, key: &str) { - overrides.remove(&crate::keymap::stored_key(key)); + crate::keymap::remove_override(overrides, key); } /// Text shown for all active chords in a dialog row, with the backtick key shown as `typed` @@ -742,14 +880,15 @@ pub fn import_json(v: &Value) -> Result<(String, Overrides), String> { /// row per line with the name and the shortcuts as the dialog shows them, separated by a tab. A /// row with no shortcut ends after the tab. pub fn export_text(set: &str, overrides: &Overrides) -> String { - let km = keymap_with(overrides); + let snapshot = snapshot(); + let km = crate::keymap::keymap_in(&snapshot, overrides); let backtick = backtick_text(); let shown_set = if PRESETS.contains(&set) || set == CUSTOM { tl!(set) } else { set }; let mut out = format!("{}\n{} {shown_set}\n", tl!("Keyboard Shortcuts"), tl!("Set:")); for (tools, heading) in [(true, "Tools"), (false, "Menu Commands")] { out += &format!("\n{}\n", tl!(heading)); - for e in entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)) { - let name = if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) }; + for e in snapshot.entries.iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)) { + let name = shown_name(e); let sc = dialog_shortcuts_with(&km, &e.key, backtick.as_deref()).unwrap_or_default(); out += &format!("{name}\t{sc}\n"); } @@ -823,10 +962,12 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option q.unwrap_or_default().to_lowercase(), Err(e) => return Some(Err(e)), }; - let km = keymap_with(&app.ui.shortcut_overrides); - let ov = &app.ui.shortcut_overrides; + let snapshot = snapshot(); + let km = crate::keymap::keymap_in(&snapshot, &app.ui.shortcut_overrides); + let ov = &crate::keymap::effect_keys_in(&app.ui.shortcut_overrides, &snapshot.effects); Ok(Value::Array( - entries() + snapshot + .entries .iter() .filter(|e| listed(ov, e)) .filter(|e| q.is_empty() || e.label.to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)) @@ -960,13 +1101,17 @@ pub fn is_recording(app: &VectorcraftApp) -> bool { // ---------- dialog ---------- -/// The dialog's working set as a keymap, with each entry's shortcut text (in [`entries`] order) -/// and the number of conflicts, kept between frames while the set is unchanged. +/// The dialog's working set as a keymap, with the entries it lists, each entry's shortcut text (in +/// their order) and the number of conflicts, kept between frames while the set and the installed +/// plug-ins are unchanged. struct DialogView { /// The dialog's `overrides` field this was built from. source: Value, /// The [`backtick_text`] the texts show. backtick: Option, + /// The plug-in revision of `entries`. + revision: u64, + entries: Arc<[Entry]>, keymap: Keymap, texts: Vec>, conflicts: usize, @@ -974,20 +1119,32 @@ struct DialogView { impl DialogView { fn new(overrides: Overrides) -> DialogView { - let keymap = keymap_with(&overrides); + let snapshot = snapshot(); + let keymap = crate::keymap::keymap_in(&snapshot, &overrides); let backtick = backtick_text(); - let texts = entries().iter().map(|e| dialog_shortcuts_with(&keymap, &e.key, backtick.as_deref())).collect(); + let texts = snapshot.entries.iter().map(|e| dialog_shortcuts_with(&keymap, &e.key, backtick.as_deref())).collect(); let conflicts = conflicts_in(&keymap).len(); - DialogView { source: stored::to_value(&overrides), backtick, keymap, texts, conflicts } + DialogView { + source: stored::to_value(&crate::keymap::effect_keys_in(&overrides, &snapshot.effects)), + backtick, + revision: snapshot.revision, + entries: snapshot.entries.clone(), + keymap, + texts, + conflicts, + } } - /// The view of `d`'s working set, from the cache unless the set or the backtick key's - /// character changed. The dialog's `overrides` field is left in its stored form. + /// The view of `d`'s working set, from the cache unless the set, the backtick key's character + /// or the installed plug-ins changed. The dialog's `overrides` field is left in its stored form. fn of(ctx: &egui::Context, d: &mut Dialog) -> Arc { let id = egui::Id::unique("dialog-shortcuts-view"); let source = d.fields.get("overrides").unwrap_or(&Value::Null); let backtick = backtick_text(); - if let Some(v) = ctx.data(|m| m.get_temp::>(id)).filter(|v| v.source == *source && v.backtick == backtick) { + let revision = menus::plugin_revision(); + if let Some(v) = + ctx.data(|m| m.get_temp::>(id)).filter(|v| v.source == *source && v.backtick == backtick && v.revision == revision) + { return v; } let v = Arc::new(DialogView::new(dialog_overrides(d))); @@ -1096,25 +1253,21 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0)); }); egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| { - for (i, e) in entries().iter().enumerate().filter(|(_, e)| e.is_tool == tools && listed(km.overrides(), e)) { + for (i, e) in view.entries.iter().enumerate().filter(|(_, e)| e.is_tool == tools && listed(km.overrides(), e)) { let sc = match &km { Cow::Borrowed(_) => view.texts.get(i).cloned().flatten(), Cow::Owned(km) => dialog_shortcuts_with(km, &e.key, view.backtick.as_deref()), }; let matches = e.label.to_lowercase().contains(&q) - || tl!(&e.label).to_lowercase().contains(&q) + || shown_label(e).to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q) - || tl!(&e.group).to_lowercase().contains(&q) + || shown_group(&e.group).to_lowercase().contains(&q) || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q)); if !q.is_empty() && !matches { continue; } let row = ui.horizontal(|ui| { - let name = if e.is_tool || e.group.is_empty() { - tl!(&e.label).to_string() - } else { - format!("{} › {}", tl!(&e.group), tl!(&e.label)) - }; + let name = shown_name(e); let r = ui .allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(360.0); @@ -1282,7 +1435,7 @@ fn export_dialog_set(app: &mut VectorcraftApp, command: &str) { fn drop_hidden(d: &mut Dialog, overrides: &Overrides) { for field in ["__recording", "__selected", "__conflict"] { let key = d.str(field); - if !key.is_empty() && entry(&key).is_none_or(|e| !listed(overrides, e)) { + if !key.is_empty() && entry(&key).is_none_or(|e| !listed(overrides, &e)) { d.fields.insert(field.into(), json!("")); } } @@ -1322,9 +1475,23 @@ fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { mod tests { use super::*; + /// Runs `f` again until no plug-in was installed or removed while it ran, and returns what it + /// returned. Other tests install and remove plug-ins at any time, and each changes the Effect + /// menu's rows. + fn with_steady_plugins(mut f: impl FnMut() -> T) -> T { + for _ in 0..100 { + let revision = menus::plugin_revision(); + let r = f(); + if menus::plugin_revision() == revision { + return r; + } + } + panic!("the installed plug-ins kept changing"); + } + #[test] fn format_parse_round_trip_for_every_default() { - for e in entries() { + for e in entries().iter() { if let Some(s) = default_of(&e.key) && let Some(sc) = crate::shortcuts::parse(s) { @@ -1428,7 +1595,6 @@ mod tests { open(&mut app); let ctx = egui::Context::default(); theme::install_fonts(&ctx); - let at = entries().iter().position(|e| e.key == "edit.clear").unwrap(); let press = |key| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }; let text = |t: &str| egui::Event::Text(t.into()); let mut record_with = |events: Vec| { @@ -1436,7 +1602,9 @@ mod tests { let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(&mut app, ui.ctx())); out.textures_delta.clear(); let d = app.ui.dialog.clone().unwrap(); - let shown = DialogView::new(dialog_overrides(&d)).texts[at].clone(); + let view = DialogView::new(dialog_overrides(&d)); + let at = view.entries.iter().position(|e| e.key == "edit.clear").unwrap(); + let shown = view.texts[at].clone(); (crate::keymap::override_text(&dialog_overrides(&d), "edit.clear"), shown) }; assert_eq!(record_with(vec![press(Key::Backtick), text("\"")]), (Some("~".to_string()), Some("\"".to_string()))); @@ -1590,18 +1758,26 @@ mod tests { fn export_text_lists_the_dialogs_rows_with_their_shortcuts() { let mut ov = Overrides::new(); crate::keymap::set_override(&mut ov, "edit.preferences", "Ctrl+Alt+Shift+9"); - let text = export_text("VectorCraft Defaults", &ov); + let (text, km, tool_rows, menu_rows) = with_steady_plugins(|| { + let text = export_text("VectorCraft Defaults", &ov); + let km = keymap_with(&ov); + let names = |tools: bool| -> Vec { + entries() + .iter() + .filter(|e| e.is_tool == tools && listed(km.overrides(), e)) + .map(|e| if e.is_tool || e.group.is_empty() { format!("{}\t", e.label) } else { format!("{} › {}\t", e.group, e.label) }) + .collect() + }; + let rows = (names(true), names(false)); + (text, km, rows.0, rows.1) + }); let lines: Vec<&str> = text.lines().collect(); assert_eq!(lines[..4], ["Keyboard Shortcuts", "Set: VectorCraft Defaults", "", "Tools"]); let menu_at = lines.iter().position(|l| *l == "Menu Commands").expect("a Menu Commands section"); assert_eq!(lines[menu_at - 1], "", "a blank line before Menu Commands"); - let km = keymap_with(&ov); - let names = |tools: bool| -> Vec { - entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)).map(|e| text_row(&e.key, "")).collect() - }; let names_of = |rows: &[&str]| -> Vec { rows.iter().map(|l| format!("{}\t", l.split('\t').next().unwrap_or_default())).collect() }; - assert_eq!(names_of(&lines[4..menu_at - 1]), names(true), "the Tools rows, in the dialog's order"); - assert_eq!(names_of(&lines[menu_at + 1..]), names(false), "the Menu Commands rows, in the dialog's order"); + assert_eq!(names_of(&lines[4..menu_at - 1]), tool_rows, "the Tools rows, in the dialog's order"); + assert_eq!(names_of(&lines[menu_at + 1..]), menu_rows, "the Menu Commands rows, in the dialog's order"); let pen = menus::pretty_shortcut(default_of("tool:pen").expect("the Pen tool has a default key")); assert!(lines[4..menu_at].contains(&text_row("tool:pen", &pen).as_str()), "{text}"); let prefs = menus::pretty_shortcut(&effective_with(&km, "edit.preferences").expect("the shortcut set")); @@ -1641,7 +1817,11 @@ mod tests { Ok(()) })); app.run("shortcuts.import", json!({"data": export_json("Studio", &Overrides::new())})).unwrap(); - assert_eq!(app.run("shortcuts.exportText", json!({"path": "/sets/Studio.txt"})), Ok(json!({"path": "/sets/Studio.txt"}))); + let expected = with_steady_plugins(|| { + written.borrow_mut().clear(); + assert_eq!(app.run("shortcuts.exportText", json!({"path": "/sets/Studio.txt"})), Ok(json!({"path": "/sets/Studio.txt"}))); + export_text("Studio", &Overrides::new()) + }); assert_eq!( app.run("shortcuts.exportText", json!({"path": "/missing/dir/Studio.txt"})), Err("can't write /missing/dir/Studio.txt".to_string()) @@ -1651,7 +1831,7 @@ mod tests { let [(path, text)] = files.as_slice() else { panic!("{files:?}") }; assert_eq!(path, "/sets/Studio.txt"); assert!(text.starts_with("Keyboard Shortcuts\nSet: Studio\n\nTools\n"), "{text}"); - assert_eq!(*text, export_text("Studio", &Overrides::new())); + assert_eq!(*text, expected); } /// The dialog's Import… leaves an action its function key when the imported set gives the key @@ -1695,7 +1875,7 @@ mod tests { fn paint_dialog_rows_show_both_default_keys() { let view = DialogView::new(Overrides::new()); for (key, plain, shifted) in [("paint.lastColor", ",", "Shift+Comma"), ("paint.lastGradient", ".", "Shift+Period")] { - let at = entries().iter().position(|e| e.key == key).unwrap(); + let at = view.entries.iter().position(|e| e.key == key).unwrap(); let shown = view.texts[at].as_deref().unwrap(); let shifted = menus::pretty_shortcut(shifted); assert!(shown.contains(plain) && shown.contains(&shifted), "{key}: {shown}"); @@ -1706,7 +1886,7 @@ mod tests { #[test] fn edit_dialog_rows_show_primary_and_function_keys() { for p in [craft_keymap::Platform::Windows, craft_keymap::Platform::Linux, craft_keymap::Platform::Mac] { - let km = crate::keymap::keymap_for(p); + let km = crate::keymap::keymap_for(p, &snapshot()); for (key, primary, alternate) in [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")] { let shown = dialog_shortcuts_with(&km, key, None).unwrap(); let primary = menus::pretty_shortcut(primary); @@ -1835,7 +2015,7 @@ mod tests { #[test] fn duplicate_can_be_given_a_shortcut() { // Duplicate has no default chord (#1539): it must still be listed so one can be set. - assert_eq!(entry("edit.duplicate").map(|e| e.group.as_str()), Some("Edit")); + assert_eq!(entry("edit.duplicate").as_ref().map(|e| e.group.as_str()), Some("Edit")); let mut ov = Overrides::new(); assign(&mut ov, "edit.duplicate", Some("Cmd+Alt+Shift+F8"), false).unwrap(); assert_eq!(effective_in(&ov, "edit.duplicate").as_deref(), Some("Cmd+Alt+Shift+F8")); @@ -1883,6 +2063,53 @@ mod tests { .collect() } + /// An open dialog lists a live-effect plug-in installed while it is open, with the shortcut its + /// working set gives it, and drops the row while the plug-in is removed, keeping that shortcut + /// in the working set. + #[test] + fn an_open_dialog_follows_live_effect_plugins() { + use vectorcraft_plugins::wat as tpl; + let (plugin, key) = ("test.ui.openfx", "effect:plugin.test.ui.openfx"); + let wasm = wat::parse_str(tpl::module(&tpl::manifest(plugin, "effect", r#"{"wiggle":{"type":"bool"}}"#), tpl::ECHO, "", 1)).unwrap(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + open(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("tab".into(), json!("menu")); + d.fields.insert("query".into(), json!("test.ui.openfx")); + let mut working = Overrides::new(); + crate::keymap::set_override(&mut working, key, "Cmd+Alt+Shift+F5"); + d.fields.insert("overrides".into(), stored::to_value(&working)); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let texts = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect::>() + }; + let row = "Effect › Plug-ins › Test test.ui.openfx…"; + let shortcut = menus::pretty_shortcut("Cmd+Alt+Shift+F5"); + // The modal draws nothing in its first frame, which only sizes it. + texts(&mut app); + assert!(!texts(&mut app).iter().any(|t| t == row)); + vectorcraft_plugins::registry::install_bytes(&wasm).unwrap(); + let shown = texts(&mut app); + assert!(shown.iter().any(|t| t == row) && shown.contains(&shortcut), "{shown:?}"); + vectorcraft_plugins::registry::remove(plugin); + assert!(!texts(&mut app).iter().any(|t| t == row)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(crate::keymap::override_text(&dialog_overrides(d), key).as_deref(), Some("Cmd+Alt+Shift+F5")); + vectorcraft_plugins::registry::install_bytes(&wasm).unwrap(); + let shown = texts(&mut app); + assert!(shown.iter().any(|t| t == row) && shown.contains(&shortcut), "{shown:?}"); + vectorcraft_plugins::registry::remove(plugin); + } + /// A menu command whose params need a value (Units needs `{units}`) is not listed while it has /// no shortcut. A shortcut the user gave it keeps it listed, where it can be changed or cleared. #[test] @@ -1908,10 +2135,11 @@ mod tests { // whose params are all optional, one the menu bar runs without params (Rotate… opens its // dialog) and one that opens a dialog for its values (New Variable). let none = Overrides::new(); - let dropped: Vec<&str> = entries().iter().filter(|e| default_of(&e.key).is_some() && !listed(&none, e)).map(|e| e.key.as_str()).collect(); + let all = entries(); + let dropped: Vec<&str> = all.iter().filter(|e| default_of(&e.key).is_some() && !listed(&none, e)).map(|e| e.key.as_str()).collect(); assert!(dropped.is_empty(), "{dropped:?}"); for id in ["edit.duplicate", "object.pathfinder.unite", "object.rotate", "variable.define", "object.distributeSpacing"] { - assert!(entry(id).is_some_and(|e| listed(&none, e)), "{id}"); + assert!(entry(id).is_some_and(|e| listed(&none, &e)), "{id}"); } for (doc, required) in [ ("{}", false), @@ -1940,12 +2168,12 @@ mod tests { let others = ["swatch.unused", "graphicStyle.unused", "document.info", "view.proofSetup", "magicWand.set", "edit.findNext"]; for id in NEEDS_A_PARAM.iter().chain(&others) { let e = entry(id).unwrap_or_else(|| panic!("{id} has an entry")); - assert!(!listed(&none, e), "{id}"); + assert!(!listed(&none, &e), "{id}"); assert_eq!(default_of(id), None, "{id}"); } let mut ov = Overrides::new(); crate::keymap::set_override(&mut ov, "transparency.set", "Cmd+Alt+Shift+F10"); - assert!(entry("transparency.set").is_some_and(|e| listed(&ov, e))); + assert!(entry("transparency.set").is_some_and(|e| listed(&ov, &e))); let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); for id in ["transparency.set", "transparency.setOpacityMask"] { let list = run_command(&mut app, "shortcuts.list", &json!({ "query": id })).unwrap().unwrap(); @@ -1960,7 +2188,7 @@ mod tests { "addon.color.printFixes", "edit.findReplace", ] { - assert!(entry(id).is_some_and(|e| listed(&none, e)), "{id}"); + assert!(entry(id).is_some_and(|e| listed(&none, &e)), "{id}"); } assert_eq!(menu_target("object.path.cleanUp").map(|(id, _)| id).as_deref(), Some("ui.paramDialog")); assert_eq!( @@ -2029,8 +2257,9 @@ mod tests { assert!(app.ui.shortcut_overrides.is_empty(), "{id} {p}"); assert_eq!(app.ui.shortcut_set, set, "{id} {p}"); } - let all = app.run("shortcuts.list", json!({})).unwrap(); - assert_eq!(app.run("shortcuts.list", json!({"query": null})), Ok(all)); + let (all, null_query) = + with_steady_plugins(|| (app.run("shortcuts.list", json!({})).unwrap(), app.run("shortcuts.list", json!({"query": null})))); + assert_eq!(null_query, Ok(all)); let r = app.run("shortcuts.set", json!({"id": "object.isolate", "shortcut": "Cmd+Alt+Shift+F11", "force": null})).unwrap(); assert_eq!(r["shortcut"], "Cmd+Alt+Shift+F11"); assert!(app.run("shortcuts.preset", json!({"name": null})).unwrap_err().starts_with("unknown preset")); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 713cf5dd4..bf54c3124 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -116,11 +116,17 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json } // Effect menu items with shortcuts of their own (#3296): a chord bound to one is a command's // too, so the tool doesn't take Cmd+Enter bound to an effect before the keymap runs it. - for e in crate::shortcut_editor::effect_items() { + for e in crate::shortcut_editor::effect_items().iter() { if let Some(sc) = crate::shortcut_editor::effect_shortcut(&e.effect).and_then(parse) { v.push((sc, e.command, json!({ "effect": e.effect }))); } } + // Object › Plug-ins' filter items with shortcuts, for the same reason. + for f in crate::shortcut_editor::filter_items().iter() { + if let Some(sc) = crate::shortcut_editor::filter_shortcut(&f.id).and_then(parse) { + v.push((sc, "plugin.dialog", json!({ "id": f.id }))); + } + } // Most specific (most modifiers) first: `paste_command` takes the first V chord the held keys // match logically, so Cmd+Shift+V isn't taken by Cmd+V. v.sort_by_key(|(sc, ..)| { diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 3d40a5427..cdbdeeeba 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -557,7 +557,7 @@ fn cmd_alt_k_and_cmd_alt_shift_f_highlight_the_character_fields() { assert!(app.ui.dialog.is_none(), "not Preferences"); // Keyboard Shortcuts lists all three under Type; Highlight Tracking has no default key. for id in ["type.highlightFontName", "type.highlightKerning", "type.highlightTracking"] { - assert_eq!(crate::shortcut_editor::entry(id).map(|e| e.group.as_str()), Some("Type"), "{id}"); + assert_eq!(crate::shortcut_editor::entry(id).as_ref().map(|e| e.group.as_str()), Some("Type"), "{id}"); } assert_eq!(crate::menus::shortcut_of("type.highlightTracking"), None); } @@ -2419,7 +2419,7 @@ fn all_registered_shortcuts_parse() { /// dialog for the selection. #[test] fn an_effect_menu_item_takes_a_shortcut_of_its_own() { - let entry = crate::shortcut_editor::entries().iter().find(|e| e.key == "effect:distort.transform").expect("Transform is listed"); + let entry = crate::shortcut_editor::entry("effect:distort.transform").expect("Transform is listed"); assert_eq!(entry.group, "Effect › Distort & Transform"); assert_eq!(crate::keymap::command_of("effect:distort.transform").id, "effect.dialog"); let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); @@ -2460,6 +2460,233 @@ fn cmd_enter_bound_to_an_effect_runs_the_effect_not_the_tool() { crate::shortcut_editor::sync(&crate::state::UiState::default()); } +/// A live-effect plug-in installed after Keyboard Shortcuts built its list has a row of its own: +/// it takes a shortcut, `shortcuts.list` and its Effect › Plug-ins item show it, and the key +/// applies the effect. While the plug-in is removed it has no row and no key is bound to it; the +/// saved shortcut is the effect's again when the plug-in comes back, now opening the dialog of the +/// options it has. +#[test] +fn a_live_effect_plugin_installed_later_takes_a_shortcut_of_its_own() { + use vectorcraft_plugins::wat as tpl; + let (plugin, effect, key) = ("test.ui.keyedfx", "plugin.test.ui.keyedfx", "effect:plugin.test.ui.keyedfx"); + let install = |params: &str| { + let src = tpl::module(&tpl::manifest(plugin, "effect", params), tpl::ECHO, "", 1); + vectorcraft_plugins::registry::install_bytes(&wat::parse_str(src).unwrap()).unwrap(); + }; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let node = vectorcraft_doc::NodeId(id.as_u64().unwrap()); + let effects = + |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(node).unwrap().appearance.effects.iter().filter(|e| e.id == effect).count(); + let shown = |app: &mut VectorcraftApp| { + let list = crate::shortcut_editor::run_command(app, "shortcuts.list", &json!({ "query": effect })).unwrap().unwrap(); + list.as_array().unwrap().iter().map(|e| (e["id"].as_str().unwrap_or_default().to_string(), e["shortcut"].clone())).collect::>() + }; + let chord = "Cmd+Alt+Shift+F6"; + let f6 = || press(Key::F6, cmd() | Modifiers::ALT | Modifiers::SHIFT); + // The lists and the keymap in effect are built before the plug-in is installed. + assert!(crate::shortcut_editor::entry(key).is_none()); + crate::shortcut_editor::sync(&app.ui); + assert!(crate::shortcuts::all_shortcuts().iter().all(|(_, _, p)| p["effect"] != effect)); + install("{}"); + let e = crate::shortcut_editor::entry(key).expect("listed once installed"); + assert_eq!((e.label.as_str(), e.group.as_str()), ("Test test.ui.keyedfx", "Effect › Plug-ins")); + assert_eq!(crate::keymap::command_of(key).id, "effect.apply", "it has no options"); + let set = crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": key, "shortcut": chord})).unwrap().unwrap(); + assert_eq!(set["shortcut"], chord); + assert_eq!(shown(&mut app), vec![(key.to_string(), json!(chord))]); + assert_eq!(crate::menus::item_shortcut("effect.apply", &json!({ "effect": effect })), Some(chord)); + frame(&mut app, f6()); + assert_eq!(effects(&app), 1, "the key applies the effect"); + // Removed: no row, and no key is bound to the effect; the saved shortcut stays. + vectorcraft_plugins::registry::remove(plugin); + assert!(crate::shortcut_editor::entry(key).is_none()); + assert_eq!(shown(&mut app), vec![]); + assert!(crate::shortcuts::all_shortcuts().iter().all(|(_, _, p)| p["effect"] != effect), "no key is bound to the removed effect"); + assert!(crate::keymap::has_override(&app.ui.shortcut_overrides, key)); + // Back, now with options: the saved shortcut opens its dialog. + install(r#"{"wiggle":{"type":"bool"}}"#); + assert_eq!(crate::keymap::command_of(key).id, "effect.dialog"); + assert_eq!(shown(&mut app), vec![(key.to_string(), json!(chord))]); + assert_eq!(crate::menus::item_shortcut("effect.dialog", &json!({ "effect": effect })), Some(chord)); + frame(&mut app, f6()); + let d = app.ui.dialog.as_ref().expect("the effect's dialog opens"); + assert_eq!(d.fields.get("__effect").and_then(serde_json::Value::as_str), Some(effect), "{:?}", d.kind); + vectorcraft_plugins::registry::remove(plugin); + crate::shortcut_editor::sync(&crate::state::UiState::default()); +} + +/// Each filter plug-in's Object › Plug-ins item has a row of its own (`plugin:`), with no +/// default shortcut, even when two filters share a name: a key given to one shows in its menu item +/// and opens that filter's dialog, and the other stays unbound. While the plug-in is removed it +/// has no row and no key is bound to it; the saved shortcut is the filter's again when it comes +/// back, now running it at once since it has no parameters. +#[test] +fn each_filter_plugin_takes_a_shortcut_of_its_own() { + use serde_json::Value; + use vectorcraft_plugins::wat as tpl; + let (one, two) = ("test.UI.key_filter-one", "test.UI.key_filter-two"); + let (key_one, key_two) = (format!("plugin:{one}"), format!("plugin:{two}")); + let install = |id: &str, name: &str, params: &str| { + let manifest = format!(r#"{{"id":"{id}","name":"{name}","version":"1","kind":"filter","params":{params}}}"#); + vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap(); + }; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let shown = |app: &mut VectorcraftApp| { + let list = crate::shortcut_editor::run_command(app, "shortcuts.list", &json!({ "query": "test.ui.key_filter-" })).unwrap().unwrap(); + list.as_array().unwrap().iter().map(|e| (e["id"].as_str().unwrap_or_default().to_string(), e["shortcut"].clone())).collect::>() + }; + let chord = "Cmd+Alt+Shift+F7"; + let f7 = || press(Key::F7, cmd() | Modifiers::ALT | Modifiers::SHIFT); + crate::shortcut_editor::sync(&app.ui); + install(one, "Same Name", "{}"); + install(two, "Same Name", r#"{"wiggle":{"type":"bool"}}"#); + let rows: Vec<_> = [&key_one, &key_two].iter().map(|k| crate::shortcut_editor::entry(k).map(|e| (e.label, e.group, e.is_tool))).collect(); + let group = "Object › Plug-ins".to_string(); + assert_eq!(rows, [Some(("Same Name".into(), group.clone(), false)), Some(("Same Name…".into(), group.clone(), false))]); + for key in [&key_one, &key_two] { + assert_eq!(crate::shortcut_editor::default_of(key), None, "no default shortcut"); + let command = crate::keymap::command_of(key); + assert_eq!(command.id, "plugin.dialog"); + assert_eq!(crate::keymap::entry_key(&command), *key, "the id is kept whole"); + assert_eq!(crate::keymap::entry_of_stored(&crate::keymap::stored_key(key)), *key); + } + assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null), (key_two.clone(), Value::Null)]); + let set = crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": key_two, "shortcut": chord})).unwrap().unwrap(); + assert_eq!(set["shortcut"], chord); + assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null), (key_two.clone(), json!(chord))]); + assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": two })), Some(chord)); + assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": one })), None); + let text = crate::shortcut_editor::export_text("Custom", &app.ui.shortcut_overrides); + let lines: Vec<&str> = text.lines().filter(|l| l.starts_with("Object › Plug-ins › Same Name")).collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert_eq!(lines[0], "Object › Plug-ins › Same Name\t"); + assert!(lines[1].starts_with("Object › Plug-ins › Same Name…\t") && lines[1].ends_with("+Alt+Shift+F7"), "{}", lines[1]); + // A chord an Effect menu item holds is a conflict. + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "effect:distort.transform", "Cmd+Alt+Shift+F8"); + let conflicts = crate::shortcut_editor::conflicts_with(&app.ui.shortcut_overrides, "Cmd+Alt+Shift+F8", &key_one); + assert_eq!(conflicts, vec!["effect:distort.transform".to_string()]); + crate::keymap::remove_override(&mut app.ui.shortcut_overrides, "effect:distort.transform"); + frame(&mut app, f7()); + let d = app.ui.dialog.take().expect("the filter's dialog opens"); + assert_eq!((d.kind.as_str(), d.str("__plugin")), ("plugin", two.to_string())); + let bound = |app: &VectorcraftApp| { + crate::keymap::vc_chord(chord).and_then(|c| crate::keymap::lookup(&crate::keymap::keymap_with(&app.ui.shortcut_overrides), &c)) + }; + assert_eq!(bound(&app).map(|c| crate::keymap::entry_key(&c)), Some(key_two.clone())); + // Removed: no row, and no key is bound to the filter; the saved shortcut stays. + vectorcraft_plugins::registry::remove(two); + assert_eq!(bound(&app), None, "the key runs nothing"); + assert!(crate::shortcut_editor::entry(&key_two).is_none()); + assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null)]); + assert!(crate::shortcuts::all_shortcuts().iter().all(|(_, _, p)| p["id"] != two), "no key is bound to the removed filter"); + assert!(crate::keymap::has_override(&app.ui.shortcut_overrides, &key_two)); + // Back under another name and without parameters: the saved shortcut runs it at once. + install(two, "Renamed", "{}"); + assert_eq!(crate::shortcut_editor::entry(&key_two).map(|e| e.label), Some("Renamed".into())); + assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null), (key_two.clone(), json!(chord))]); + let undo = app.session.doc().unwrap().history.undo.len(); + frame(&mut app, f7()); + assert!(app.ui.dialog.is_none(), "no dialog: {:?}", app.ui.dialog.as_ref().map(|d| &d.kind)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "the filter ran as one undo step"); + vectorcraft_plugins::registry::remove(one); + vectorcraft_plugins::registry::remove(two); + crate::shortcut_editor::sync(&crate::state::UiState::default()); +} + +/// A filter plug-in replaced under its id, or reinstalled from a folder by Reload Plug-ins, keeps +/// its shortcut, which runs the plug-in as it is now. With no document open the key does nothing, +/// as the dimmed menu item does. +#[test] +fn a_filter_plugin_keeps_its_shortcut_when_replaced_or_reloaded() { + use vectorcraft_plugins::wat as tpl; + let id = "test.UI.replaced_filter"; + let key = format!("plugin:{id}"); + let wasm = |name: &str, params: &str| { + let manifest = format!(r#"{{"id":"{id}","name":"{name}","version":"1","kind":"filter","params":{params}}}"#); + wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap() + }; + let with_params = r#"{"wiggle":{"type":"bool"}}"#; + let chord = "Cmd+Alt+Shift+F6"; + let f6 = || press(Key::F6, cmd() | Modifiers::ALT | Modifiers::SHIFT); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + vectorcraft_plugins::registry::install_bytes(&wasm("Original", "{}")).unwrap(); + crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": key, "shortcut": chord})).unwrap().unwrap(); + + vectorcraft_plugins::registry::install_bytes(&wasm("Replaced", with_params)).unwrap(); + assert_eq!(crate::shortcut_editor::entry(&key).map(|e| e.label), Some("Replaced…".into())); + assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": id })), Some(chord)); + frame(&mut app, f6()); + assert_eq!(app.ui.dialog.take().expect("the replaced filter has parameters").str("__plugin"), id); + + let folder = std::env::temp_dir().join(format!("vectorcraft-ui-filter-shortcut-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("filter.wasm"), wasm("Reloaded", with_params)).unwrap(); + app.run("plugin.reload", json!({ "path": folder.display().to_string() })).unwrap(); + std::fs::remove_dir_all(&folder).ok(); + assert_eq!(crate::shortcut_editor::entry(&key).map(|e| e.label), Some("Reloaded…".into())); + assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": id })), Some(chord)); + + let mut empty = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + empty.ui.shortcut_overrides = app.ui.shortcut_overrides.clone(); + frame(&mut empty, f6()); + assert!(empty.ui.dialog.is_none(), "no document: no dialog"); + vectorcraft_plugins::registry::remove(id); + crate::shortcut_editor::sync(&crate::state::UiState::default()); +} + +/// The key of a heavy filter does what a click on its item does: one without parameters starts in +/// the background, and one with parameters opens its dialog, which has no Preview. +#[test] +fn a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item() { + use vectorcraft_plugins::wat as tpl; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let (at_once, asks) = ("test.UI.heavy_filter-at-once", "test.UI.heavy_filter-asks"); + for (id, params, chord) in [(at_once, "{}", "Cmd+Alt+Shift+F7"), (asks, r#"{"wiggle":{"type":"bool"}}"#, "Cmd+Alt+Shift+F8")] { + let manifest = format!(r#"{{"id":"{id}","name":"Heavy {id}","version":"1","kind":"filter","budget":"heavy","params":{params}}}"#); + let wasm = wat::parse_str(tpl::spinning(&manifest, 1 << 40, r#"{"objects":[]}"#)).unwrap(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "allowHeavy": true})).unwrap(); + crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": format!("plugin:{id}"), "shortcut": chord})).unwrap().unwrap(); + } + + frame(&mut app, press(Key::F7, cmd() | Modifiers::ALT | Modifiers::SHIFT)); + assert_eq!(app.plugin_runs.running.len(), 1, "started in the background"); + assert_eq!(app.ui.status, format!("Running “Heavy {at_once}”…")); + + frame(&mut app, press(Key::F8, cmd() | Modifiers::ALT | Modifiers::SHIFT)); + let d = app.ui.dialog.take().expect("the heavy filter with parameters opens its dialog"); + assert_eq!(d.str("__plugin"), asks); + assert!(d.fields.get("preview").is_none(), "no Preview"); + + assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1); + vectorcraft_plugins::registry::remove(at_once); + vectorcraft_plugins::registry::remove(asks); + crate::shortcut_editor::sync(&crate::state::UiState::default()); +} + +/// Keyboard Shortcuts translates a menu path one segment at a time and the label of a built-in +/// item. A filter plug-in's name stays as the plug-in gives it, even when it reads like a command. +#[test] +fn a_filter_plugin_row_translates_its_path_and_not_its_name() { + let zh = crate::i18n::Lang::from_code("zh-hant").unwrap(); + crate::i18n::set_current(zh); + let entry = + |key: &str| crate::shortcut_editor::Entry { key: key.into(), label: "Group".into(), group: "Object › Plug-ins".into(), is_tool: false }; + let path = format!("{} › {}", crate::i18n::tr(zh, "Object"), crate::i18n::tr(zh, "Plug-ins")); + let (filter, command) = (crate::shortcut_editor::shown_name(&entry("plugin:a.b")), crate::shortcut_editor::shown_name(&entry("object.group"))); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert_ne!(path, "Object › Plug-ins"); + assert_eq!(filter, format!("{path} › Group")); + assert_eq!(command, format!("{path} › {}", crate::i18n::tr(zh, "Group"))); +} + /// #2898 review: Up and Down change the corner kind only while a corner widget is pressed. With /// none pressed, a plain Up still nudges the selected rectangle and leaves its corners alone; with /// one pressed, Up steps the kind and the rectangle stays where it is. diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index fdd0798a8..c1e3f822c 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -417,6 +417,7 @@ fn keyboard_shortcuts_export_text_writes_the_working_set_as_text() { Ok(()) })); let ov = crate::keymap::overrides_from(&[("view.grid", "Ctrl+Alt+G")]); + let revision = crate::menus::plugin_revision(); app.run("edit.keyboardShortcuts", json!({})).unwrap(); let d = app.ui.dialog.as_mut().expect("Keyboard Shortcuts"); d.fields.insert("overrides".into(), crate::keymap::stored::to_value(&ov)); @@ -431,7 +432,11 @@ fn keyboard_shortcuts_export_text_writes_the_working_set_as_text() { let texts = texts.borrow(); let [(path, text)] = texts.as_slice() else { panic!("{texts:?}") }; assert_eq!(path, "/keys/list.txt"); - assert_eq!(*text, crate::shortcut_editor::export_text("Custom", &ov)); + // Another test installing or removing a plug-in meanwhile changes the Effect menu's rows. + if crate::menus::plugin_revision() == revision { + assert_eq!(*text, crate::shortcut_editor::export_text("Custom", &ov)); + } + assert!(text.starts_with("Keyboard Shortcuts\nSet: Custom\n"), "{text}"); assert!(text.contains(&format!("\t{}\n", crate::menus::pretty_shortcut("Ctrl+Alt+G"))), "{text}"); assert!(app.ui.shortcut_overrides.is_empty(), "the working set is kept only on OK"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("shortcuts"), "the dialog stays open"); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fcd8da020..8cdb6d325 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -219,7 +219,8 @@ 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, 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 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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) 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; 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. @@ -231,9 +232,9 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic 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 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). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic 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 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). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. @@ -248,9 +249,9 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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 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). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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 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). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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 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). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), 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. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. diff --git a/docs/gaps.md b/docs/gaps.md index 862abd09c..2c5e75db8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -187,7 +187,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users -- **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path comes from VectorCraft's font metrics, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. +- **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. - **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. diff --git a/docs/mcp.md b/docs/mcp.md index 90ba11e28..a234b3f57 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -696,8 +696,8 @@ effect colors that new art takes; Undo restores their original values and Redo r colors of 3D effects stay RGB. Gray colors stay Gray and print on the black plate only. RGB grays are colors like any -other: to CMYK they separate through the profile into four-colour greys and a rich black, as Illustrator converts -them. `grays: "black"` puts RGB greys with R = G = B on the black plate instead, K = their grey value (the inks Edit Colors › +other: to CMYK they separate through the profile into light grays in cyan, magenta and yellow only, darker +four-color grays and a rich black. `grays: "black"` puts RGB greys with R = G = B on the black plate instead, K = their grey value (the inks Edit Colors › Convert to Grayscale before the switch would give); other colors convert as usual. The reply's `changed` counts the colors that changed in the art, symbols, graph designs and pattern tiles; it does not count the colors of graphic styles and brushes. @@ -3656,6 +3656,13 @@ 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. +Each installed filter plug-in's Object › Plug-ins item is its own row, with id `plugin:` (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:", shortcut}`, `shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line +for the row under its menu path and label, and the key runs what a click on the item runs, `plugin.dialog {id}`. A +removed plug-in's row goes away and its key runs nothing; its shortcut stays saved and applies again when a plug-in +with the same id is installed. + `shortcuts.exportText {path?}` saves the current set's shortcut list as a UTF-8 text file, as Edit › Keyboard Shortcuts › Export Text… does with the dialog's set as edited so far. The file starts with a "Keyboard Shortcuts" line and a "Set:" line with the set's name, followed by a Tools section and a Menu Commands section. Each section lists the @@ -4595,6 +4602,14 @@ its file was found. Only files on this computer, or on the network share the doc on, are looked at: a path to another computer, a device or a URL counts as missing. On Windows, a document opened from a mapped drive (`Z:\…`) is on the share the drive is mapped to, and links on that share (`\\server\share\…`) are looked at. A link on a drive mapped to another share (`Y:\…`) counts as missing, from a document on this computer too. +The links of an open document keep their relative paths from the file it was opened from (for a `.ai` file another +app wrote, that file), also after a save to another folder. When a link's file is not at its path, a save and Package +write the path relative to the new file of the place the file is found, or for a missing file of the place that +relative path names, and a document made with New Document from State keeps the same places, so a document moved while +its linked files were missing finds them once they are back in that folder. In the open document, a link +is looked for at its path, then at its relative path from the folder of the file the document was opened from and from +the document's own folder, then by name in those two folders. A link found away from its path takes the relative path +of the place it was found. The result lists `missingLinks` (their images show the preview), `modifiedLinks` (left as they were; read again only with the preference `updateLinks: "automatically"`, then they are in `updatedLinks`), each as `{name, path, ids}`, with `refused` (why the @@ -5825,6 +5840,53 @@ dialog open. {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":4,"tateChuYoko":true,"tcyUpDown":2,"tcyLeftRight":-1}}} ``` +## Composite Fonts + +A composite font sets each class of character in a font of its own +([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). +`type.compositeFonts.set {fonts: [{name, parts}]}` replaces the active document's composite fonts as one undo step, +and adds no step when nothing changes. `parts` holds `kanji` (the base font), `kana`, `punctuation`, `symbols`, `roman` +and `numerals`, each `{font, style?, size?, baseline?, hScale?, vScale?, scaleAtCenter?}`; a part left out copies the +`kanji` part. Which characters fall in each class is VectorCraft's own rule: Kanji takes ideographs, full-width +letters and digits and every character no other class takes; Kana takes hiragana and katakana of both widths; +Punctuation takes East Asian and full-width punctuation, quotes and dashes; Symbols takes arrows, math, shapes and +other symbols; Roman takes half-width letters, their punctuation and spaces; Numerals takes the digits 0 to 9. + +`size` (10 to 400, 100 by default) and `baseline` (-100 to 100, 0 by default; positive raises) are percentages of the +type's size, VectorCraft's own ranges. `hScale` and `vScale` (10 to 400, 100 by default) scale Kana, Roman and +Numerals only, and `scaleAtCenter` (Kana only) scales kana from the center of the em box instead of the baseline. A +value out of range, a scale on another class, an unknown class or key, a name that is empty, longer than 255 +characters, shared by two fonts (in any case) or the name of an installed font, and a part set in a composite font are +each refused with an error, and nothing changes. A document keeps at most 256. + +Type set in a composite font names it as its family (`text.setStyle {font: "My Composite"}`), and the font menus list +composite fonts first. The layout sets each character in its class's font, a letter and its combining marks together. +A composite font taken out of the document leaves its type naming it, and the type shows as missing. Composite fonts +are saved with the document. SVG and legacy `.ai` write type in one of the document's composite fonts as outlines, and +SVG, PDF, EPS and legacy `.ai` export draw it in the font's parts also when the document was read without an open app. +`type.compositeFonts.list` returns the document's composite fonts. + +`text.replaceFont` and `addon.cleanup.replaceFonts` with a composite font as the new font set the type in it by its own +name, and the type keeps its style. `text.fonts` lists a composite font by its name; its `status` is the worst among the +parts its characters are drawn in, and `missingGlyphs` counts the characters that their part's font lacks. +`text.missingFonts`, File › Package and the warnings about fonts that aren't available name the part fonts that the +characters are drawn in. `text.glyphAlternates` lists the alternates of a character from its part's font. + +`type.compositeFonts.export {path?, names?, fonts?}` writes the document's composite fonts (all, or those `names` +lists), or the `fonts` given, to a `.vccomposite` file; without `path` it returns the file's text as `data`. +`type.compositeFonts.import {path | data | dataBase64, apply?}` reads a file of at most 4 MB. With `apply` (the +default) its fonts join the document's, replacing a font of the same name, as one undo step; with `apply: false` it +only returns them. Opening a `.vccomposite` file imports it. + +Type › Composite Fonts… (shown with Show East Asian Options) opens the Composite Fonts dialog on the document's fonts: +Font picks one, and a table sets each class's font, style, size, baseline and scales. New adds a font based on the one +picked, Delete Font takes it out, Import… adds the fonts of a file and Export… writes the one picked. OK runs +`type.compositeFonts.set` with every font as one undo step; a refused font keeps the dialog open. + +```json +{"name":"run_command","arguments":{"command":"type.compositeFonts.set","params":{"fonts":[{"name":"My Composite","parts":{"kanji":{"font":"Source Han Sans"},"roman":{"font":"Source Sans 3","size":95,"baseline":-2},"numerals":{"font":"Source Sans 3"}}}]}}} +``` + ## Optical Margin Alignment `type.opticalMarginAlignment {on?: bool}` (Type › Optical Margin Alignment) turns Optical Margin Alignment on or off @@ -6207,8 +6269,12 @@ Type on a path flows between a start and an end bracket, stored as fractions of Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); `flip` then turns the type to the other side of its path, the path running the other way and the brackets swapping ends so the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the -default) along the path; `spacing` (points) closes glyphs up round the outside of curves and opens them up round the -inside. With none of them it queries the first selected type on a path: +default) along the path, each character by its own font, size and vertical scale: `ascender` runs the top of the +font's em box along it (its ideographic em box, else an em box centered on the cap height), `descender` runs the +bottom of the font's bounding box along it, and `center` runs a line at half the height of the top of the font's H +above the baseline along it (in vertical type, `ascender` and `descender` move the characters half an em with their +horizontal scale, and `center` leaves them on the path); `spacing` (points) closes glyphs up round the outside of +curves and opens them up round the inside. With none of them it queries the first selected type on a path: `{start, end, flip: false, effect, alignToPath, spacing}`. The Options dialog shows Effect, Flip, Align to Path and Spacing and leaves the brackets where they are. As in the reference app, the Selection and Direct Selection tools show selected type on a path's brackets: dragging the start or end bracket (`pointer_gesture`) sets where the type diff --git a/docs/plugins.md b/docs/plugins.md index 93d95d026..cb728ea17 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -56,7 +56,9 @@ In the app, installed filters appear under **Object › Plug-ins**, next to **In Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns, text, lists of numbers and colors, labelled, explained and sorted into sections as its manifest says) with a live preview; without -parameters it runs at once. Parameters too many for the window's height scroll inside the dialog, which keeps its +parameters it runs at once. Edit › Keyboard Shortcuts lists each installed filter under Object › Plug-ins, where +it can be given a shortcut (`plugin:`); the shortcut stays saved while the plug-in is removed and applies again +when it is reinstalled. Parameters too many for the window's height scroll inside the dialog, which keeps its heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 998c156cb..3bfb96a9b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -209,7 +209,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path comes from VectorCraft's font metrics, and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | @@ -549,7 +549,7 @@ since 2026-10-01. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. -- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. +- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. @@ -1060,6 +1060,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them. New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. + Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. @@ -1158,7 +1159,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels. - **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel. - **Swatch library and Recolor Artwork parameters:** the `swatch.library.*` commands, `swatch.resetDefaults`, the graphic style library save, load and add, the `recolor.*` commands and `color.harmony`'s `limitTo` refuse parameters of the wrong form, such as `names: "Grays"`, `limitTo: 5` or `includeImages: "false"`. Errors name the parameter, except that a `limitTo` that is not a string, such as `5`, gets an unknown-library error. A `null` counts as left out except for `recolor.apply`'s `model`, which must be a string when given. - - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). + - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB grays separate into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them. @@ -1176,8 +1177,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Pasting into hex color fields:** in the hex fields of the Color Picker, Swatch Options, Recolor Artwork, the Color panel, Document Setup, Duotone, Color Themes and Perspective Grid, a click or keyboard focus selects the digits. A pasted code of six digits, with or without `#`, replaces them wherever the caret is. A pasted code of three digits also replaces them, unless the paste holds only those digits and exactly three digits stay; those digits then edit the text in place. A press on another field commits the text as Tab and Enter do. As VectorCraft's own rule, a paste that is not a color leaves the field as it was. Tests: `a_hex_code_pasted_into_the_hex_field_replaces_its_digits` in `crates/ui-egui/src/dialogs/tests.rs`, `define_grid_takes_a_three_digit_color` in `crates/ui-egui/src/dialogs/tests_perspective.rs` and `a_paste_into_a_hex_field_replaces_or_edits_its_digits` in `crates/ui-egui/src/tests/numfields.rs`. - **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates. - **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement. - - **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13. - - **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C59 M12 Y3 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only. + - **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C62 M19 Y0 K0, where the profile-free formula gave C63 M24 Y0 K13. + - **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C62 M19 Y0 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only. + - **Generic CMYK (SWOP-like):** each ink's 40% and 50% tints gain as [CGATS TR 001](https://registry.color.org/cmyk-registry/cgats_tr_001) gives for SWOP printing, and separations stay within SWOP's [300% total ink limit](https://en.wikipedia.org/wiki/Specifications_for_Web_Offset_Publications). The paper, solid and overprint colors, the black-generation curve and the black point are VectorCraft's own values: RGB black separates to 90% black under neutral cyan, magenta and yellow that fill the ink limit (C75 M68 Y67 K90). - **Swatches made from a shown color in a CMYK document:** the Color panel menu's Create New Swatch, an Alt-click on the Swatches panel's New Swatch button, the New Swatch dialog for a gradient, a Fill or Stroke proxy or the Gradient panel's thumbnail dropped on the Swatches panel (or Alt-dropped on a swatch of its kind), and the Gradient panel's Add to Swatches and Save to Swatches keep each color's model, as the Color Guide's and Color Themes' saved colors do: an RGB object's color gives an RGB swatch, and a gradient's RGB stops stay RGB. - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there. - **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/it/stroke_visibility.rs`). @@ -1391,6 +1393,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. - The Links panel's Relink… opens a Relink dialog whose Replace menu picks which links take the new file: Selected Instances (the selected images, as before), All Instances (every image linked to the same file), Missing Links from This Folder (then every other missing link whose file name is in the new file's folder) and Each Link Separately (a file for each selected link in turn). Each relinks as one undo step (`links.relink {scope}`, `ui.relinkDialog`). Relink to Folder… stays as it is. - Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`. + - A linked file that is missing when a moved document is saved keeps the folder its relative path names, instead of a relative path worked out from the out-of-date absolute path, also for a `.ai` file another app wrote (such as `src/`), so the file is found once it is back in that folder. Evidence: `a_missing_link_keeps_its_relative_path_when_the_moved_document_is_saved`, `links_of_an_imported_file_are_found_by_their_relative_folder`, `a_missing_link_of_a_moved_document_keeps_its_relative_place`. - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed. - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. @@ -1811,6 +1814,10 @@ Cmd/Ctrl+Alt+. (`stroke.weightUp`) and Cmd/Ctrl+Alt+, (`stroke.weightDown`) rais Edit › Keyboard Shortcuts lists every item of the Effect menu under its menu path (Effect › Distort & Transform › Transform…) and gives each one a shortcut of its own (stored as `effect:`; they all run `effect.dialog {effect}`, or `effect.apply` for an effect without options, so a shortcut on the command alone couldn't tell them apart). The menu shows the shortcut, and pressing it opens that effect's dialog for the selection (#3296). Test: `shortcuts::tests::an_effect_menu_item_takes_a_shortcut_of_its_own`. +A live-effect plug-in installed while the app runs gets its Effect › Plug-ins row in Edit › Keyboard Shortcuts, in `shortcuts.list` and in a Keyboard Shortcuts dialog that is already open, and its key runs it. A removed plug-in's row goes away and its key runs nothing; the shortcut stays saved and applies again when a plug-in with the same id is installed, opening the effect's dialog if the new one has options and applying the effect if it has none. Tests: `shortcuts::tests::a_live_effect_plugin_installed_later_takes_a_shortcut_of_its_own`, `shortcut_editor::tests::an_open_dialog_follows_live_effect_plugins`. + +Each installed filter plug-in's Object › Plug-ins item has a row of its own (stored as `plugin:`, since every filter runs `plugin.dialog {id}`), with no default shortcut, also when two filters share a name. The menu shows its shortcut, and pressing it does what a click on the item does: it opens the filter's dialog, or runs a filter without parameters at once as one undo step (a heavy filter starts in the background). A removed filter's row goes away and its key runs nothing; the shortcut stays saved and applies again when a plug-in with the same id is installed. With no document open the key does nothing, as the dimmed item does. The command palette has one result per filter, and Import Shortcut Set offers each filter as a target without matching a row to it by name. Plug-in names stay as the plug-in gives them; the menu path is translated. Tests: `shortcuts::tests::each_filter_plugin_takes_a_shortcut_of_its_own`, `shortcuts::tests::a_filter_plugin_keeps_its_shortcut_when_replaced_or_reloaded`, `shortcuts::tests::a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item`, `shortcuts::tests::a_filter_plugin_row_translates_its_path_and_not_its_name`, `palette::tests::each_filter_plugin_is_a_result_of_its_own`, `dialogs::tests_shortcut_import::an_imported_shortcut_reaches_a_filter_plugin_picked_as_its_target`. + ## Live Corners: arrow keys change the corner kind while dragging (2026-11-01) While a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the kind of the corners being edited (Round, Inverted Round, Chamfer, in turn, from the pressed corner's), shown live with the radius and kept, with it, as one undo step when the drag ends; a press with an arrow and no drag changes just the kind (#2898). Alt-clicking a widget still cycles it as before. Test: `tests::livecorners::arrows_while_dragging_a_corner_widget_change_the_corner_kind`. diff --git a/docs/type-parity.md b/docs/type-parity.md index dcf0eeaed..b7e7fffab 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -68,6 +68,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: East Asian Type units (Q, Ha) | done | Preferences › Units › East Asian Type (`unitsAsianType`, Points by default) offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`). The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | CJK: manual tate-chu-yoko | done | Character panel menu › Tate-chu-yoko (`text.setFormat` / `text.setRangeStyle {tateChuYoko}`), with Show East Asian Options: marked characters stand side by side across one em of a vertical column, squeezed to the em when wider; two or three half-width digits still do so without the mark; horizontal type keeps the mark and does not move them. Tate-chu-yoko Settings… (`ui.tateChuYokoSettings`, `{tcyUpDown, tcyLeftRight}` in points) moves a marked block up or down and right or left as one piece. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `tests_vertical::tate_chu_yoko_settings_move_a_marked_block_up_down_left_and_right`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on`, `typecmd::tate_chu_yoko_settings_are_character_attributes_saved_only_when_set`, `dialogs::tate_chu_yoko::tests` | | CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | +| CJK: composite fonts | partial | Type › Composite Fonts… (`type.compositeFonts.set`), with Show East Asian Options: a font made of a font per class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals), each with its own style, size and baseline in percent of the type's size, Kana, Roman and Numerals with horizontal and vertical scale, and Kana with Scale at Center; New, Delete Font, Import… and Export… (`.vccomposite`, `type.compositeFonts.import` and `.export`) ([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per class; SVG and legacy `.ai` write it as outlines. Not yet: custom character sets, the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `compositefonts::tests`, `dialogs::composite_fonts::tests` | | CJK: ruby | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`) | #633 | | Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Assamese and Sanskrit added, #4947; Serbian: Cyrillic, Serbian: Latin and Croatian); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). English and eleven Indian languages hyphenate by run language; Sanskrit automatic hyphenation awaits allowlisted patterns (#4947). Not yet: spelling by it, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set`, `tests::typo::indic_language_runs_hyphenate_with_both_composers`, `hyphen::tests::sanskrit_without_allowlisted_patterns_uses_only_exceptions` | | Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Serbian: Cyrillic, Serbian: Latin and Croatian; Bulgarian, #5945); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). Not yet: hyphenation and spelling by it, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set` |