diff --git a/Cargo.lock b/Cargo.lock index 9fd8db586..ee3905a8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6384,6 +6384,7 @@ dependencies = [ name = "vectorcraft-js" version = "0.21.0" dependencies = [ + "chrono", "proptest", "unicode-general-category", ] diff --git a/ROADMAP.md b/ROADMAP.md index 504324eaf..1bedf0b95 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,7 @@ - Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. - Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged. +- UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. - PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document. - PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned. diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs index f03572ebb..c57ab6b58 100644 --- a/apps/vectorcraft/src/prefs.rs +++ b/apps/vectorcraft/src/prefs.rs @@ -168,6 +168,11 @@ fn same_lasting(a: &serde_json::Value, b: &serde_json::Value) -> bool { } } +/// Fields that a read fills in from other fields when the file lacks them +/// (`vectorcraft_ui_egui::layout_migration`): `help_bar` follows `status_bar`. A save writes them +/// whenever the file lacks them, so the next read does not fill them in again from a changed field. +const DERIVED: &[&str] = &["help_bar"]; + /// `on_disk` with what changed from `base` to `mine` put over it: each top-level field of the UI /// state, and each engine preference (`engine_prefs`) on its own, so two windows that change /// different settings keep both. A field both changed takes this window's value (it saves last). @@ -219,6 +224,13 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se } } } + for key in DERIVED { + if !out.contains_key(*key) + && let Some(v) = mine.get(*key) + { + out.insert((*key).to_string(), v.clone()); + } + } if use_mine { match mine.get("action_set") { Some(v) => { @@ -383,6 +395,20 @@ mod tests { assert!(!same_lasting(&base, &mine)); } + /// A file from before the Help Bar setting: the read shows the Help Bar with the status bar. + /// After the status bar is hidden and the Help Bar left showing, the save writes `help_bar`, + /// so the next read does not hide it with the status bar. A value on disk is kept. + #[test] + fn a_help_bar_setting_the_file_lacks_is_saved() { + let base = json!({"status_bar": true, "help_bar": true}); + let mine = json!({"status_bar": false, "help_bar": true}); + let on_disk = json!({"status_bar": true}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"status_bar": false, "help_bar": true})); + // Another window saved its own Help Bar setting: it stays. + let on_disk = json!({"status_bar": true, "help_bar": false}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"status_bar": false, "help_bar": false})); + } + /// One window deleted the first Actions set and saved; this one made the second set current. /// The saved index follows that set to its place in the merged list. #[test] diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 72fa4f7b3..45ed99299 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1227,10 +1227,10 @@ impl<'a> Scene<'a> { let Some(&first) = run.first() else { continue }; let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte); let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo); - // A right-to-left paragraph's marker is drawn with its tab first and its number's - // period at the number's left; the TEXT entity holds the characters in that order. + // A right-to-left paragraph's marker is drawn with its number's period at the + // number's left; the TEXT entity holds the characters in that order. let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') { - Some(number) if m.tab == m.glyphs.start => format!(".{number}"), + Some(number) if layout.glyphs.get(m.tab).is_some_and(|g| g.rtl) => format!(".{number}"), _ => m.text.clone(), }); let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 5c57fa634..90e4cbc2c 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1076,15 +1076,15 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() { /// A numbered list paragraph's number and period are a TEXT entity of their own where they are /// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the -/// number's left. +/// number's left, with the marker aligned left or right. #[test] fn a_number_is_text_of_its_own_before_its_paragraph() { - use vectorcraft_doc::text::{NumberStyle, ParaList}; - let numbered = |text: &str, count: u32| { + use vectorcraft_doc::text::{ListAlign, NumberStyle, ParaList}; + let numbered_aligned = |text: &str, count: u32, align: ListAlign| { let mut d = text_doc(text, 20.0); let id = d.layers[0].children().unwrap()[0].id; if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { - t.para.list = Some(ParaList::numbered(NumberStyle::Arabic)); + t.para.list = Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); t.para.left_indent = 30.0; t.para.first_line_indent = -30.0; t.list_count = count; @@ -1092,10 +1092,27 @@ fn a_number_is_text_of_its_own_before_its_paragraph() { let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::>() }; + let numbered = |text: &str, count: u32| numbered_aligned(text, count, ListAlign::Left); assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]); let rtl = numbered("שלום", 11); assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::>(), ["שלום", ".12"]); assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}"); + // Aligned right, the number keeps its order and moves right, against the text. + let right = numbered_aligned("Item", 11, ListAlign::Right); + assert_eq!((right[0].0.as_deref(), right[1].clone()), (Some("12."), (Some("Item".to_string()), 40.0))); + assert!(right[0].1 > 10.0 && right[0].1 < 40.0, "{right:?}"); + let rtl = numbered_aligned("שלום", 11, ListAlign::Right); + assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::>(), ["שלום", ".12"]); + // A mixed paragraph: the period follows the paragraph's direction, not the script of the + // words it holds, with either alignment. + let marker = |text: &str, align: ListAlign| { + let out = numbered_aligned(text, 11, align); + out.into_iter().filter_map(|(s, _)| s).find(|s| s.contains("12")).unwrap_or_default() + }; + for align in [ListAlign::Left, ListAlign::Right] { + assert_eq!(marker("Item שלום", align), "12.", "{align:?}"); + assert_eq!(marker("שלום Item", align), ".12", "{align:?}"); + } } /// Area type that Shrink Text to Fit scales down is written at the height it draws at. diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index bc44b7bf4..d25be2c41 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -19,7 +19,7 @@ use std::f64::consts::PI; use std::sync::Arc; use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; -use vectorcraft_geom::kurbo::ParamCurveArclen; +use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; @@ -904,9 +904,12 @@ pub struct Spine { cum: Vec, anchors: Vec, closed: bool, + path: PathData, } impl Spine { + /// Build a spine from the first subpath of `path`: flatten it to a polyline, record cumulative + /// arc lengths, and the arc length at each anchor. pub fn new(path: &PathData) -> Option { let sp = path.subpaths.first()?; let mut pts: Vec = Vec::new(); @@ -943,8 +946,9 @@ impl Spine { if pts.len() < 2 { return None; } - Some(Self { pts, cum, anchors, closed: sp.closed }) + Some(Self { pts, cum, anchors, closed: sp.closed, path: path.clone() }) } + /// The spine's flattened length in arc length. pub fn length(&self) -> f64 { *self.cum.last().unwrap_or(&0.0) } @@ -1002,20 +1006,32 @@ impl Spine { pub fn at(&self, f: f64) -> (Point, f64) { self.at_length(f.clamp(0.0, 1.0) * self.length()) } - /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). + /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). The point and + /// tangent come from the original curve at that arc length, not from the flattened polyline: a + /// flattened chord's direction is piecewise constant, so steps oriented to a curved spine with + /// `Align to Path` jump between chords instead of following the curve smoothly. pub fn at_length(&self, s: f64) -> (Point, f64) { - let last = self.pts.len().saturating_sub(2); let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 }; - let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) { - Ok(i) => i.min(last), - Err(i) => i.saturating_sub(1).min(last), - }; - let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else { - return (self.pts.first().copied().unwrap_or_default(), 0.0); - }; - let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0); - let d = *p1 - *p0; - (p0.lerp(*p1, u), d.y.atan2(d.x)) + let Some(sp) = self.path.subpaths.first() else { return (self.pts.first().copied().unwrap_or_default(), 0.0) }; + let n = sp.segment_count(); + let mut start = 0.0; + for i in 0..n { + let c = sp.segment(i); + let end = self.anchor_length(i + 1).unwrap_or(self.length()); + if s <= end || i + 1 == n { + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); + let e = c.eval(t); + let tv = c.deriv().eval(t).to_vec2(); + // Where a handle sits on its anchor the derivative vanishes there: take the + // direction between points a little before and after instead. + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; + return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); + } + start = end; + } + // Only a spine without segments gets here: the loop returns at the last one. + (self.pts.first().copied().unwrap_or_default(), 0.0) } } @@ -1446,6 +1462,45 @@ mod tests { assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); } + #[test] + fn orientations_follow_a_curved_spine_smoothly() { + // A curved spine: from (0, 0) with handles (+50, +50) / (-50, -50) to (100, 0) with handles + // (+50, -50) / (-50, +50) — the classic up-bow arc. + let spine = PathData::single(SubPath::new( + vec![Anchor::smooth(Point::new(0.0, 0.0), Point::new(50.0, 50.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(50.0, -50.0))], + false, + )); + let sp = Spine::new(&spine).unwrap(); + // Many steps along the bow: each step's tangent comes from the curve at its arc length, + // not from a flattened chord — so the orientations must sweep the true angle, smoothly. + let len = sp.length(); + let mut prev: Option = None; + for i in 1..=200 { + let (pt, ang) = sp.at_length(len * i as f64 / 201.0); + if let Some(p) = prev { + let step = (ang - p).abs().min(2.0 * std::f64::consts::PI - (ang - p).abs()); + assert!(step < 0.1, "tangent step {} is too big at {} ({}, {})", step, i, pt.x, pt.y); + } + prev = Some(ang); + } + } + + /// Where the spine starts at an anchor whose handle sits on it, the first step still faces along + /// the curve (towards the next handle), not along a direction of zero length. + #[test] + fn a_handle_on_its_anchor_still_gives_the_curve_direction() { + let spine = PathData::single(SubPath::new( + vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(150.0, -50.0))], + false, + )); + let sp = Spine::new(&spine).unwrap(); + let (p, angle) = sp.at_length(0.0); + assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "{p:?}"); + assert!((angle - std::f64::consts::FRAC_PI_4).abs() < 0.05, "faces the in handle at (50, 50): {angle}"); + let (_, end) = sp.at_length(sp.length()); + assert!(end.is_finite(), "{end}"); + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index b19adc8b0..15f04ce15 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -321,6 +321,20 @@ pub struct SeriesPaint { /// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. #[serde(skip_serializing_if = "Option::is_none")] pub stroke_opacity: Option, + /// Blend mode of the topmost fill of each path directly in the series group, as [`Self::fill_opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub fill_blend: Option, + /// Blend mode of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. Absent: + /// Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub stroke_blend: Option, + /// Object opacity (0..1) of each path directly in the series group, taken from the object opacity set on one of them. + /// Absent: fully opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub opacity: Option, + /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub blend: Option, } /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index ea3575bf6..a04ee7c38 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -437,7 +437,7 @@ pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec) { let Some(children) = parent.children() else { return }; - if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) { + if parent.clips() && !edits_inside(parent, opt.scope) && children.first().is_some_and(|clip| !clip_contains(clip, p)) { return; } for i in hit_order(parent, children, p) { @@ -653,6 +653,15 @@ fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbo clip_outline(clip, p, opt) } +/// Does isolation mode (`scope`) edit what `parent` holds: is `parent` the isolated container, or +/// the parent of the single object isolated? Then the selection tools hit the art that the +/// clipping mask of `parent` hides, and the Unlock submenu lists it (#3748). The reference +/// app's help says that the parts outside a clipping mask can still be edited +/// (). +fn edits_inside(parent: &Node, scope: Option) -> bool { + scope.is_some_and(|s| s == parent.id || parent.children().is_some_and(|c| c.iter().any(|c| c.id == s))) +} + fn hit_children( parent: &Node, p: Point, @@ -670,8 +679,10 @@ fn hit_children( chain.push(clip.id); return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }); } - // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it. - if !clip_contains(clip, p) { + // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it, except for + // the selection tools in isolation mode within it. + let edited = opt.clip_outlines && editable_only && edits_inside(parent, opt.scope); + if !edited && !clip_contains(clip, p) { return None; } } @@ -1205,7 +1216,9 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured /// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then -/// only within that region's bounds. +/// only within that region's bounds. In isolation mode the clipping mask of the isolated container, +/// or of the parent of the single object isolated, does not limit the marquee, as it does not limit +/// the selection tools' clicks. pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { marquee_with(doc, r, scope, leaves, false) } @@ -1223,16 +1236,15 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh // Isolated art whose layer, or a group around it, was locked or hidden afterwards: the // marquee touches none of it, as clicks don't (#5309). Some(s) if !doc.is_editable(s.id) => vec![], - Some(s) => match (s.children(), marquee_in(s, r)) { - (Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), - (Some(_), None) => vec![], + Some(s) => match s.children() { + Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), // A single object isolated: only it. - (None, _) => doc + None => doc .parent_of(s.id) .and_then(|p| doc.node(p)) - .and_then(|p| Some((p.children()?, marquee_in(p, r)?))) + .and_then(|p| p.children()) .into_iter() - .flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r))) + .flat_map(|c| c.iter().filter(|c| c.id == s.id).map(|c| (c, r))) .collect(), }, // The objects of the layers, looking through sublayers (hidden, locked and template ones diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 495dc227b..d6f4cafe7 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -255,7 +255,9 @@ fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline() /// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away /// half alone it selects nothing, and around a rectangle drawn beside the group's mask it /// takes that rectangle alone; reaching into the mask takes the group. The same holds for -/// Group Selection's leaves and in isolation mode. +/// Group Selection's leaves. In isolation mode (#3748) the isolated group's own mask does not +/// limit the marquee, as it does not limit clicks: with the group or the art isolated, the +/// marquee takes the hidden art. #[test] fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { let mut d = Document::new(300.0, 200.0); @@ -270,8 +272,9 @@ fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { let hidden = Rect::new(150.0, -10.0, 210.0, 50.0); assert!(marquee(&d, hidden, None, false).is_empty()); assert!(marquee(&d, hidden, None, true).is_empty()); - assert!(marquee(&d, hidden, Some(g), false).is_empty()); - assert!(marquee(&d, hidden, Some(art_id), false).is_empty()); + assert_eq!(marquee(&d, hidden, Some(g), false), vec![art_id]); + assert_eq!(marquee(&d, hidden, Some(g), true), vec![art_id]); + assert_eq!(marquee(&d, hidden, Some(art_id), false), vec![art_id]); // This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge. assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]); // This marquee crosses the shown half's top edge and goes on into the hidden half. @@ -682,6 +685,82 @@ fn select_behind_takes_a_layer_masks_outline_first() { assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]); } +/// #3748: in isolation mode the selection tools hit the art that the isolated clip group (or +/// the isolated layer with a clipping mask) hides outside its mask, and the art when it is the +/// single object isolated; the mask's outline is still hit first. Outside isolation mode, and +/// for the tools that act on what is drawn, the hidden art is not hit. The Unlock submenu +/// lists the hidden art when it is locked. +#[test] +fn isolation_reaches_the_art_a_clipping_mask_hides() { + let hidden = Point::new(170.0, 90.0); + for layer in [false, true] { + let (mut d, g, clip, art) = masked_square(layer); + let isolated = HitOptions { scope: Some(g), ..SELECTING }; + assert!(hit_test(&d, hidden, SELECTING).is_none(), "layer {layer}: not isolated"); + let h = hit_test(&d, hidden, isolated); + assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(g)))), Some((art, art)), "layer {layer}"); + assert_eq!(objects_at(&d, hidden, SELECTING, Some(g)), vec![art], "layer {layer}"); + assert_eq!(hit_test(&d, Point::new(140.0, 90.0), isolated).map(|h| h.leaf), Some(clip), "layer {layer}: the outline first"); + let one = HitOptions { scope: Some(art), ..SELECTING }; + assert_eq!(hit_test(&d, hidden, one).map(|h| h.leaf), Some(art), "layer {layer}: the art isolated"); + assert!(hit_test(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }).is_none(), "layer {layer}: drawn art only"); + assert!(hit_test_visible(&d, hidden, isolated).is_none(), "layer {layer}: sampling"); + d.node_mut(art).unwrap().locked = true; + assert_eq!(locked_objects_at(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }), vec![art], "layer {layer}"); + assert!(locked_objects_at(&d, hidden, HitOptions::default()).is_empty(), "layer {layer}: not isolated"); + } +} + +/// #3748, nested clip groups: with the outer clip group isolated, a click or a marquee takes +/// the art its own mask hides, while the mask of a clip group inside it still limits that +/// group's art. With the inner clip group isolated, or its art, the inner mask does not. +#[test] +fn isolation_reaches_hidden_art_only_for_the_isolated_clip_group() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (outer, outer_clip, outer_art, inner, inner_clip, inner_art) = + (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let mask = |id, r| { + let mut path = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + Arc::new(path) + }; + let art = |id, r| Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default_art())); + let inner_group = Node::new( + inner, + NodeKind::Group { + children: vec![mask(inner_clip, Rect::new(60.0, 60.0, 120.0, 120.0)), art(inner_art, Rect::new(40.0, 40.0, 160.0, 160.0))], + clip: true, + }, + ); + let children = + vec![mask(outer_clip, Rect::new(20.0, 20.0, 180.0, 180.0)), art(outer_art, Rect::new(190.0, 20.0, 260.0, 180.0)), Arc::new(inner_group)]; + d.insert(Some(l), 0, Node::new(outer, NodeKind::Group { children, clip: true })).unwrap(); + // `outer_hidden` is outside the outer mask, on the outer group's own art; `inner_hidden` is + // inside the outer mask and outside the inner one, on the inner group's art. + let (outer_hidden, inner_hidden, shown) = (Point::new(220.0, 100.0), Point::new(150.0, 100.0), Point::new(90.0, 90.0)); + let leaf = |p, scope| hit_test(&d, p, HitOptions { scope, ..SELECTING }).map(|h| h.leaf); + assert_eq!((leaf(outer_hidden, None), leaf(inner_hidden, None)), (None, None), "not isolated"); + assert_eq!(leaf(outer_hidden, Some(outer)), Some(outer_art), "outer isolated: its own hidden art"); + assert_eq!(leaf(inner_hidden, Some(outer)), None, "outer isolated: the inner mask still clips"); + let h = hit_test(&d, shown, HitOptions { scope: Some(outer), ..SELECTING }); + assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(outer)))), Some((inner_art, inner)), "outer isolated: shown art"); + assert_eq!(leaf(inner_hidden, Some(inner)), Some(inner_art), "inner isolated"); + assert_eq!(leaf(outer_hidden, Some(inner)), None, "inner isolated: the outer group's art"); + assert_eq!(leaf(inner_hidden, Some(inner_art)), Some(inner_art), "the inner art isolated"); + // These marquees cross the right edge of the outer art (outside the outer mask) and of the + // inner art (inside the outer mask, outside the inner one). + let (outer_edge, inner_edge) = (Rect::new(255.0, 95.0, 265.0, 105.0), Rect::new(155.0, 95.0, 165.0, 105.0)); + assert_eq!(marquee(&d, outer_edge, Some(outer), false), vec![outer_art]); + assert!(marquee(&d, inner_edge, Some(outer), false).is_empty()); + assert!(marquee(&d, inner_edge, Some(outer), true).is_empty()); + assert_eq!(marquee(&d, inner_edge, Some(inner), false), vec![inner_art]); + assert!(marquee(&d, inner_edge, None, false).is_empty()); + assert!(marquee(&d, outer_edge, None, false).is_empty()); +} + /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the /// sublayer; hidden, locked and template sublayers block both. #[test] diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 67c9c0ee8..cace68ce6 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -572,7 +572,8 @@ mod tests { place(&mut g, &symbols); g.transform(Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false); let rec = g.extras().objects_on_path.as_deref().cloned().unwrap(); - assert_eq!((rec.spacing, rec.start), (Some(40.0), 20.0)); + // The spine measures arc length along the curve, so within rounding of the exact values. + assert!(rec.spacing.is_some_and(|d| (d - 40.0).abs() < 1e-9) && (rec.start - 20.0).abs() < 1e-9, "{rec:?}"); place(&mut g, &symbols); let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x; assert!((x(NodeId(2)) - 20.0).abs() < 1e-9 && (x(NodeId(3)) - 60.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3))); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 11be20ee2..7f99b702a 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -26,8 +26,11 @@ pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, }; -pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef}; -pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop}; +pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias}; +pub use tabs_lists::{ + Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign, + TabStop, +}; pub use wrap::{resolve_wrap, wrap_work}; /// A text object. `runs` split into paragraphs at `\n`. @@ -83,6 +86,11 @@ pub struct TextObject { /// Off for new type and for documents from before it, and saved only when on. #[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")] pub optical_margin: bool, + /// Character panel › Anti-aliasing Method: how the type's edges are drawn as pixels. Each + /// frame of a thread keeps its own method. Sharp for new type and for documents from before it, + /// and saved only when it is another method. + #[serde(default, rename = "antiAlias", skip_serializing_if = "crate::skip::is_default")] + pub anti_alias: TypeAntiAlias, /// Character panel menu › System Layout, copied from the document: when on, each character /// that takes space advances by a whole number of points. Off by default, and saved only when /// on. The document setting is [`crate::DocSetup::system_layout`]. @@ -101,6 +109,11 @@ pub struct TextObject { /// file saves the count only when it is not 0. #[serde(default, rename = "listCount", skip_serializing_if = "crate::skip::is_default")] pub list_count: u32, + /// Threaded text: as [`Self::list_count`], the counts the earlier frames carry into this frame + /// at list levels 2 on, level 2 first ([`ListCounts::from_parts`]). Empty when they are all 0, + /// and the file saves them only when not empty. A file's longer list opens with the first 8. + #[serde(default, rename = "listLevelCounts", skip_serializing_if = "Vec::is_empty", deserialize_with = "tabs_lists::read_level_counts")] + pub list_level_counts: Vec, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 70e953922..dc83c33cc 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -1,8 +1,8 @@ use super::area::{AreaOptions, PathAlign, PathEffect}; use super::char_style::CharStyle; use super::para_style::ParaStyle; -use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end}; -use super::tabs_lists::ParaList; +use super::runs::{RubySpan, TextKind, TextRun, TypeAntiAlias, move_anchors, reverse_from_end}; +use super::tabs_lists::{ListCounts, ParaList}; use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2}; impl TextObject { @@ -90,13 +90,24 @@ impl TextObject { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + anti_alias: TypeAntiAlias::Sharp, system_layout: false, fractional_widths: true, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), } } + /// The list counts the story's earlier frames carry into this frame at each level + /// ([`Self::list_count`] and [`Self::list_level_counts`]). + pub fn list_counts(&self) -> ListCounts { + ListCounts::from_parts(self.list_count, &self.list_level_counts) + } + /// Set the list counts the story's earlier frames carry into this frame ([`Self::list_counts`]). + pub fn set_list_counts(&mut self, counts: ListCounts) { + (self.list_count, self.list_level_counts) = counts.parts(); + } pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index f189801bc..0300e604d 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -258,6 +258,12 @@ impl ParaStyle { } } + /// The left indent the layout sets the paragraph at: its left indent, and for a paragraph in a + /// list at a level past the first, [`ParaList::level_indent`] more. + pub fn laid_out_left_indent(&self) -> f64 { + self.left_indent + self.list.map_or(0.0, |l| l.level_indent()) + } + /// The leading of characters styled `st` in this paragraph, as the layout sets it: their own /// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their /// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is diff --git a/crates/doc/src/text/runs.rs b/crates/doc/src/text/runs.rs index a540ba210..0bf62ed15 100644 --- a/crates/doc/src/text/runs.rs +++ b/crates/doc/src/text/runs.rs @@ -144,6 +144,44 @@ pub(super) fn reverse_from_end(path: &mut PathData) { } } +/// The anti-aliasing method of a type object (Character panel › Anti-aliasing Method): how the +/// edges of its characters are drawn when the type is drawn as pixels (the canvas, raster exports, +/// rasterizing). SVG, PDF and EPS export ignore it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TypeAntiAlias { + /// Hard edges: a pixel takes the type's color where the type covers at least half of it, and + /// nothing elsewhere. + None, + /// Each edge pixel shows the share of it the type covers. + #[default] + Sharp, + /// An edge pixel covered by a share `c` shows `2c - 0.5`, between 0 and 1: edges about half as + /// wide as Sharp's. + Crisp, + /// An edge pixel covered by a share `c` shows `2c`, at most 1: heavier type. + Strong, +} + +impl TypeAntiAlias { + pub const ALL: [Self; 4] = [Self::None, Self::Sharp, Self::Crisp, Self::Strong]; + + /// The id used in command params and files (`none`, `sharp`, `crisp`, `strong`). + pub fn id(self) -> &'static str { + match self { + Self::None => "none", + Self::Sharp => "sharp", + Self::Crisp => "crisp", + Self::Strong => "strong", + } + } + + /// The method an id names. + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|a| a.id() == id) + } +} + /// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in /// vertical type (the Japanese convention), or below (left in vertical type). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 7f6382b2f..c4ec92b70 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -155,14 +155,29 @@ fn roman(mut n: u32) -> String { s } +/// Where a list's marker stands before the first line's text ([`ParaList::align`]): the Bullets +/// and Numbering dialog's Alignment. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ListAlign { + /// The marker starts where the first line starts: at the left indent plus the first-line + /// indent. + #[default] + Left, + /// The marker ends where the first line's text starts, against the text. + Right, +} + /// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the /// list's marker before the paragraph's first line, in the font, size and color of the paragraph's /// first character without its baseline shift, rotation, superscript or subscript, tracking, manual /// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after -/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a -/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves -/// out as their defaults, and a build ignores the fields it doesn't know. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +/// it starts at the next tab stop, the left indent counting as a stop. The marker stands at the +/// first line's start, or against the text with [`ListAlign::Right`]. A paragraph at a level past +/// the first ([`Self::level`]) is set [`LIST_LEVEL_INDENT`] further in for each level. The marker +/// is not a character of the text, and an empty paragraph has none. A saved list reads the fields +/// it leaves out as their defaults, and a build ignores the fields it doesn't know. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(default)] pub struct ParaList { /// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for @@ -172,17 +187,127 @@ pub struct ParaList { /// numbered list. #[serde(skip_serializing_if = "Option::is_none")] pub number: Option, + /// Where the marker stands before the first line's text. The file saves it only when it is + /// [`ListAlign::Right`]. + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub align: ListAlign, + /// The paragraph's level in the list, 1 to [`LIST_LEVELS`] (Tab and Shift+Tab at the start of + /// the paragraph, Bullets and Numbering…'s Level): each level counts its own numbers + /// ([`Self::next_marker`]). A value outside that range is read as the nearest level + /// ([`Self::depth`]). The file saves it only when it is not 1. + #[serde(skip_serializing_if = "is_first_level", deserialize_with = "read_level")] + pub level: u8, +} + +impl Default for ParaList { + /// A bulleted list with a dot, its marker aligned left, at level 1. + fn default() -> Self { + Self { bullet: Bullet::default(), number: None, align: ListAlign::default(), level: 1 } + } +} + +/// Is `level` the first list level, which the file does not save? +fn is_first_level(level: &u8) -> bool { + *level == 1 +} + +/// Reads a saved list level, a whole number, as the nearest of 1 to [`LIST_LEVELS`], so a level +/// such as -1 or 300 opens the file at level 1 or 9. Anything else is an error. +fn read_level<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let level = match serde_json::Value::deserialize(d)? { + serde_json::Value::Number(n) if n.is_i64() || n.is_u64() => n.as_i64().map_or(LIST_LEVELS as i64, |n| n.clamp(1, LIST_LEVELS as i64)), + _ => return Err(serde::de::Error::custom("a list level must be a whole number")), + }; + Ok(level as u8) +} + +/// Reads a type object's saved counts at list levels 2 on ([`ListCounts::from_parts`]), keeping +/// the first `LIST_LEVELS - 1` and skipping the rest of a longer list without storing it. +pub(super) fn read_level_counts<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + struct Counts; + impl<'de> serde::de::Visitor<'de> for Counts { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list of list counts") + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while out.len() < LIST_LEVELS - 1 { + match seq.next_element::()? { + Some(c) => out.push(c), + None => return Ok(out), + } + } + while seq.next_element::()?.is_some() {} + Ok(out) + } + } + d.deserialize_seq(Counts) +} + +/// How many levels a list has (Bullets and Numbering…'s Level: 1 to 9). +pub const LIST_LEVELS: usize = 9; + +/// How much further in (pt) the layout sets a list paragraph for each level past the first: its +/// left indent counts that much more, and the marker and the text move with it. +pub const LIST_LEVEL_INDENT: f64 = 18.0; + +/// The numbered paragraphs a story's list has counted at each level, level 1 first +/// ([`ParaList::next_marker`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ListCounts(pub [u32; LIST_LEVELS]); + +impl ListCounts { + /// The counts with `first` at level 1 and `deeper` at levels 2 on, a missing level at 0, as a + /// type object saves them ([`TextObject::list_count`], [`TextObject::list_level_counts`]). + pub fn from_parts(first: u32, deeper: &[u32]) -> Self { + let mut counts = [0; LIST_LEVELS]; + counts[0] = first; + for (c, &d) in counts[1..].iter_mut().zip(deeper) { + *c = d; + } + Self(counts) + } + + /// The count at level 1, and the counts at levels 2 on without the zeros that end them. + pub fn parts(&self) -> (u32, Vec) { + let deeper = &self.0[1..]; + let used = deeper.iter().rposition(|&c| c != 0).map_or(0, |i| i + 1); + (self.0[0], deeper[..used].to_vec()) + } } impl ParaList { /// A bulleted list with `bullet`. pub fn bulleted(bullet: Bullet) -> Self { - Self { bullet, number: None } + Self { bullet, ..Self::default() } } /// A numbered list with `style`. pub fn numbered(style: NumberStyle) -> Self { - Self { bullet: Bullet::default(), number: Some(style) } + Self { number: Some(style), ..Self::default() } + } + + /// This list with its marker at `align`. + pub fn aligned(self, align: ListAlign) -> Self { + Self { align, ..self } + } + + /// This list at level `level` (1 to [`LIST_LEVELS`]). + pub fn at_level(self, level: u8) -> Self { + Self { level, ..self } + } + + /// The list's level, 1 to [`LIST_LEVELS`]: [`Self::level`], a value outside the range taken + /// as the nearest level. + pub fn depth(&self) -> usize { + usize::from(self.level).clamp(1, LIST_LEVELS) + } + + /// How much further in (pt) than its left indent the layout sets a paragraph of this list: + /// [`LIST_LEVEL_INDENT`] for each level past the first. + pub fn level_indent(&self) -> f64 { + (self.depth() - 1) as f64 * LIST_LEVEL_INDENT } /// The characters of the marker the layout draws before the paragraph: the bullet, or for a @@ -195,18 +320,26 @@ impl ParaList { } /// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and - /// which holds no text when `empty`, with `count` the numbered paragraphs counted before it. - /// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has - /// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the - /// count as it is; a numbered paragraph adds one to the count and is numbered with it. - pub fn next_marker(list: Option, empty: bool, count: &mut u32) -> Option { + /// which holds no text when `empty`, with `counts` the numbered paragraphs counted before it at + /// each level. A paragraph in no list sets every count back to 0 and has no marker; an empty + /// paragraph has none and leaves the counts as they are. A paragraph with text sets the counts + /// of the levels deeper than its own back to 0, so a deeper paragraph after it counts from 1 + /// again; a bulleted one has its bullet and leaves the count of its level as it is, and a + /// numbered one adds one to the count of its level and is numbered with it. A paragraph back + /// at a shallower level then goes on with that level's count. + pub fn next_marker(list: Option, empty: bool, counts: &mut ListCounts) -> Option { let Some(list) = list else { - *count = 0; + *counts = ListCounts::default(); return None; }; if empty { return None; } + let level = list.depth() - 1; + for deeper in &mut counts.0[level + 1..] { + *deeper = 0; + } + let count = &mut counts.0[level]; if list.number.is_some() { *count = count.saturating_add(1); } diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index a3f4ab4d2..6a997c9e5 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -194,13 +194,13 @@ fn number_styles_write_their_numbers_and_roll_over() { assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV."); assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}"); let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot))); - let mut count = 0; + let mut count = ListCounts::default(); let markers: Vec> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)] .iter() .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count)) .collect(); assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]); - assert_eq!(count, 1); + assert_eq!(count.parts(), (1, vec![])); let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash)); assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits)); @@ -211,6 +211,20 @@ fn number_styles_write_their_numbers_and_roll_over() { ); } +/// A list's marker alignment: Left by default and not saved, Right saved as `"align": "right"`; +/// a list saved without it reads as Left. +#[test] +fn a_list_saves_its_marker_alignment_only_when_it_is_right() { + let right = ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right); + assert_eq!(ParaList::bulleted(Bullet::Dot).align, ListAlign::Left); + assert_eq!(serde_json::to_value(right).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})); + assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"})); + let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); + assert_eq!(read(serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})), right); + assert_eq!(read(serde_json::json!({"bullet": "square"})).align, ListAlign::Left); + assert!(serde_json::from_value::(serde_json::json!({"align": "center"})).is_err()); +} + /// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph /// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the /// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters @@ -240,6 +254,70 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() { assert_eq!(t.typed_list_at(19), Some((16, letters))); } +/// List levels: each level counts its own numbers, a deeper paragraph starts its count at 1, +/// a paragraph back at a shallower level goes on with that level's count, and a paragraph in +/// no list starts every level again. A level past the first sets the paragraph 18 pt further +/// in; the file saves the level only when it is not 1, and a level outside 1 to 9 reads as the +/// nearest one. A type object's counts save level 1 and the deeper levels apart. +#[test] +fn list_levels_count_their_own_numbers_and_save_only_past_the_first() { + let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)); + let dot = |level| Some(ParaList::bulleted(Bullet::Dot).at_level(level)); + let mut counts = ListCounts::default(); + let markers: Vec> = [ + (num(1), false), + (num(2), false), + (num(2), false), + (num(3), false), + (num(2), true), + (num(2), false), + (num(1), false), + (num(2), false), + (dot(1), false), + (num(2), false), + (num(1), false), + (None, false), + (num(2), false), + ] + .iter() + .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut counts)) + .collect(); + let want = ["1.", "1.", "2.", "1.", "", "3.", "2.", "1.", "\u{2022}", "1.", "3.", "", "1."]; + assert_eq!(markers.iter().map(|m| m.as_deref().unwrap_or("")).collect::>(), want); + assert_eq!(counts, ListCounts([0, 1, 0, 0, 0, 0, 0, 0, 0])); + assert_eq!((ParaList::default().level, ParaList::bulleted(Bullet::Dot).depth()), (1, 1)); + assert_eq!([0, 1, 4, 9, 12].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).depth()), [1, 1, 4, 9, 9]); + assert_eq!([1, 2, 9].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).level_indent()), [0.0, 18.0, 144.0]); + let para = ParaStyle { left_indent: 18.0, list: dot(3), ..Default::default() }; + assert_eq!((para.laid_out_left_indent(), ParaStyle { list: None, ..para.clone() }.laid_out_left_indent()), (54.0, 18.0)); + assert_eq!(serde_json::to_value(dot(1)).unwrap(), serde_json::json!({"bullet": "dot"})); + assert_eq!(serde_json::to_value(num(4)).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "level": 4})); + let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); + assert_eq!((read(serde_json::json!({"bullet": "dash"})).level, read(serde_json::json!({"level": 4}))), (1, dot(4).unwrap())); + assert!(serde_json::from_value::(serde_json::json!({"level": "two"})).is_err()); + assert!(serde_json::from_value::(serde_json::json!({"level": 2.5})).is_err()); + let levels = [serde_json::json!(0), serde_json::json!(-1), serde_json::json!(12), serde_json::json!(300), serde_json::json!(u64::MAX)]; + assert_eq!( + levels.map(|l| read(serde_json::json!({ "level": l })).level), + [1, 1, 9, 9, 9], + "a saved level outside 1 to 9 reads as the nearest one" + ); + let deep = ListCounts([5, 0, 2, 0, 0, 0, 0, 0, 0]); + assert_eq!(deep.parts(), (5, vec![0, 2])); + assert_eq!((ListCounts::from_parts(5, &[0, 2]), ListCounts::default().parts()), (deep, (0, vec![]))); + let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); + t.set_list_counts(deep); + assert_eq!((t.list_count, t.list_level_counts.as_slice(), t.list_counts()), (5, &[0, 2][..], deep)); + let saved = serde_json::to_value(&t).unwrap(); + assert_eq!((&saved["listCount"], &saved["listLevelCounts"]), (&serde_json::json!(5), &serde_json::json!([0, 2]))); + t.set_list_counts(ListCounts::default()); + assert!(serde_json::to_value(&t).unwrap().get("listLevelCounts").is_none(), "no counts past level 1 are saved without them"); + let mut saved = serde_json::to_value(&t).unwrap(); + saved["listLevelCounts"] = serde_json::json!((1..=20).collect::>()); + let opened = serde_json::from_value::(saved).unwrap(); + assert_eq!(opened.list_level_counts, (1..=8).collect::>(), "a file keeps only the counts of levels 2 to 9"); +} + /// In a threaded frame whose first paragraph continues from the frame before, the text at the /// frame's start is not the start of a paragraph, so typing a list's start there starts nothing. /// The paragraphs after it still start lists. diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 0ff1cdac0..4819727f9 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -210,6 +210,177 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo assert_eq!(baked(&fitted, distort()).1.geom, None); } +#[test] +fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = |kind| GradientPaint::new(Gradient::new(kind, stops.clone())); + // A ramp across the square and one down it, so both axes of the box map show. + let across = GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None }; + let down = GradientGeom { start: Point::new(60.0, 10.0), end: Point::new(60.0, 75.0), aspect: 1.0, focal: None }; + let placed = |geom| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp(GradientKind::Linear) })); + let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear))); + let square = Rect::new(0.0, 0.0, 100.0, 100.0); + let baked = |fill: &Paint, e: Effect| { + let mut n = Node::path(NodeId(50), shapes::rectangle(square), Appearance::basic(fill.clone(), Paint::None, 0.0)); + n.appearance.effects.push(e); + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + match m.appearance.fill_paint() { + Paint::Gradient(g) => (m.geometric_bounds().unwrap(), (*g).clone()), + p => panic!("a gradient: {p:?}"), + } + }; + // Where the box map from the square to `to` takes `p`. + let follow = |p: Point, to: Rect| Point::new(to.x0 + p.x / 100.0 * to.width(), to.y0 + p.y / 100.0 * to.height()); + let styles = [ + "arc", + "arcLower", + "arcUpper", + "arch", + "bulge", + "shellLower", + "shellUpper", + "flag", + "wave", + "fish", + "rise", + "fisheye", + "inflate", + "squeeze", + "twist", + ]; + // The square's warped bounds, from `warp_point` on 401 points along each side: the square's + // corners are (-1, -1) and (1, 1) in the warp's box coordinates, and a vertical warp swaps + // the axes. + let warped_bounds = |style: &str, p: &serde_json::Value| { + let Some(style) = crate::WarpStyle::from_id(style) else { panic!("warp.{style}: a style") }; + let share = |key: &str, default: f64| p.get(key).and_then(serde_json::Value::as_f64).unwrap_or(default) / 100.0; + let vertical = p.get("orientation").and_then(serde_json::Value::as_str) == Some("vertical"); + let (bend, dh, dv) = (share("bend", 50.0), share("horizontal", 0.0), share("vertical", 0.0)); + let mut r: Option = None; + for k in 0..=400 { + let s = f64::from(k) / 200.0 - 1.0; + for (x, y) in [(s, -1.0), (s, 1.0), (-1.0, s), (1.0, s)] { + let (x2, y2) = if vertical { + let (a, b) = crate::warp_point(style, bend, dh, dv, 1.0, y, x); + (b, a) + } else { + crate::warp_point(style, bend, dh, dv, 1.0, x, y) + }; + let q = Point::new(50.0 + x2 * 50.0, 50.0 + y2 * 50.0); + r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + } + r.unwrap_or(square) + }; + let near = + |a: Rect, b: Rect, tol: f64| (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol; + let mut moved = 0; + for style in styles { + for params in + [json!({"bend": 30.0}), json!({"bend": -40.0, "orientation": "vertical"}), json!({"bend": 20.0, "horizontal": 30.0, "vertical": -20.0})] + { + // A warp that bends the square's sides inward keeps its bounds (within 0.5 pt), and the + // gradient stays with them. + let expected = warped_bounds(style, ¶ms); + let moves = !near(expected, square, 0.5); + moved += usize::from(moves); + for linear in [across, down] { + let what = format!("warp.{style} {params} {linear:?}"); + let (b, g) = baked(&placed(linear), fx(&format!("warp.{style}"), params.clone())); + assert!(near(b, expected, 0.1), "{what}: bounds {b:?}, want {expected:?}"); + let geom = g.geom.unwrap(); + let shifted = geom.start.distance(linear.start).max(geom.end.distance(linear.end)); + assert_eq!(shifted > 0.1, moves, "{what}: the gradient moves {shifted} with bounds {b:?}"); + let want = GradientGeom { start: follow(linear.start, b), end: follow(linear.end, b), ..linear }; + assert_geom_near(geom, want, &what); + } + let what = format!("warp.{style} {params}"); + // A fitted gradient stays fitted, and fits the warped outline. + assert_eq!(baked(&fitted, fx(&format!("warp.{style}"), params)).1.geom, None, "{what}"); + } + } + assert_eq!(moved, 39, "the warps that move the bounds, of 45"); + // With no bend and no distortion the gradient stays where it is. + let (_, g) = baked(&placed(across), fx("warp.arc", json!({"bend": 0.0}))); + assert_geom_near(g.geom.unwrap(), across, "no bend"); +} + +/// A warp with no distortion and no bend (or a bend Arc takes as none) leaves a placed gradient +/// exactly where it was, even where warping the outline moves its bounds by a rounding error. +#[test] +fn a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let geom = GradientGeom { start: Point::new(13.7, 31.3), end: Point::new(91.1, 47.9), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut drift = 0; + let cases = crate::WARP_STYLES.map(|(s, _)| (s, 0.0)); + for (k, (style, bend)) in cases.into_iter().chain([("arc", 1e-5), ("arc", -1e-5)]).enumerate() { + let k = k as f64; + let oval = Rect::new(10.1 + k * 0.37, 20.3 - k * 1.13, 97.7 + k * 3.31, 71.9 + k * 0.71); + let mut n = Node::path(NodeId(50), shapes::ellipse(oval), Appearance::basic(placed.clone(), Paint::None, 0.0)); + n.appearance.effects.push(fx(&format!("warp.{style}"), json!({ "bend": bend }))); + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + drift += usize::from(m.geometric_bounds() != shapes::ellipse(oval).bounds()); + let Paint::Gradient(g) = m.appearance.fill_paint() else { panic!("warp.{style} {bend}: a gradient") }; + assert_eq!(g.geom, Some(geom), "warp.{style} {bend}"); + } + assert!(drift > 0, "the warped outlines' bounds move by a rounding error"); + // Another style with as small a bend still moves the outline, and the gradient follows it. + let mut n = Node::path(NodeId(50), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(placed, Paint::None, 0.0)); + n.appearance.effects.push(fx("warp.rise", json!({"bend": 1e-5}))); + let d = bake_document(&doc_with(n)).unwrap(); + let Paint::Gradient(g) = d.node(NodeId(50)).unwrap().appearance.fill_paint() else { panic!("warp.rise: a gradient") }; + assert_ne!(g.geom, Some(geom), "warp.rise 1e-5"); +} + +/// A Warp effect on a fill or a stroke itself moves and scales its placed gradient from the +/// path's bounds before the warp to its bounds after it (a stroke: its stroke-inflated bounds). +#[test] +fn a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: Point::new(20.0, 60.0), end: Point::new(80.0, 60.0), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let square = Rect::new(0.0, 0.0, 100.0, 100.0); + let width = 4.0; + // Where the box map from `from` to `to` takes `p`. + let follow = |p: Point, from: Rect, to: Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + let pad = |r: Rect| r.inflate(width / 2.0, width / 2.0); + for style in ["arc", "rise", "inflate"] { + for stroke in [false, true] { + let what = format!("warp.{style} on the {}", if stroke { "stroke" } else { "fill" }); + let mut ap = + if stroke { Appearance::basic(Paint::None, placed.clone(), width) } else { Appearance::basic(placed.clone(), Paint::None, 0.0) }; + let Some(item) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_)) == stroke) else { panic!("{what}: the item") }; + item.effects_mut().push(fx(&format!("warp.{style}"), json!({"bend": 40.0}))); + let n = Node::path(NodeId(50), shapes::rectangle(square), ap); + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let Paint::Gradient(g) = item.paint() { + found.push((c.geometric_bounds().unwrap(), g.geom.unwrap())); + } + } + }); + assert!(!found.is_empty(), "{what}: the gradient is kept"); + for (b, g) in found { + assert_ne!(b, square, "{what}: the warp changes the bounds"); + let (from, to) = if stroke { (pad(square), pad(b)) } else { (square, b) }; + let want = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom }; + assert_geom_near(g, want, &what); + } + } + } +} + #[test] fn item_effects_split_into_one_path_per_item() { let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 07106ffc8..26c193b62 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -111,8 +111,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = - "{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 57a10f66e..d561c90b9 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -175,9 +175,10 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R } /// How placed gradients follow geometry effects. The Transform effect without copies maps them as -/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort moves -/// and scales them from the object's bounds before the effect to its bounds after it. For the -/// other geometry effects it is empty. It does not change gradients fitted to the object. +/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort and +/// the Warp effects move and scale them from the object's bounds before the effect to its bounds +/// after it. For the other geometry effects it is empty. It does not change gradients fitted to +/// the object. #[derive(Clone, Debug, Default, PartialEq)] pub struct Carry(Vec); @@ -193,9 +194,10 @@ enum CarryStep { impl Carry { /// Does effect `id` with merged params `p` on geometry with reference box `b` (`seed` for - /// Random) move placed gradients? Free Distort does unless it leaves every corner in place; - /// the Transform effect does without copies (with copies the original stays where it is, and - /// `crate::reshape` gives each copy its own gradients). + /// Random) move placed gradients? Free Distort does unless it leaves every corner in place, a + /// Warp effect unless it has no bend and no distortion, and the Transform effect does without + /// copies (with copies the original stays where it is, and `crate::reshape` gives each copy + /// its own gradients). pub(crate) fn follows(id: &str, p: &Value, b: Rect, seed: u64) -> bool { match id { "distort.transform" => { @@ -209,10 +211,17 @@ impl Carry { [(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)].iter().zip(&corners).all(|(a, c)| c.distance((*a).into()) <= tol); !still && corners.iter().all(|c| c.x.is_finite() && c.y.is_finite()) } + _ if id.starts_with("warp.") => crate::warp::bends(id, p), _ => false, } } + /// Does effect `id` move placed gradients from each path's bounds before it to its bounds + /// after it, rather than by one map for the whole object? + pub(crate) fn by_bounds(id: &str) -> bool { + id == "distort.freeDistort" || id.starts_with("warp.") + } + /// How placed gradients follow effect `id` (as [`Self::follows`]) on an object whose geometric /// bounds are `from` before the effect and `to` after it; empty when they stay in place. pub(crate) fn of(id: &str, p: &Value, b: Rect, from: Option, to: Option, seed: u64) -> Carry { diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index a3f6859b8..a1abaac7d 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -312,7 +312,8 @@ fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNod Some(po::boolean_n(&refs, |inside| { let (mut start, mut area) = (0, false); for (k, (op, count)) in members.iter().enumerate() { - let here = inside.get(start..start + count).is_some_and(|flags| flags.contains(&true)); + // `inside` is ascending: the first leaf at or after `start` tells whether one of this member's is in it. + let here = inside.get(inside.partition_point(|&i| i < start)).is_some_and(|&i| i < start + count); start += count; area = if k == 0 { here } else { op.apply(area, here) }; } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index a0cddc94d..9c7e2a31a 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -349,6 +349,42 @@ fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() { assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves"); } +/// Under a Warp effect, the gradient placed on a symbol instance's art's own fill follows the art's +/// bounds, and the gradient placed on a compound shape follows its area's bounds, each from before +/// the warp to after it, in the art Pathfinder takes. +#[test] +fn shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (2.0, 3.0).into(), end: (9.0, 3.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let follow = |p: Point, from: Rect, to: Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + let check = |out: &Node, from: Rect, what: &str| { + let to = out.geometric_bounds().unwrap(); + assert_ne!(from, to, "{what}: the warp changes the bounds"); + let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("{what}: a gradient: {:?}", out.appearance) }; + let got = g.geom.unwrap(); + let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to)); + assert!(got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, "{what}: {got:?}, want {s:?} {e:?}"); + }; + for style in ["arc", "rise", "bulge"] { + let warp = || Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true }; + let mut art = Node::group(NodeId(20), vec![sq(21, 0.0, Color::BLACK)]); + art.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + let hook = move |_: &Node| Some(art.clone()); + let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + instance.appearance.effects.push(warp()); + check(&shape_art(&instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance")); + let mut shape = compound_shape(ShapeMode::Add); + shape.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into(); + shape.appearance.effects.push(warp()); + check(&shape_art(&shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape")); + } +} + #[test] fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; @@ -378,6 +414,118 @@ fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}"); } +/// A group's Warp effect moves and scales the gradient placed on each of its paths from that path's +/// bounds before the warp to its bounds after it, not by the group's bounds. +#[test] +fn a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = |x: f64| GradientGeom { start: (x + 2.0, 3.0).into(), end: (x + 8.0, 3.0).into(), aspect: 1.0, focal: None }; + let placed = |x: f64| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom(x)), ..ramp.clone() })); + let square = |id: u64, x: f64| { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(placed(x), Paint::None, 0.0))) + }; + for style in ["arc", "bulge", "rise", "twist"] { + let mut g = Node::group(NodeId(10), vec![square(1, 0.0), square(2, 20.0)]); + g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut moved = false; + for (i, x) in [0.0, 20.0].into_iter().enumerate() { + let m = &art.children().unwrap()[i]; + let b = m.geometric_bounds().unwrap(); + let follow = |p: Point| Point::new(b.x0 + (p.x - x) / 10.0 * b.width(), b.y0 + p.y / 10.0 * b.height()); + let Paint::Gradient(gp) = m.appearance.fill_paint() else { panic!("a gradient") }; + let got = gp.geom.unwrap(); + let want = geom(x); + assert!( + got.start.distance(follow(want.start)) < 1e-6 && got.end.distance(follow(want.end)) < 1e-6, + "warp.{style} path {i}: {got:?} in {b:?}" + ); + moved |= got.start.distance(want.start) > 0.1; + } + assert!(moved, "warp.{style} moves a gradient"); + } +} + +/// Under a group's Warp effect, the group's own placed gradients follow the group's bounds, a +/// nested group's own placed gradients follow the nested group's bounds, and a compound path's +/// placed gradients follow the compound path's bounds, each from before the warp to after it (a +/// stroke's: those bounds inflated by the stroke). +#[test] +fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (12.0, 4.0).into(), end: (26.0, 4.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let own_fill = |n: &mut Node| { + n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + n.appearance.items.push(AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(placed.clone(), 3.0))); + }; + let follow = |p: Point, from: Rect, to: Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + for style in ["arc", "bulge", "rise", "flag"] { + let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]); + own_fill(&mut inner); + let ring = vec![ + Arc::new(Node::path(NodeId(6), shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 14.0)), Appearance::default())), + Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(23.0, 3.0, 27.0, 7.0)), Appearance::default())), + ]; + let mut compound = Node::new(NodeId(3), NodeKind::Compound { children: ring, rule: FillRule::EvenOdd }); + compound.appearance = Appearance::basic(placed.clone(), placed.clone(), 2.0).into(); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(compound), sq(2, 40.0, Color::BLACK)]); + own_fill(&mut g); + let mut before = std::collections::HashMap::new(); + g.walk(&mut |c: &Node| { + before.insert(c.id, c.geometric_bounds().unwrap()); + }); + g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + // The bounds after the warp of the group, the nested group and the compound path (the paths + // a group's own fill paints carry the group's id). + let mut after = std::collections::HashMap::new(); + art.walk(&mut |c: &Node| { + if matches!(c.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) { + after.entry(c.id).or_insert_with(|| c.geometric_bounds().unwrap()); + } + }); + let mut checked = vec![]; + art.walk(&mut |c: &Node| { + if ![NodeId(3), NodeId(5), NodeId(10)].contains(&c.id) { + return; + } + for item in &c.appearance.items { + let Paint::Gradient(gp) = item.paint() else { continue }; + let (from, to) = (before[&c.id], after[&c.id]); + assert_ne!(from, to, "warp.{style} {:?}: the warp changes the bounds", c.id); + let stroke = match item { + AppearanceItem::Stroke(s) => Some(s), + AppearanceItem::Fill(_) => None, + }; + let (from, to) = match stroke { + Some(s) => (s.paint_bounds(from), s.paint_bounds(to)), + None => (from, to), + }; + let got = gp.geom.unwrap(); + let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to)); + assert!( + got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, + "warp.{style} {:?} (stroke: {}): {got:?}, want {s:?} {e:?} ({from:?} to {to:?})", + c.id, + stroke.is_some() + ); + checked.push((c.id, stroke.is_some())); + } + }); + checked.sort(); + checked.dedup(); + let want: Vec<_> = [NodeId(3), NodeId(5), NodeId(10)].into_iter().flat_map(|id| [(id, false), (id, true)]).collect(); + assert_eq!(checked, want, "warp.{style}"); + } +} + /// A group nested in a group with a Transform effect or Free Distort keeps its own placed gradient /// on its members: the Transform's map moves it, and Free Distort moves and scales it from the /// nested group's bounds before the effect to its bounds after it. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7bba3e8dd..84c83d2e7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -80,8 +80,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; pub use reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, has_transform_options, - image_paint, needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, + has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 85f2849d9..ca39bc6cf 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -133,7 +133,13 @@ fn gives_shape(e: &Effect) -> bool { /// Does `item`, a fill or stroke of an image, paint something: does it paint and carry an effect /// that gives it a shape ([`gives_shape`])? fn shapes_image(item: &AppearanceItem) -> bool { - crate::paints(item) && item.effects().iter().any(gives_shape) + crate::paints(item) && gives_image_shape(item.effects()) +} + +/// Do `effects`, those of an image's fill or stroke, give that fill or stroke a shape to paint +/// ([`gives_shape`])? +pub fn gives_image_shape(effects: &[Effect]) -> bool { + effects.iter().any(gives_shape) } /// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each @@ -652,12 +658,13 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); - // Placed gradients follow a Transform effect and Free Distort ([`crate::Carry`]): under - // Free Distort (`boxes`) each path's move and scale from its bounds before the effect to - // its bounds after it. Under that and the other effects, the placed gradients of type's - // outlines in `art` (`outline_text`) move and scale with the outlines' bounds. + // Placed gradients follow a Transform effect, Free Distort and the Warp effects + // ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from + // its bounds before the effect to its bounds after it. Under those and the other effects, + // the placed gradients of type's outlines in `art` (`outline_text`) move and scale with + // the outlines' bounds. let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed); - let boxes = follows && e.id == "distort.freeDistort"; + let boxes = follows && crate::Carry::by_bounds(&e.id); let mut outlines = vec![]; if !follows || boxes { type_outline_bounds(art, &mut outlines); @@ -728,8 +735,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b rebase_type_outlines(art, &mut outlines.into_iter()); } // 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 that its bounds measure kept its shape (past the budget). + // follow their bounds under Free Distort and Warp (they stay when no path changed them). + // Each stays 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() { diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 0c84cde69..325ce9b38 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -57,16 +57,27 @@ fn numbers(v: &str) -> Option> { /// Filter `xml` as it is drawn and written ([`rebuilt`]) when it passes [`check`], `None` when it /// doesn't. Remembered for the filters seen last, so drawing does not check a filter again each time. fn checked(xml: &str) -> Option> { - use std::hash::BuildHasher; - type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option>>, usize); static SEEN: std::sync::OnceLock> = std::sync::OnceLock::new(); - let seen = SEEN.get_or_init(Default::default); + remembered(&SEEN, xml, |xml| checked_form(xml).ok().map(|(_, form)| Arc::from(form))) +} + +/// The results [`remembered`] keeps: the hash's seed, the results by key, and their size in bytes. +type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option>>, usize); + +/// `make(xml)`, remembered in `seen` for the filters seen last. +fn remembered( + seen: &'static std::sync::OnceLock>, + xml: &str, + make: impl FnOnce(&str) -> Option>, +) -> Option> { + use std::hash::BuildHasher; + let seen = seen.get_or_init(Default::default); // Keyed by a hash that a file can't aim at (randomly seeded) and the length. let key = seen.lock().ok().map(|s| (s.0.hash_one(xml), xml.len())); if let Some(hit) = key.and_then(|k| seen.lock().ok().and_then(|s| s.1.get(&k).cloned())) { return hit; } - let form: Option> = checked_form(xml).ok().map(|(_, form)| Arc::from(form)); + let form = make(xml); if let (Some(k), Ok(mut s)) = (key, seen.lock()) && !s.1.contains_key(&k) { @@ -83,7 +94,7 @@ fn checked(xml: &str) -> Option> { form } -/// The most check results [`checked`] keeps, and the most bytes of filters it keeps. +/// The most results [`remembered`] keeps in one place, and the most bytes of filters it keeps there. const MAX_CHECKED: usize = 65_536; const MAX_CHECKED_BYTES: usize = 32 << 20; @@ -279,9 +290,30 @@ const LATER_BLEND_MODES: [&str; 11] = /// The `feComposite` operators of SVG 1.1. const SVG11_OPERATORS: [&str; 6] = ["over", "in", "out", "atop", "xor", "arithmetic"]; -/// The longest form SVG output writes ([`svg11`]), leaving room within [`MAX_DRAWN_XML`] for the -/// region and id written into it. -const MAX_WRITTEN_XML: usize = MAX_DRAWN_XML - 1024; +/// The longest id SVG output gives a filter it writes: a unique-id prefix of up to 13 characters, +/// `filter-` and a counter of up to 20 digits, with room to spare. +const WRITTEN_ID_ROOM: usize = 64; + +/// The longest text `{}` writes for a finite `f64`: a sign, `0.`, 307 zeros and 17 digits, as for +/// `-f64::MIN_POSITIVE`. +const MAX_NUMBER_TEXT: usize = 327; + +/// The most the four region attributes written into a filter ([`SvgFilterFx::written_xml`]) add to +/// it: each of `x`, `y`, `width` and `height` as ` height="…"` with the longest number. +const REGION_ROOM: usize = 4 * (" height=\"\"".len() + MAX_NUMBER_TEXT); + +/// The longest form SVG output writes ([`svg11`]): room is left within [`SvgFilter::MAX_XML`], the +/// longest filter SVG import reads, for the id written into it. Measured with [`written_len`]. +const MAX_WRITTEN_XML: usize = SvgFilter::MAX_XML - WRITTEN_ID_ROOM; + +/// How long `s` is as SVG output can write it and import reads it back: SVG output with the Latin-1 +/// encoding writes each character past U+00FF as `&#x…;`, which import keeps as text, so the +/// longer of the two lengths is counted for each such character. +fn written_len(s: &str) -> usize { + // `&#x`, one hex digit per four significant bits, and `;`. + let reference = |c: u32| 4 + ((u32::BITS - c.leading_zeros()) as usize).div_ceil(4); + s.chars().map(|c| if u32::from(c) < 0x100 { c.len_utf8() } else { reference(u32::from(c)) }).sum() +} /// Filter `xml` (a rebuilt one, [`rebuilt`]) as SVG 1.1 output writes it, in the form an SVG 1.1 /// viewer draws as resvg draws `xml`: @@ -413,7 +445,7 @@ fn svg11(xml: &str) -> Option { } out.push_str(""); } - (out.len() <= MAX_WRITTEN_XML).then_some(out) + (written_len(&out) <= MAX_WRITTEN_XML).then_some(out) } /// The result names of a filter's SVG 1.1 form ([`svg11`]): the names its elements use, and the @@ -1278,13 +1310,28 @@ impl SvgFilterFx { /// whose box in the document is `bounds`: in its SVG 1.1 form ([`svg11`]), `None` when SVG 1.1 /// can't hold it ([`Self::writes_as_svg11`]). pub fn written_xml(&self, bounds: Rect) -> Option { - self.with_region(&svg11(&self.xml)?, (bounds - self.origin).scale_from_origin(1.0 / self.scale)) + let out = self.with_region(&self.svg11_form()?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))?; + (written_len(&out) <= MAX_WRITTEN_XML).then_some(out) } - /// Can SVG 1.1 output write the filter as a `` element ([`svg11`])? When it can't, SVG - /// output treats it as it treats the other pixel effects. + /// Can SVG 1.1 output write the filter as a `` element ([`svg11`]) that SVG import + /// reads back? When it can't, SVG output treats it as it treats the other pixel effects. pub fn writes_as_svg11(&self) -> bool { - svg11(&self.xml).is_some() + self.svg11_form().is_some() + } + + /// The filter's SVG 1.1 form ([`svg11`]) when, with any region and id that SVG output writes + /// into it, it is a filter SVG import reads ([`check`]): at most [`SvgFilter::MAX_XML`] bytes, + /// [`MAX_PRIMITIVES`] primitives and [`MAX_DEPTH`] levels deep, and drawn. Remembered for the + /// filters seen last, so export does not check a filter again for each object. + fn svg11_form(&self) -> Option> { + static FORMS: std::sync::OnceLock> = std::sync::OnceLock::new(); + // `measured` follows from the XML, so the XML is the whole key. + let region = if self.measured { REGION_ROOM } else { 0 }; + remembered(&FORMS, &self.xml, |xml| { + let form = svg11(xml)?; + (written_len(&form) + region <= MAX_WRITTEN_XML && check(&form).is_ok()).then(|| Arc::from(form)) + }) } /// How finely the filter runs on a raster whose pixels are `space.px` points wide, for an diff --git a/crates/effects/src/svg_filter/tests.rs b/crates/effects/src/svg_filter/tests.rs index 4d0a60394..df1956f3f 100644 --- a/crates/effects/src/svg_filter/tests.rs +++ b/crates/effects/src/svg_filter/tests.rs @@ -457,9 +457,48 @@ fn a_filter_at_the_size_limit_draws_with_its_measured_region() { assert_eq!(xml.len(), SvgFilter::MAX_XML); assert_eq!(check(&xml), Ok("r".into())); let f = filter(json!({ "xml": xml })); - let written = f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(); - assert!(written.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", written.len()); - assert!(with_id(&written, DRAWN_ID).is_some(), "the drawn form is read"); + let drawn = f.drawn_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(); + assert!(drawn.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", drawn.len()); + assert!(with_id(&drawn, DRAWN_ID).is_some(), "the drawn form is read"); +} + +/// SVG output writes a filter live only when the form it writes, with its region and id, is one +/// SVG import reads back: at most [`SvgFilter::MAX_XML`] bytes. A filter at the limit whose region +/// is measured against the box, one at the limit whose id is a character long, and one whose +/// shadows make its SVG 1.1 form longer are left to be written as other pixel effects are. +#[test] +fn svg_output_writes_a_filter_only_in_a_form_import_reads() { + let at_limit = |open: &str, close: &str| { + let xml = format!("{open}{}{close}", "a".repeat(SvgFilter::MAX_XML - open.len() - close.len())); + assert_eq!((xml.len(), check(&xml).is_ok()), (SvgFilter::MAX_XML, true)); + filter(json!({ "xml": xml })) + }; + let measured = at_limit( + r#""#, + ); + let short_id = at_limit(r#""#); + let shadows = format!(r#"{}"#, "".repeat(12), "a".repeat(SvgFilter::MAX_XML - 400)); + assert!(check(&shadows).is_ok() && shadows.len() <= SvgFilter::MAX_XML); + for f in [measured, short_id, filter(json!({ "xml": shadows }))] { + assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{} bytes", f.xml().len()); + } + // Room for the id and the longest region: still written, and read back. + let open = r#""#; + let xml = format!("{open}{}{close}", "a".repeat(MAX_WRITTEN_XML - REGION_ROOM - open.len() - close.len())); + let f = filter(json!({ "xml": xml })); + assert!(f.writes_as_svg11()); + let id = format!("u_vc_unique_-filter-{}", usize::MAX); + let out = with_id(&f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(), &id).unwrap(); + assert!(out.len() <= SvgFilter::MAX_XML && check(&out).is_ok(), "{} bytes", out.len()); + // The longest numbers a region can take still fit. + let far = f.written_xml(Rect::new(-f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::MAX / 4.0, f64::MAX / 4.0)).unwrap(); + assert!(with_id(&far, &id).is_some_and(|out| out.len() <= SvgFilter::MAX_XML)); + for v in [-f64::MIN_POSITIVE, -5e-324, -2.225073858507201e-308, f64::MIN, -1.0 / 3.0] { + assert!(format!("{v}").len() <= MAX_NUMBER_TEXT, "{v:e}"); + } + assert_eq!(format!("{}", -f64::MIN_POSITIVE).len(), MAX_NUMBER_TEXT); } /// Only a type selector naming a filter or one of its elements, or `*`, lets a style sheet reach diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 1afa679aa..90c215bf0 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -180,6 +180,24 @@ fn warp_zero_bend_is_identity_ish() { } } +/// `warp::bends` says a warp moves placed gradients exactly when the warp moves the outline: +/// Arc leaves the outline as it is under a bend of 0.0001 % and moves it above that, and the other +/// styles move it at any bend. +#[test] +fn warp_bends_when_the_warp_moves_the_outline() { + // A star: Fisheye moves nothing on the edge of the box, where an ellipse or a rectangle lies. + let oval = shapes::star(Point::new(51.7, 34.3), 40.9, 17.3, 5, 7.0); + for (s, _) in WARP_STYLES { + let id = format!("warp.{s}"); + let flat = run(&id, json!({"bend": 0}), &oval); + for bend in [0.0, 1e-5, -1e-5, 0.9e-4, -0.9e-4, 1.1e-4, -1.1e-4] { + let p = json!({ "bend": bend }); + let moved = run(&id, p.clone(), &oval) != flat; + assert_eq!(crate::warp::bends(&id, &p), moved, "{id} at {bend} %"); + } + } +} + #[test] fn warp_arc_bends_the_top_edge() { let out = run("warp.arc", json!({"bend": 50}), &square()); diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index fb0279ede..84f196a8b 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -32,3 +32,14 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - let piece = b.width().hypot(b.height()) / 24.0; map_nonlinear_within(path, piece, max, f) } + +/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with +/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the +/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline` +/// tests both sides of it). +pub(crate) fn bends(id: &str, p: &Value) -> bool { + let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0; + let bend = share("bend", 50.0); + let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 }; + bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0 +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 75835394a..6ef4918e1 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -356,8 +356,16 @@ pub fn command_specs() -> &'static [CommandSpec] { }) } +/// The command `id`: a built-in one, or a command an installed plug-in adds +/// ([`plugin::command_specs`]). pub fn find_command(id: &str) -> Option<&'static CommandSpec> { - command_specs().iter().find(|c| c.id == id) + command_specs().iter().find(|c| c.id == id).or_else(|| plugin::find_spec(id)) +} + +/// Runs command `spec`'s implementation with `p`. A plug-in command's runs the plug-in +/// ([`plugin::run_command`]), which needs the command's id. +pub(crate) fn run_spec(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result { + if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) } } // ---------- param helpers ---------- diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 02990a662..be452f6c6 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, true) ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, false) ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, reduce_basic ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, set_item ), @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, remove_item ), @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, duplicate_item ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, show_all_hidden ), @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), @@ -628,25 +628,33 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let object = super::graph::capture_series_object_opacity(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; ok() } +/// Each of `ids` whose object opacity or blend mode is another in `d` than in `before`, for +/// [`super::graph::capture_series_object_opacity`]. +fn object_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + let of = |d: &vectorcraft_doc::Document, id: NodeId| d.node(id).map(|n| (n.opacity, n.blend)); + ids.iter().copied().filter(|id| of(before, *id) != of(d, *id)).collect() +} + /// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an /// edit that replaces whole stacks. fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> { ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect() } -/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d` -/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity or blend +/// mode in `d` than in `before` (no row counts as opaque and Normal), for [`super::graph::capture_series_opacity`]. fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| { d.node(id) - .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity)) - .unwrap_or(1.0) + .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(|it| (it.opacity(), it.blend()))) + .unwrap_or((1.0, BlendMode::Normal)) }; ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() } @@ -666,9 +674,10 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator bool { let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| { let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible) }; - let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + let kinds = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten(); - painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind))) + rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)).collect() }; + let written = |d: &vectorcraft_doc::Document, rows: (bool, bool)| painted_rows(d, kinds(rows)); let mut opacity_rows = top_opacity_changed(before, d, ids); + // A written row that had paint before the edit counts too when the edit left it without paint: only a row without + // paint both before and after keeps what the graph stores. + opacity_rows.extend(painted_rows(before, kinds(opacities))); opacity_rows.extend(written(d, opacities)); let mut rows = top_paint_changed(before, d, ids); rows.extend(written(d, paints)); let kept = super::graph::capture_series_opacity(d, &opacity_rows); let painted = super::graph::capture_series_top_paints(d, &rows); - kept || painted + let objects = if object { ids.to_vec() } else { object_opacity_changed(before, d, ids) }; + let stored = super::graph::capture_series_object_opacity(d, &objects); + kept || painted || stored } /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. @@ -743,7 +759,8 @@ fn transfer(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &changed); rows.extend(painted_rows(d, both_kinds(&[target]))); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted)) + let object = super::graph::capture_series_object_opacity(d, &changed); + Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -862,10 +879,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result { edited.push((*id, fill)); } } - // The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when - // it is drawn again, and hiding or showing a row changes which row that is. + // The graph keeps an opacity, blend mode, paint or weight set on the topmost visible fill or stroke of its series art + // for when it is drawn again, and hiding or showing a row changes which row that is. let mut opacities = top_opacity_changed(&before, d, &ids); - if p.get("opacity").and_then(Value::as_f64).is_some() { + if p.get("opacity").and_then(Value::as_f64).is_some() || blend.is_some() { opacities.extend(edited.iter().copied()); } let kept = super::graph::capture_series_opacity(d, &opacities); @@ -1347,7 +1364,10 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { rows.extend(painted_rows(d, written(paints))); let kept = super::graph::capture_series_opacity(d, &opacity_rows); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &targets) + usize::from(kept || painted)) + // A pick of the object's transparency writes it on every target. + let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) }; + let object = super::graph::capture_series_object_opacity(d, &objects); + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 5dcb82136..53f373a3b 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -263,7 +263,7 @@ pub fn specs() -> Vec { "Symbol Instance Appearance", [], None, - "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}", + "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol, a path naming no object, or a fill or stroke other than null for an object that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect), with nothing of the call applied; opacity, blend and null are taken for every object → {id, overrides: how many objects the instance changes}", has_selection, symbol_instance_set_appearance ), diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index 6197e57be..89c7c3c52 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -129,6 +129,64 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { Ok(path) } +/// The refusal of `key` (`fill` or `stroke`) for `n`, an object of a dynamic symbol's art, when `n` +/// does not draw that paint of its own; `None` when it does. Paths (not guides), +/// compound paths, type, groups and layers draw them; a symbol instance draws its fill as a stain +/// on its art ([`solid_fill_check`]) and no stroke; an image draws one only when that fill or +/// stroke has a Convert to Shape or Outline Object effect. +fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option { + Some(match &n.kind { + NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")), + NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None, + NodeKind::Image(_) => { + let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) }; + if effects.is_some_and(|e| vectorcraft_render::effects::gives_image_shape(e)) { + return None; + } + bad(cmd, format!("{key} cannot be set on an image without a Convert to Shape or Outline Object effect on it")) + } + NodeKind::SymbolInstance { .. } if key == "fill" => return None, + NodeKind::SymbolInstance { .. } => bad(cmd, "stroke cannot be set on a symbol instance"), + NodeKind::Blend { .. } => bad(cmd, format!("{key} cannot be set on a blend")), + NodeKind::Envelope { .. } => bad(cmd, format!("{key} cannot be set on an envelope")), + NodeKind::Mesh(_) => bad(cmd, format!("{key} cannot be set on a gradient mesh")), + NodeKind::Repeat(_) => bad(cmd, format!("{key} cannot be set on a Repeat")), + NodeKind::PlacedDocument(_) => bad(cmd, format!("{key} cannot be set on a placed document")), + NodeKind::Intertwine { .. } => bad(cmd, format!("{key} cannot be set on an Intertwine group")), + }) +} + +/// Refuse a `fill` or `stroke` other than null that the object at `path` of `art` does not draw +/// ([`paint_refusal`]); null, which takes a change back, is taken for every object. +fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<()> { + let Some(n) = symbol_overrides::part(art, path) else { return Ok(()) }; + for key in ["fill", "stroke"] { + if p.get(key).is_some_and(|v| !v.is_null()) + && let Some(e) = paint_refusal(n, key, cmd) + { + return Err(e); + } + } + Ok(()) +} + +/// Refuse `fill`, the paint `p` asks for the object at `path` of `art`, when that object is a +/// symbol instance and `fill` is a gradient or a pattern: an instance stains its art with a solid +/// fill only, so such a fill draws nothing. +fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> { + let asked = p.get("fill").is_some_and(|v| !v.is_null()); + let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })); + let draws_nothing = |f: &vectorcraft_color::Paint| match f { + vectorcraft_color::Paint::Gradient(_) => true, + vectorcraft_color::Paint::Pattern { .. } => true, + vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false, + }; + if asked && is_instance && fill.is_some_and(draws_nothing) { + return Err(bad(cmd, "fill on a symbol instance must be a solid color or none")); + } + Ok(()) +} + /// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or /// an object of `paint.setFill`'s paint params sets it. fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result>> { @@ -170,9 +228,11 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu } let (id, art) = dynamic_instance(s, p, C)?; let path = path_param(p, &art, C)?; + check_paint_kinds(p, &art, &path, C)?; let d = &s.doc()?.doc; let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; + solid_fill_check(p, &art, &path, new.fill.as_ref(), C)?; let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?; Ok(json!({ "id": id.0, "overrides": count })) } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 1b37ca96f..d39244924 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), @@ -153,7 +153,7 @@ pub fn specs() -> Vec { "Make and Expand", ["Object", "Image Trace"], None, - "{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors}", + "{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors, warnings: [text…] (as imageTrace.make)}", has_selection_or_ids, |s, p| trace_make(s, p, true) ), diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 1924ed405..9c6c28b90 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -712,7 +712,8 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result trace_now(&inputs, &trace, &bytes, &|_, _| true)?, }; let TraceInputs { target, image: img, .. } = inputs; - let warnings = trace_warnings((img.width, img.height), raster, res.worked_at); + let mut warnings = keep_warnings(&trace, &res); + warnings.extend(trace_warnings((img.width, img.height), raster, res.worked_at)); // Pixel space of the decoded raster → the image object's pixel space → document. let sx = img.width.max(1) as f64 / raster.0.max(1) as f64; let sy = img.height.max(1) as f64 / raster.1.max(1) as f64; @@ -824,6 +825,30 @@ pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32), worked: Optio out } +/// What a trace with Keep Colors tells the user: that nothing was traced because no color the +/// trace found matches a listed one, because Ignore Color left out the ones that match, or because +/// the ones that match produce no paths; each listed color that matches none of them; and that Flat +/// Logo mode does not use the list. +pub(crate) fn keep_warnings(params: &tr::TraceParams, res: &tr::TraceResult) -> Vec { + if params.keep_colors.is_empty() { + return vec![]; + } + if params.mode == tr::Mode::Logo { + return vec!["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds.".to_string()]; + } + if res.paths.is_empty() && res.keep_unmatched.len() == params.keep_colors.len() { + return vec![format!("Nothing was traced: none of the colors the trace found matches keepColors ({}).", params.keep_colors.join(", "))]; + } + let mut out = vec![]; + if res.paths.is_empty() && res.keep_ignored { + out.push("Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()); + } else if res.paths.is_empty() { + out.push("Nothing was traced: the colors that match keepColors produce no paths.".to_string()); + } + out.extend(res.keep_unmatched.iter().map(|c| format!("keepColors: none of the colors the trace found matches {c}, so nothing of it is traced."))); + out +} + fn trace_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; let v: Vec = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect(); @@ -1069,7 +1094,25 @@ mod color_group_tests { #[cfg(test)] mod trace_limit_tests { - use super::{MAX_TRACE_ANCHORS, trace_warnings}; + use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings}; + use vectorcraft_trace as tr; + + #[test] + fn keep_warnings_name_matched_colors_that_produce_no_paths() { + let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() }; + // A listed color was traced, and produced no paths: the palette lists no color without paths. + let traced = tr::TraceResult::default(); + let none = vec!["Nothing was traced: the colors that match keepColors produce no paths.".to_string()]; + assert_eq!(keep_warnings(¶ms, &traced), none); + // With Ignore Color, too: Ignore Color did not leave that color out. + let ignoring = tr::TraceParams { ignore_white: true, ..params.clone() }; + assert_eq!(keep_warnings(&ignoring, &traced), none); + // Ignore Color left out every color that matches: none was traced. + assert_eq!( + keep_warnings(&ignoring, &tr::TraceResult { keep_ignored: true, ..tr::TraceResult::default() }), + vec!["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()] + ); + } #[test] fn trace_warnings_name_a_large_or_scaled_down_image() { diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs index 2b356ab85..3925612a1 100644 --- a/crates/engine/src/cmd/compositefonts.rs +++ b/crates/engine/src/cmd/compositefonts.rs @@ -71,24 +71,39 @@ pub fn specs() -> Vec { /// 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 { +/// not define shows as missing. The lowercased names of the composite fonts added, changed or +/// removed. +pub(crate) fn sync(s: &mut Session) -> Vec { let db = FontDb::global(); let mut names = Vec::new(); - let mut changed = false; + let mut changed = Vec::new(); 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 name = f.name.to_lowercase(); + if db.set_composite_font(&f) { + changed.push(name.clone()); + } + names.push(name); } 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); + if db.remove_composite_font(&gone) { + changed.push(gone); + } } + s.composite_names_registered.extend(names.iter().chain(&changed).cloned()); s.composite_names = names; changed } +/// The lowercased names of the composite fonts the open documents define, the font database holds +/// and the session has registered before: the names type may have been laid out in. +pub(crate) fn names_in_use(s: &Session) -> std::collections::HashSet { + let defined = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + let held = FontDb::global().composite_font_names().into_iter().map(|n| n.to_lowercase()); + defined.chain(held).chain(s.composite_names_registered.iter().cloned()).collect() +} + /// 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(); @@ -172,6 +187,9 @@ fn replace(s: &mut Session, label: &str, after: Vec) -> Result<() } 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(); + // A refused or panicked edit leaves the document without the new fonts: `sync` forgets a name + // listed here that the document doesn't define. + s.composite_names.extend(after.iter().map(|f| f.name.to_lowercase())); let db = FontDb::global(); for f in &before { if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) { diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index 670a85ee2..f51f066df 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -395,6 +395,150 @@ fn opened_document_measures_missing_composite_font_as_missing() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// The native file of a document whose composite font `name` sets Roman at full size, with type +/// "Ab12" set in it → (the file, the type's id, its width). +fn full_roman_file(name: &str) -> (Vec, u64, f64) { + let mut s = session(); + let mut full = font(name); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": name})).unwrap()["id"].as_u64().unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + (vectorcraft_format::save_file(&s.doc().unwrap().doc), id, width) +} + +/// Type in a missing font that no composite font is named after keeps the bounds it was measured +/// with when its file was read: opening the document does not lay it out again. +#[test] +fn opening_leaves_type_in_an_unrelated_missing_font_as_measured() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Unrelated")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "No Such Family CF"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let kept = vectorcraft_geom::Rect::new(1.0, 2.0, 3.0, 4.0); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = Some(kept); + } + s.add_document(doc, None); + assert_eq!(text(&s, id).cached_bounds, Some(kept)); +} + +/// Text holding an inline graphic whose symbol's art is type in a composite font is measured with +/// the graphic at the size the opened document's own definition gives it. +#[test] +fn opening_measures_inline_graphics_of_composite_type_in_its_own_definition() { + let mut s = session(); + let mut full = font("CF Inline"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let art = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Inline"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Inline Mark", "ids": [art]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "x", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "CF Inline Mark"})).unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Inline")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("inline.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - width).abs() < 1e-6); +} + +/// Type read for opening while a composite font of its name was registered is measured again when +/// it opens, even after the document that defined the font has closed in between. +#[test] +fn opening_measures_type_read_in_a_composite_font_closed_since() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Gone"})).unwrap()["id"].as_u64().unwrap(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Gone")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("gone.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + assert!(s.close_document(1)); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + 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 read for opening lays out its type in the composite fonts registered then (the front +/// document's, which defines the name with Roman at half size); once the file opens, its type is +/// measured again in its own. +#[test] +fn an_opened_file_measures_its_type_in_its_own_composite_fonts() { + let (bytes, id, width) = full_roman_file("CF Opened"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Opened")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("opened.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A document replaced while in the back (File › Revert) is measured in its own composite fonts +/// when it comes to the front, not in the front document's. +#[test] +fn a_document_replaced_in_the_back_is_measured_in_its_own_composite_fonts() { + let (bytes, id, width) = full_roman_file("CF Replaced"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!(s.set_active(0)); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// So is one that comes to the front because the front document closes. +#[test] +fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() { + let (bytes, id, width) = full_roman_file("CF Closed"); + let mut s = session(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Closed")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!(s.close_document(1)); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A failed batch takes back the composite fonts it set, in the font database too. +#[test] +fn a_failed_batch_takes_back_its_composite_fonts() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let steps = json!([ + {"command": "type.compositeFonts.set", "params": {"fonts": [font("CF Failed Batch")]}}, + {"command": "no.such.command"}, + ]); + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert!(names(&s).is_empty()); + assert!(db.composite_font("CF Failed Batch").is_none()); +} + +/// A composite font whose edit is refused, because it makes selected type too wide for the canvas, +/// is not left in the font database. +#[test] +fn a_refused_edit_takes_back_its_new_composite_font() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let x = crate::MAX_COORD - 1000.0; + s.execute("text.create", &json!({"x": x, "y": 50, "text": "MMMMMMMM", "size": 100, "font": "CF Refused Edit"})).unwrap(); + let wide = json!({"name": "CF Refused Edit", "parts": {"kanji": {"font": "Source Sans 3", "size": 400}}}); + let e = s.execute("type.compositeFonts.set", &json!({"fonts": [wide]})).unwrap_err().to_string(); + assert!(e.contains("exceed the canvas"), "{e}"); + assert!(names(&s).is_empty()); + assert!(db.composite_font("CF Refused Edit").is_none()); +} + /// A loaded composite font cannot replace an installed family used by existing type. #[test] fn loaded_composite_font_cannot_take_an_installed_family_name() { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bf6b673ff..b721fc0ed 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -157,7 +157,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut); refused while an object of a symbol instance's art is selected", + "{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected", has_selection_guides_or_artboard_tool, cut ), @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", + "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -946,15 +946,27 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { Ok((direct, roots)) } -/// The params edit.copy and edit.cut pass on to `artboard.copy` and `artboard.cut`. -fn artboard_clipboard_params(p: &Value) -> Value { - p.get("lockedAndHidden").map_or_else(|| json!({}), |v| json!({ "lockedAndHidden": v })) +/// Copy or Cut takes artboards: the Artboard tool is chosen, or the journal recorded the artboards +/// it took then. +fn takes_artboards(s: &Session, p: &Value) -> bool { + s.tool_id() == "artboard" || p.get("selectedArtboards").is_some() +} + +/// Copy or Cut of artboards: `selectedArtboards`, `art` and `lockedAndHidden` of `p`, or their +/// defaults from the Artboard tool, which the journal entry records. +fn copy_artboards(s: &mut Session, p: &Value, cut: bool) -> Result { + let given: serde_json::Map = + ["selectedArtboards", "art", "lockedAndHidden"].into_iter().filter_map(|key| p.get(key).map(|v| (key.to_string(), v.clone()))).collect(); + let given = Value::Object(given); + super::panelcmds::boards_and_art_noted(s, &given, if cut { "edit.cut" } else { "edit.copy" })?; + super::panelcmds::artboard_copy(s, &given, cut) } fn copy(s: &mut Session, p: &Value) -> Result { + switch_or(p, "art", false, "edit.copy")?; // The Artboard tool copies its artboard (and the art on it). - if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false); + if takes_artboards(s, p) { + return copy_artboards(s, p, false); } // With an object of a dynamic symbol instance's art selected, the instance is copied. let (direct, targets) = copy_cut_targets(s)?; @@ -994,8 +1006,9 @@ fn copy(s: &mut Session, p: &Value) -> Result { } fn cut(s: &mut Session, p: &Value) -> Result { - if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true); + switch_or(p, "art", false, "edit.cut")?; + if takes_artboards(s, p) { + return copy_artboards(s, p, true); } refuse_symbol_part(s)?; // Ruler guides selected on their own: Copy takes them, Clear removes them. diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index de056e4ec..b47b9e797 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -470,7 +470,7 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F /// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its /// path (a plain path then), and every geometry effect of its own removed. `None` when it has no /// visible geometry effect. -fn reshaped(l: &Node) -> Option { +pub(crate) fn reshaped(l: &Node) -> Option { use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry}; if !has_geometry(&l.appearance.effects) { return None; @@ -490,7 +490,7 @@ fn reshaped(l: &Node) -> Option { /// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they /// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along /// with the outline. -fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> { +pub(crate) fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> { use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry}; if !has_geometry(item.effects()) { return Some((l.clone(), path.clone(), bounds, item.clone())); @@ -996,10 +996,13 @@ fn moves_only(a: Affine) -> bool { (m0 - 1.0).abs() <= eps && m1.abs() <= eps && m2.abs() <= eps && (m3 - 1.0).abs() <= eps } +/// Is `e` a visible Transform effect with Random on (which takes its seed from the object's id)? +pub(crate) fn seeded(e: &vectorcraft_doc::Effect) -> bool { + e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true) +} + /// Does `n` have a visible Transform effect with Random on, its own or a fill's or stroke's? fn seeded_here(n: &Node) -> bool { - let seeded = - |e: &vectorcraft_doc::Effect| e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true); n.appearance.effects.iter().any(seeded) || n.appearance.items.iter().any(|i| i.effects().iter().any(seeded)) } diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index ded4dcd5d..479877992 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -120,7 +120,7 @@ const ONE_FILE_WARNING: &str = "each data set saves over the same file, which ke /// `path`; a Save step without a `path` when the document has a file of its own (saved, and not /// converted from an older version or restored by Data Recovery) and the step gives no `format` or /// the document's own; an Export for Screens step whose `folder`, given or from its -/// `exportPreset`, names a folder, whatever the overrides. +/// `exportPreset`, names a folder, when Override Export does not give each data set its own folder. /// Other steps return their data instead of writing a file. pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { if p.get("source").and_then(Value::as_str) != Some("datasets") { @@ -150,8 +150,8 @@ pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { "document.export" | "document.exportSelection" => !export && path, // The batch does not point these exports into `destinationFolder`. "document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path, - // Override Export sets `path`, which Export for Screens does not read: its `folder` stays. - "document.exportForScreens" => super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), + // Override Export gives Export for Screens a folder for each data set; otherwise its `folder` stays. + "document.exportForScreens" => !export && super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), _ => false, } }) @@ -196,14 +196,29 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: if files.is_empty() { return Err(bad(cmd, "no readable files in the folder")); } + // Each file's name without its extension, through [`source_stem`] (`a..svg` gives `a`, the + // folder Windows makes for `a.`, and `a\b.svg` keeps `a\b` where `\` is not a separator), with + // `-2`, `-3`… after a name an earlier file used (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file + // wrote ([`StemPicker`]), as data set names are numbered. A file's stem is picked once it is + // open, so its artboard files count ([`dataset_output_paths`]). No output takes the name of a + // source file in the destination folder, other than that file's own output, or of a subfolder + // of the destination folder that holds source files. let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; + let in_dest: Vec> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect(); + let mut picker = + StemPicker { stems: std::collections::HashSet::new(), written: in_dest.iter().flatten().map(|(name, _)| name.clone()).collect() }; let mut processed = Vec::new(); let mut failed = 0usize; - for path in &files { - let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file"); + for (path, place) in files.iter().zip(&in_dest) { let ext = super::extension(path); - match process_one_file(s, &run, path, stem, &ext) { + let base = source_stem(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone()); + let pick = |doc: &vectorcraft_doc::Document| { + let outputs = dataset_output_extensions(&run, doc, &ext); + picker.pick(&base, |stem| output_names(run.dest_folder, stem, &outputs, doc), own.as_deref()) + }; + match process_one_file(s, &run, path, &ext, pick) { Ok(row) => processed.push(row), Err(e) => { failed += 1; @@ -260,6 +275,68 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false})) } +/// The output stem for a source file's name without its extension: without trailing periods and +/// spaces and with `-` after a Windows device name ([`super::screens::name_without_device`]), and +/// "file" when nothing is left. Other characters stay, so `a\b` and `a:b` keep their names on Linux +/// and macOS. +#[cfg(not(target_arch = "wasm32"))] +fn source_stem(stem: &str) -> String { + let stem = super::screens::name_without_device(stem); + if stem.is_empty() { "file".to_string() } else { stem } +} + +/// The names in lower case of the files and folders that a source with the stem `stem` writes +/// into the destination folder ([`dataset_output_paths`]), artboard files included. +#[cfg(not(target_arch = "wasm32"))] +fn output_names(folder: Option<&str>, stem: &str, outputs: &[DatasetOutput], doc: &vectorcraft_doc::Document) -> Vec { + dataset_output_paths(folder, stem, outputs, doc) + .iter() + .filter_map(|path| path.file_name().and_then(|name| name.to_str()).map(str::to_lowercase)) + .collect() +} + +/// When the file at `path` is inside `folder`: the name in lower case of the file, when it is +/// directly inside, or of the subfolder of `folder` that holds it, and whether it is the file's own. +#[cfg(not(target_arch = "wasm32"))] +fn name_in_folder(path: &str, folder: Option<&str>) -> Option<(String, bool)> { + let folder = std::fs::canonicalize(folder?).ok()?; + let path = std::fs::canonicalize(path).ok()?; + let mut parts = path.strip_prefix(&folder).ok()?.components(); + let first = parts.next()?.as_os_str().to_str()?.to_lowercase(); + Some((first, parts.next().is_none())) +} + +/// Picks the stem of each folder source in turn: its name, with `-2`, `-3`… after it when an +/// earlier source used it or when one of the files or the folder it writes has the name of a file +/// or folder an earlier source wrote (`a.svg` writes the file `a.svg`, so the Export for Screens +/// folder of `a.svg.svg` is `a.svg-2`) or a name in `written` from the start (the source files and +/// subfolders in the destination folder) other than its own file's. Names are compared without case. +#[cfg(not(target_arch = "wasm32"))] +struct StemPicker { + stems: std::collections::HashSet, + written: std::collections::HashSet, +} + +#[cfg(not(target_arch = "wasm32"))] +impl StemPicker { + /// `names` gives the names in lower case of what a stem writes; `own` is the source's own name. + fn pick(&mut self, base: &str, names: impl Fn(&str) -> Vec, own: Option<&str>) -> String { + let mut name = base.to_string(); + let mut n = 1usize; + loop { + let lower = name.to_lowercase(); + let outputs = names(&name); + if !self.stems.contains(&lower) && outputs.iter().all(|f| !self.written.contains(f) || own == Some(f.as_str())) { + self.stems.insert(lower); + self.written.extend(outputs); + return name; + } + n += 1; + name = format!("{base}-{n}"); + } + } +} + /// Drop Open (and optionally Close) steps when the batch supplies the file itself. #[cfg(not(target_arch = "wasm32"))] fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec { @@ -279,12 +356,19 @@ fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec } #[cfg(not(target_arch = "wasm32"))] -fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext: &str) -> Result { +fn process_one_file( + s: &mut Session, + run: &Run<'_>, + path: &str, + ext: &str, + stem_for: impl FnOnce(&vectorcraft_doc::Document) -> String, +) -> Result { s.execute("document.open", &json!({"path": path}))?; - let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, ext); + let stem = stem_for(&s.doc()?.doc); + let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, ext); let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?; // Folder sources open a file per pass: close after Save and Close / Folder. - finish_destination(s, run, stem, ext, path, true)?; + finish_destination(s, run, &stem, ext, path, true)?; Ok(json!({"path": path, "result": batch})) } @@ -349,10 +433,12 @@ fn dataset_stems(names: &[String], doc_stem: &str, naming: &str, run: &Run<'_>, "fileNumber" => ordinal, "dataSetName" if !safe_data_set.is_empty() => safe_data_set, "dataSetName" => ordinal, - _ => format!("{doc_stem}_{safe}"), + // Without trailing periods and spaces, which Windows drops from a folder name, so the + // Export for Screens folders of `Set.` and `Set` are numbered apart. + _ => safe_file_name(&format!("{doc_stem}_{safe}")), } }); - let outputs = dataset_output_extensions(run, doc); + let outputs = dataset_output_extensions(run, doc, "vectorcraft"); let mut taken = std::collections::HashSet::new(); let mut reserved = std::collections::HashSet::new(); let mut stems = Vec::with_capacity(bases.len()); @@ -384,30 +470,39 @@ struct DatasetOutput { ext: String, artboards: bool, joiner: &'static str, + /// An Export for Screens or Export Assets step writes into the folder `{stem}` instead. + folder: bool, } /// Lists step output extensions and reserves artboard file names for saves with any artboard or /// exports with multiple artboards. Per-artboard names count for vectorcraft, template, AI and AIT -/// saves and SVG, SVGZ, EPS, raster, DXF, EMF, WMF and AI exports. +/// saves and SVG, SVGZ, EPS, raster, DXF, EMF, WMF and AI exports. An Export for Screens or Export +/// Assets step reserves the folder `{stem}` ([`rewrite_paths`]). A step without a format and the +/// Folder destination write `default_ext` (`vectorcraft` when it is empty). #[cfg(not(target_arch = "wasm32"))] -fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) -> Vec { +fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, default_ext: &str) -> Vec { + let default_ext = if default_ext.is_empty() { "vectorcraft" } else { default_ext }; let mut outputs = Vec::new(); if run.destination == "folder" { - outputs.push(DatasetOutput { ext: "vectorcraft".into(), artboards: false, joiner: "-" }); + outputs.push(DatasetOutput { ext: default_ext.into(), artboards: false, joiner: "-", folder: false }); } for step in run.steps { let id = step.get("command").and_then(Value::as_str).unwrap_or(""); + if run.override_export && matches!(id, "document.exportForScreens" | "assets.export") { + outputs.push(DatasetOutput { ext: String::new(), artboards: false, joiner: "-", folder: true }); + continue; + } let writes = (run.override_save && matches!(id, "document.save" | "file.save" | "file.saveAs")) - || (run.override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection")); + || (run.override_export && matches!(id, "document.export" | "document.exportSelection")); if writes { let format = step.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()); let ext = match format { Some(format) if super::format(format).is_some() => format_extension(format), Some(_) => continue, - None => "vectorcraft".to_string(), + None => default_ext.to_string(), }; if safe_extension(&ext) { - let format_id = format.and_then(super::format).map(|f| f.id).unwrap_or("vectorcraft"); + let format_id = super::format(format.unwrap_or(default_ext)).map(|f| f.id).unwrap_or("vectorcraft"); let is_save = matches!(id, "document.save" | "file.save" | "file.saveAs"); let artboards = if is_save { !doc.artboards.is_empty() && matches!(format_id, "vectorcraft" | "template" | "ai" | "ait") @@ -434,7 +529,7 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) -> | "ai" ) }; - outputs.push(DatasetOutput { ext, artboards, joiner: if format_id == "eps" { "_" } else { "-" } }); + outputs.push(DatasetOutput { ext, artboards, joiner: if format_id == "eps" { "_" } else { "-" }, folder: false }); } } } @@ -463,6 +558,10 @@ fn dataset_output_paths(folder: Option<&str>, stem: &str, outputs: &[DatasetOutp } let mut paths = Vec::new(); for output in outputs { + if output.folder { + paths.extend(output_folder(folder, stem)); + continue; + } if !safe_extension(&output.ext) || super::format(&output.ext).is_none() { continue; } @@ -497,6 +596,18 @@ fn output_path(folder: &str, stem: &str, ext: &str) -> Option Option { + use std::path::Path; + if stem.is_empty() || matches!(stem, "." | "..") || stem.contains(std::path::is_separator) { + return None; + } + let path = Path::new(folder).join(stem); + (path.parent() == Some(Path::new(folder))).then_some(path) +} + /// Save when the extension is a Save format; otherwise export (PNG, JPEG…). /// `as_copy`: `file.saveCopy` so the open document keeps its path (data-set batches). #[cfg(not(target_arch = "wasm32"))] @@ -513,7 +624,9 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result } /// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the -/// extension of the step's `format` ([`format_extension`]), else `default_ext`. +/// extension of the step's `format` ([`format_extension`]), else `default_ext`. Export for Screens +/// and Export Assets steps get `dest_folder/{stem}` as their `folder`, one folder for each source +/// file or data set, and name the files in it themselves. #[cfg(not(target_arch = "wasm32"))] fn rewrite_paths( steps: &[Value], @@ -532,21 +645,26 @@ fn rewrite_paths( let id = c.get("command").and_then(Value::as_str).unwrap_or(""); let mut c = c.clone(); let rewrite = (override_save && matches!(id, "document.save" | "file.save" | "file.saveAs")) - || (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection")); + || (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection" | "assets.export")); if !rewrite { return c; } let params = c.get("params").cloned().unwrap_or(json!({})); let mut params = params.as_object().cloned().unwrap_or_default(); - let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { - Some(f) => format_extension(f), - None => default_ext.to_string(), - }; - if params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()).is_some_and(|f| super::format(f).is_none()) { - return c; + if matches!(id, "document.exportForScreens" | "assets.export") { + let Some(dir) = output_folder(folder, stem) else { return c }; + params.insert("folder".into(), json!(dir.to_string_lossy())); + } else { + let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { + Some(f) => format_extension(f), + None => default_ext.to_string(), + }; + if params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()).is_some_and(|f| super::format(f).is_none()) { + return c; + } + let Some(file) = output_path(folder, stem, &ext) else { return c }; + params.insert("path".into(), json!(file.to_string_lossy())); } - let Some(file) = output_path(folder, stem, &ext) else { return c }; - params.insert("path".into(), json!(file.to_string_lossy())); if let Some(o) = c.as_object_mut() { o.insert("params".into(), Value::Object(params)); } @@ -723,6 +841,15 @@ mod tests { assert_eq!(file_names(&dest), ["a\\b.svg"], "{r}"); } + /// A source stem keeps `\`, `:` and the other characters Windows does not allow in a name, which + /// only reach the batch where the file system allows them, and loses trailing periods and spaces. + #[test] + fn source_stem_keeps_characters_the_file_system_allowed() { + for (stem, want) in [("a\\b", "a\\b"), ("a:b", "a:b"), ("a.", "a"), ("a. ", "a"), ("CON", "CON-"), ("...", "file"), ("", "file")] { + assert_eq!(source_stem(stem), want, "{stem:?}"); + } + } + #[test] fn output_path_accepts_only_platform_file_name_separators() { let folder = tmp("output-path"); @@ -806,6 +933,319 @@ mod tests { assert!(file_names(&dest).is_empty(), "{r}"); } + /// Override Export writes an Export for Screens step's files into a folder for each source file + /// inside the destination folder, under the names the export gives them, whether the step names + /// a folder of its own, takes one from an export preset or names none. With Override Export off, + /// the files go to the step's folder. + #[test] + fn batch_override_export_sends_export_for_screens_files_to_the_destination_folder() { + let src = tmp("screens-src"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "b.svg", "#0f0"); + let own = tmp("screens-own"); + let svg = json!([{"format": "svg"}]); + let preset = json!({"name": "Own Folder", "settings": {"formats": svg, "folder": own.to_string_lossy()}}); + let steps = [json!({"folder": own.to_string_lossy(), "formats": svg}), json!({"exportPreset": "Own Folder"}), json!({"formats": svg})]; + for (i, params) in steps.into_iter().enumerate() { + let dest = tmp(&format!("screens-out-{i}")); + let mut s = Session::new(); + s.execute("document.exportPresets.save", &preset).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": params}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{params}: {r}"); + assert_eq!(file_names(&dest), ["a", "b"], "{params}: {r}"); + for stem in ["a", "b"] { + assert_eq!(file_names(&dest.join(stem)), ["Artboard-1.svg"], "{params}: {r}"); + } + assert!(file_names(&own).is_empty(), "{params}: {r}"); + } + let dest = tmp("screens-out-off"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "overrideExport": false, + "commands": [{"command": "document.exportForScreens", "params": {"folder": own.to_string_lossy(), "formats": svg}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&own), ["Artboard-1.svg"], "{r}"); + assert!(file_names(&dest).is_empty(), "{r}"); + } + + /// Two source files with the same name in different subfolders get Export for Screens folders of + /// their own: the second one's folder is numbered, as a repeated data set name is. + #[test] + fn batch_override_export_numbers_export_for_screens_folders_of_files_with_the_same_name() { + let src = tmp("screens-same-src"); + std::fs::create_dir_all(src.join("sub")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("sub"), "a.svg", "#0f0"); + let dest = tmp("screens-same-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + for folder in file_names(&dest) { + println!("{folder}/: {:?}", file_names(&dest.join(&folder))); + } + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["a", "a-2"], "{r}"); + for (folder, fill) in [("a", "#ff0000"), ("a-2", "#00ff00")] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap(); + assert!(svg.contains(fill), "{folder}: {svg}"); + } + } + + /// A source file whose name without its extension ends in a period (`a..svg`) gets the folder + /// name without it, numbered after the folder of `a.svg`: Windows drops the period from a folder + /// name, so `a.` and `a` would be one folder there. + #[test] + fn batch_override_export_numbers_folders_that_differ_by_a_trailing_period() { + let src = tmp("screens-period-src"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "a..svg", "#0f0"); + let dest = tmp("screens-period-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["a", "a-2"], "{r}"); + for folder in ["a", "a-2"] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + } + } + + /// A source file's Export for Screens folder does not take the name of a file the Folder + /// destination saved for an earlier source: `a.svg` is saved as `a.svg`, so the folder of + /// `a.svg.svg` is numbered. + #[test] + fn batch_override_export_numbers_folders_named_like_a_saved_file() { + let src = tmp("screens-file-src"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "a.svg.svg", "#0f0"); + let dest = tmp("screens-file-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["a", "a.svg", "a.svg-2", "a.svg-2.svg"], "{r}"); + for folder in ["a", "a.svg-2"] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + } + } + + /// With the destination folder the same as the source folder, a file's output does not replace + /// another source file before Batch opens it: `a..svg` would be saved as `a.svg`, so it is saved + /// as `a-2.svg`, and `a.svg` is saved over itself. + #[test] + fn batch_does_not_save_over_another_source_file_in_the_destination_folder() { + let src = tmp("in-place"); + write_svg(&src, "a..svg", "#f00"); + write_svg(&src, "a.svg", "#0f0"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": src.to_string_lossy(), + "commands": [{"command": "object.unlockAll", "params": {}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&src), ["a-2.svg", "a..svg", "a.svg"], "{r}"); + for (file, fill) in [("a-2.svg", "#ff0000"), ("a.svg", "#00ff00")] { + let svg = std::fs::read_to_string(src.join(file)).unwrap(); + assert!(svg.contains(fill), "{file}: {svg}"); + } + } + + /// With the destination folder the same as the source folder and `includeSubdirs`, an Export for + /// Screens folder does not take the name of a subfolder that holds source files: the folder of + /// `a.svg` is `a-2`, so `a/Artboard-1.svg` is opened as it was. + #[test] + fn batch_does_not_export_into_a_source_subfolder() { + let src = tmp("in-place-sub"); + std::fs::create_dir_all(src.join("a")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("a"), "Artboard-1.svg", "#0f0"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destinationFolder": src.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + let source = std::fs::read_to_string(src.join("a").join("Artboard-1.svg")).unwrap(); + assert!(source.contains("fill=\"#0f0\""), "{r}: {source}"); + let export = std::fs::read_to_string(src.join("a-2").join("Artboard-1.svg")).unwrap(); + assert!(export.contains("#ff0000"), "{r}: {export}"); + } + + /// With the destination folder the same as the source folder, a save's artboard files do not + /// replace another source file: `a.vectorcraft` would also write `a-Artboard-1.vectorcraft`, so + /// it is saved as `a-2`. + #[test] + fn batch_does_not_save_artboard_files_over_another_source_file() { + let src = tmp("in-place-artboards"); + let mut s = Session::new(); + for (name, width) in [("a.vectorcraft", 20), ("a-Artboard-1.vectorcraft", 30)] { + s.execute("file.new", &json!({"width": width, "height": 20, "artboards": 1})).unwrap(); + s.execute("document.save", &json!({"path": src.join(name).to_string_lossy()})).unwrap(); + } + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": src.to_string_lossy(), + "commands": [{"command": "document.save", "params": {"separateArtboards": true}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!( + file_names(&src), + ["a-2-Artboard-1.vectorcraft", "a-2.vectorcraft", "a-Artboard-1-Artboard-1.vectorcraft", "a-Artboard-1.vectorcraft", "a.vectorcraft"], + "{r}" + ); + s.execute("document.open", &json!({"path": src.join("a-Artboard-1.vectorcraft").to_string_lossy()})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 30.0, "{r}"); + } + + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets + /// folders of their own: the second one's folder is numbered. + #[test] + fn batch_override_export_numbers_data_set_folders_that_differ_by_a_trailing_period() { + let dest = tmp("assets-period-out"); + let mut s = with_data_sets(&["a", "a."]); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("assets.add", &json!({"ids": [id], "name": "Logo"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "assets.export", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_a-2"], "{r}"); + for folder in ["Untitled_a", "Untitled_a-2"] { + assert_eq!(file_names(&dest.join(folder)), ["Logo.svg"], "{r}"); + } + } + + /// Two source files with the same name in different subfolders are saved under names of their + /// own, by Override Save and by the Folder destination: the second one's name is numbered. + #[test] + fn batch_numbers_saved_files_of_source_files_with_the_same_name() { + let src = tmp("save-same-src"); + std::fs::create_dir_all(src.join("sub")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("sub"), "a.svg", "#0f0"); + let save = json!([{"command": "file.saveAs", "params": {"format": "svg"}}]); + let unlock = json!([{"command": "object.unlockAll", "params": {}}]); + for (tag, destination, commands) in [("save", "none", save), ("folder", "folder", unlock)] { + let dest = tmp(&format!("save-same-{tag}")); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destination": destination, + "destinationFolder": dest.to_string_lossy(), + "commands": commands + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{tag}: {r}"); + assert_eq!(file_names(&dest), ["a-2.svg", "a.svg"], "{tag}: {r}"); + for (file, fill) in [("a.svg", "#ff0000"), ("a-2.svg", "#00ff00")] { + let svg = std::fs::read_to_string(dest.join(file)).unwrap(); + assert!(svg.contains(fill), "{tag}: {file}: {svg}"); + } + } + } + + /// Override Export writes an Export Assets step's files into a folder for each data set inside + /// the destination folder, under the assets' names. + #[test] + fn batch_override_export_sends_asset_export_files_to_the_destination_folder() { + let own = tmp("assets-own"); + let dest = tmp("assets-out"); + let mut s = with_data_sets(&["a", "b"]); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("assets.add", &json!({"ids": [id], "name": "Logo"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "assets.export", "params": {"folder": own.to_string_lossy(), "formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_b"], "{r}"); + for stem in ["Untitled_a", "Untitled_b"] { + assert_eq!(file_names(&dest.join(stem)), ["Logo.svg"], "{r}"); + } + assert!(file_names(&own).is_empty(), "{r}"); + } + /// Override Save names each file with the extension of the format the step names, also over /// data sets. #[test] @@ -1258,7 +1698,7 @@ mod tests { assert!(!dataset_output_exists( Some(&folder), "../outside", - &[DatasetOutput { ext: "svg".into(), artboards: false, joiner: "-" }], + &[DatasetOutput { ext: "svg".into(), artboards: false, joiner: "-", folder: false }], &vectorcraft_doc::Document::new(100.0, 80.0), )); } @@ -1525,7 +1965,15 @@ mod tests { assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back"); assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back"); let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]); - assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), "Override Export leaves its folder"); + assert!( + !with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), + "Override Export gives each data set its own folder" + ); + assert!( + with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false, "commands": screens})), + "without Override Export the step keeps its folder" + ); + assert!(with(json!({"commands": screens})), "without a destination folder the step keeps its folder"); assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]}))); let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]); assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back"); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index b71ede096..12495a95f 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_selection, export::export_selection ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 32b87d80d..b63447e22 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -21,14 +21,14 @@ pub fn specs() -> Vec { "Export DXF", [], None, - "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", + "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", has_doc, export_dxf )] } /// The DXF options `document.formats` lists. -pub(super) const OPTIONS: &[FormatOption] = &[ +pub(crate) const OPTIONS: &[FormatOption] = &[ super::ARTBOARD, super::ARTBOARDS, super::RANGE, diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index f90dacfc7..ac3332699 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -32,14 +32,14 @@ pub fn specs() -> Vec { "Export EPS", [], None, - "{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}", + "{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}. A key EPS doesn't take, and an unknown key inside flattener, are refused", has_doc, export_eps )] } /// The EPS options `document.formats` lists. -pub(super) const OPTIONS: &[FormatOption] = &[ +pub(crate) const OPTIONS: &[FormatOption] = &[ super::ARTBOARD, super::ARTBOARDS, super::RANGE, diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 52e7489dd..2387088b3 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -21,7 +21,7 @@ const C: &str = "document.exportPdf"; pub use vectorcraft_pdf::DEFAULT_PRESET; /// The PDF options `document.formats` lists; `document.exportPdf` documents every field. -pub(super) const OPTIONS: &[FormatOption] = &[ +pub(crate) const OPTIONS: &[FormatOption] = &[ super::ARTBOARD, super::ARTBOARDS, super::RANGE, @@ -50,6 +50,19 @@ pub(super) const OPTIONS: &[FormatOption] = &[ default: "true", description: "embed the native document so VectorCraft reopens the PDF editable (the default preset's choice)", }, + FormatOption { name: "thumbnails", ty: "boolean", default: "false", description: "each page drawn as a /Thumb image, 106 px on its long side" }, + FormatOption { + name: "fastWebView", + ty: "boolean", + default: "false", + description: "a linearized file: the first page shows while the rest downloads", + }, + FormatOption { + name: "viewAfterSaving", + ty: "boolean", + default: "false", + description: "in the desktop app's document.exportPdf, the written file opens in the system viewer", + }, FormatOption { name: "compression", ty: "object", default: "null", description: "{color, gray, mono, compressText} (see document.exportPdf)" }, FormatOption { name: "marks", ty: "object", default: "null", description: "printer's marks (see document.exportPdf)" }, FormatOption { @@ -109,7 +122,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), @@ -118,7 +131,7 @@ pub fn specs() -> Vec { "PDF Settings", [], None, - "{preset?, includeDocument?: false, …options of document.exportPdf} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…)", + "{preset?, includeDocument?: false, …options of document.exportPdf} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…). A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", always, pdf_settings ), diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 803109943..8a149c624 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -174,7 +174,13 @@ pub(super) fn file_part(s: &str) -> String { /// every platform. Spaces between the device name and the period are dropped (`CON .png` becomes /// `CON-.png`). pub(crate) fn safe_file_name(s: &str) -> String { - let name = file_part(s); + name_without_device(&file_part(s)) +} + +/// [`safe_file_name`] without [`file_part`]: drops trailing periods and spaces and puts `-` after a +/// Windows device name, and keeps every other character, so a source file name with `\` or `:` keeps +/// it where the file system allows it. +pub(super) fn name_without_device(name: &str) -> String { let name = name.trim_end_matches(['.', ' ']); let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len())); // Windows ignores spaces before the extension, so `CON .png` is the CON device too. diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 8c1722a60..489da992a 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -445,6 +445,39 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() { assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3)], "{r}"); } +/// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other +/// Library… in the Symbols panel lists them in the list's order. +#[test] +fn a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols() { + let symbol = |name: &str, x: f64| { + format!( + "%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n0 1 1 0 1 0 0 Xa\n{x} 0 m\n{x} 4 L\n{} 4 L\n{} 0 L\nf\nU\n\ + %_/ArtDictionary :\n%_5 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n", + x + 4.0, + x + 4.0 + ) + }; + let symbols = format!( + "%AI5_Begin_NonPrinting\n{}{}{}%AI5_End_NonPrinting--\n%AI24_BeginSymbolList\n(Star)\n(Leaf)\n(Arrow)\n%AI24_EndSymbolList\n", + symbol("Arrow", 0.0), + symbol("Leaf", 10.0), + symbol("Star", 20.0) + ); + let data = ai::editing_data(200.0, 100.0, &symbols); + let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"); + let mut s = Session::new(); + let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "shapes.ai"}); + let r = s.execute("symbol.loadLibrary", &p).unwrap(); + assert_eq!((&r["name"], &r["count"]), (&json!("shapes"), &json!(3)), "{r}"); + let lib = s.execute("symbol.library", &json!({"library": r["library"]})).unwrap(); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Star", "Leaf", "Arrow"], "{lib}"); + let r = open(&mut s, "shapes.ai", &file, json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + let names: Vec<&str> = doc.symbols.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Star", "Leaf", "Arrow"], "{r}"); +} + /// A 1-bit RGB image opens from the editing data in the colors its PDF part shows. #[test] fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() { diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 7a47457c2..8c838d421 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -42,7 +42,10 @@ use data::{fmt_value, parse_csv, to_csv}; use generate::generate; #[cfg(test)] use series::default_series_color; -pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint}; +pub(crate) use series::{ + capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, + series_object_opacity_differs, shown_paint, +}; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 8ab2573ff..7d7d75a16 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,3 +1,5 @@ +use vectorcraft_color::BlendMode; + use super::axis::radar_axis; use super::labels::{Role, filled_lines_on, label_style}; use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; @@ -26,26 +28,43 @@ pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: P (fill, stroke, width) } -/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity on their -/// topmost fill and stroke. Other art, such as a graph design, keeps its own opacity. A value read from a file is -/// held to 0..1, and one that is not a number is left out. +/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity and blend mode on +/// their topmost fill and stroke, and its kept object opacity and blend mode. Other art, such as a graph design, keeps its +/// own. An opacity read from a file is held to 0..1, and one that is not a number is left out. pub(super) fn with_series_opacity(g: &GraphSpec, i: usize, mut items: Vec>) -> Vec> { let Some(slot) = g.series_paints.get(i) else { return items }; let kept = |o: Option| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0)); - let (fill, stroke) = (kept(slot.fill_opacity), kept(slot.stroke_opacity)); - if fill.is_none() && stroke.is_none() { + let (fill, stroke, object) = (kept(slot.fill_opacity), kept(slot.stroke_opacity), kept(slot.opacity)); + if fill.is_none() && stroke.is_none() && object.is_none() && slot.fill_blend.is_none() && slot.stroke_blend.is_none() && slot.blend.is_none() { return items; } for item in &mut items { if !matches!(item.kind, NodeKind::Path { .. }) { continue; } - let ap = &mut Arc::make_mut(item).appearance; - if let (Some(o), Some(f)) = (fill, ap.fill_mut()) { - f.opacity = o; + let n = Arc::make_mut(item); + if let Some(o) = object { + n.opacity = o; } - if let (Some(o), Some(st)) = (stroke, ap.stroke_mut()) { - st.opacity = o; + if let Some(b) = slot.blend { + n.blend = b; + } + let ap = &mut n.appearance; + if let Some(f) = ap.fill_mut() { + if let Some(o) = fill { + f.opacity = o; + } + if let Some(b) = slot.fill_blend { + f.blend = b; + } + } + if let Some(st) = ap.stroke_mut() { + if let Some(o) = stroke { + st.opacity = o; + } + if let Some(b) = slot.stroke_blend { + st.blend = b; + } } } items @@ -216,10 +235,10 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P changed } -/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: -/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind -/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity -/// changed. +/// Copy the opacity and blend mode of the topmost visible fill (`true`) or stroke (`false`) of generated series art just +/// edited (`edited`: each object with the kind of row) into that series' fill or stroke opacity and blend mode; art left +/// without a row of that kind keeps neither. Called inside the same document edit, so both undo together. Returns +/// whether a series' kept opacity or blend mode changed. pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool { if edited.is_empty() { return false; @@ -236,14 +255,15 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible()); let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); - Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity))) + let blend = row.map_or(BlendMode::Normal, vectorcraft_doc::AppearanceItem::blend); + Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend))) }) .collect(); let mut changed = false; - for (graph, index, fill, kept) in captures { + for (graph, index, fill, kept, blend) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; - // A fully opaque row on a series the graph keeps nothing for leaves the graph as it is. - if kept.is_none() && spec.series_paints.len() <= index { + // A fully opaque Normal row on a series the graph keeps nothing for leaves the graph as it is. + if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index { continue; } let was = spec.series_paints.clone(); @@ -253,15 +273,73 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; if fill { - slot.fill_opacity = kept; + (slot.fill_opacity, slot.fill_blend) = (kept, blend); } else { - slot.stroke_opacity = kept; + (slot.stroke_opacity, slot.stroke_blend) = (kept, blend); } changed |= spec.series_paints != was; } changed } +/// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity +/// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object +/// opacity or blend mode changed. +pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool { + let mut changed = false; + for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + // A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is. + if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index { + continue; + } + let was = spec.series_paints.clone(); + // `index` is below the graph's series count (`series_members`). + if spec.series_paints.len() <= index { + spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); + } + let Some(slot) = spec.series_paints.get_mut(index) else { continue }; + (slot.opacity, slot.blend) = (opacity, blend); + changed |= spec.series_paints != was; + } + changed +} + +/// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying +/// it. +pub(crate) fn series_object_opacity_differs(doc: &Document, edited: &[NodeId]) -> bool { + let mut after: HashMap<(NodeId, usize), (Option, Option)> = HashMap::new(); + let stored = |graph: NodeId, index: usize| { + let spec = doc.node(graph).and_then(|n| n.extras().graph.as_deref()); + spec.and_then(|g| g.series_paints.get(index)).map_or((None, None), |slot| (slot.opacity, slot.blend)) + }; + for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { + after.insert((graph, index), (opacity, blend)); + } + after.into_iter().any(|((graph, index), pair)| stored(graph, index) != pair) +} + +/// The series and object opacity and blend mode (None for 100% and Normal) of each of `edited` that is a path directly in a +/// series group, in order. +fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, Option, Option)> { + if edited.is_empty() { + return Vec::new(); + } + let members = series_members(doc); + if members.is_empty() { + return Vec::new(); + } + edited + .iter() + .filter_map(|id| { + // Only the paths the graph draws again, not the copy of a graph design. + let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; + let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; + Some((r.graph, r.index, (node.opacity < 1.0).then_some(node.opacity), (node.blend != BlendMode::Normal).then_some(node.blend))) + }) + .collect() +} + /// Graph Data › Transpose row/column: categories become series and series categories. pub(super) fn transpose(g: &mut GraphSpec) { let cells = g.cells(); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 5825c5fc0..cc986da63 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_color::Paint; +use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind}; use crate::{NodeId, Session}; @@ -347,14 +347,14 @@ fn clearing_a_series_marks_appearance_forgets_its_kept_opacity() { } /// Removing the fill whose opacity the graph keeps takes the kept opacity away; an edit that leaves a mark opaque -/// (a blend mode alone) keeps nothing for its series. +/// and Normal (an opacity of 100% alone) keeps nothing for its series. #[test] fn removing_a_series_marks_fill_forgets_its_kept_opacity() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let id = graph(&mut s, "column"); let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; - s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "blend": "multiply"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap(); assert!(spec(&s, id).series_paints.is_empty()); s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); @@ -503,6 +503,198 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() { assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6)); } +/// Each mark of series `index`: its object opacity and blend mode, and the blend modes of its top fill and stroke. +fn transparency_of(s: &Session, id: NodeId, index: u32) -> Vec<(f32, BlendMode, BlendMode, BlendMode)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, index).children().unwrap().to_vec(); + marks.iter().map(|m| (m.opacity, m.blend, m.appearance.fill().unwrap().blend, m.appearance.stroke().unwrap().blend)).collect() +} + +/// An object opacity and blend mode set on one series mark with no fill or stroke row picked are kept by the graph: +/// every mark of that series has them after the graph is drawn again, through save and open, the other series stay +/// opaque and Normal, 100% and Normal forget them, and undo takes them back. +#[test] +fn an_object_opacity_and_blend_mode_on_a_series_mark_survive_regeneration() { + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap(); + assert_eq!(restored, spec(&s, id)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "opacity": 100, "blend": "normal"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the edit and what the graph stored"); +} + +/// `object.setProps` with the opacity a mark already shows replaces the opacity another mark of the series left on the +/// graph, in one undo step, and an opacity on a graph design's copy in a series is not kept. +#[test] +fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5)); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 100})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5)); + // Setting it again where the graph already stores it leaves the document and its history as they are. + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + save_design(&mut s, "Square"); + s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap(); + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + assert!(matches!(copy.kind, NodeKind::Group { .. })); + s.execute("object.setProps", &json!({"id": copy.id.0, "opacity": 50})).unwrap(); + assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none())); +} + +/// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of +/// the series again; Normal forgets it. +#[test] +fn a_row_blend_mode_on_a_series_mark_survives_regeneration() { + let (mut s, id, _, marks) = column_marks(); + let (fill, stroke) = (top_row(&s, marks[0], true), top_row(&s, marks[0], false)); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "item": fill, "blend": "screen"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [marks[1].0], "item": stroke, "blend": "darken"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_blend, kept.stroke_blend), (Some(BlendMode::Screen), Some(BlendMode::Darken))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (1.0, normal, BlendMode::Screen, BlendMode::Darken)), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "item": top_row(&s, bar, true), "blend": "normal"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_blend, None); +} + +/// The graph stores an object's or a row's opacity and blend mode as one pair from the mark just edited: setting only +/// one half on another mark replaces both, either way round, and the redraw and undo follow the stored pair. +#[test] +fn setting_half_of_a_series_marks_transparency_stores_that_marks_whole_pair() { + let normal = BlendMode::Normal; + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "blend": "multiply"})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 50})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.5), None)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (0.5, normal)), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Multiply))); + + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap(); + s.execute("transparency.set", &json!({"ids": [marks[1].0], "blend": "screen"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Screen))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (1.0, BlendMode::Screen)), "{:?}", transparency_of(&s, id, 0)); + + // A fill row: a blend alone on one mark replaces the opacity another mark's row left, and the other way round. + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "darken"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken))); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 60})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.6), None)); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken))); +} + +/// Clear Appearance on a series mark sets its object opacity back to 100% and Normal, and the graph keeps neither. +#[test] +fn clearing_a_series_marks_appearance_forgets_its_kept_object_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.4)); +} + +/// An appearance moved or copied onto a mark of another series takes the source's object opacity and blend mode, and +/// the graph keeps them for the target's series; a move leaves the source's series opaque and Normal. +#[test] +fn moving_an_appearance_onto_a_series_mark_keeps_its_object_opacity() { + for copy in [false, true] { + let (mut s, id, _, marks) = column_marks(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": to.0, "copy": copy})).unwrap(); + let paints = spec(&s, id).series_paints; + assert_eq!((paints[1].opacity, paints[1].blend), (Some(0.4), Some(BlendMode::Multiply)), "copy {copy}"); + let source = if copy { (Some(0.4), Some(BlendMode::Multiply)) } else { (None, None) }; + assert_eq!((paints[0].opacity, paints[0].blend), source, "copy {copy}"); + } +} + +/// An Eyedropper pick of the object's transparency onto a series mark makes the graph keep the picked opacity and blend +/// mode for that series, even an opaque Normal pick onto a mark that already shows it. +#[test] +fn an_eyedropper_pick_of_object_transparency_onto_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": marks[0].0, "ids": [to.0]})).unwrap(); + let kept = spec(&s, id).series_paints[1].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + // An opaque Normal source picked onto another opaque mark of series 0 replaces what series 0 stores. + let opaque = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].id; + s.execute("appearance.copyFrom", &json!({"source": opaque.0, "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); +} + +/// A graphic style made from an object at 50% Multiply, applied to a series mark, makes the graph keep the style's object +/// opacity and blend mode; redefining the style updates them for a mark still linked to it. +#[test] +fn a_graphic_styles_object_opacity_on_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let (rect, _) = blue_style(&mut s, "Blue", true); + s.execute("object.setProps", &json!({"id": rect.0, "opacity": 50, "blend": "multiply"})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Faded"})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Faded", "ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.5), Some(BlendMode::Multiply))); + s.execute("object.setProps", &json!({"id": rect.0, "opacity": 100, "blend": "normal"})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Faded", "id": rect.0})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5), "one undo takes back the redefinition and what the graph stored"); + + // An opaque Normal style applied to an opaque mark replaces the opacity another mark of the series left. + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 40})).unwrap(); + blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); +} + /// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight. fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); @@ -1010,19 +1202,46 @@ fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() { assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); } -/// An opacity set on a Filled Lines band's stroke row is kept, without a stroke paint, and goes on the band's thin black -/// outline when the graph is drawn again. +/// An opacity and blend mode set on a Filled Lines band's stroke row are kept and drawn on its outline, which keeps its +/// black paint and 0.25 pt weight. #[test] -fn a_stroke_opacity_set_on_a_filled_line_band_goes_on_its_outline() { +fn a_filled_line_bands_outline_keeps_a_stroke_opacity_and_blend_mode() { let (mut s, id, band) = band_graph(); let row = top_row(&s, band, false); - s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 30})).unwrap(); - let kept = spec(&s, id).series_paints.first().cloned().unwrap_or_default(); - assert_eq!((kept.stroke.is_none(), kept.stroke_opacity), (true, Some(0.3))); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 50, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke, kept.stroke_opacity, kept.stroke_blend), (None, Some(0.5), Some(BlendMode::Multiply))); let (_, outline, width) = redrawn_band(&mut s, id); assert_eq!((outline, width), (Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); let band = series_parts(&s, id, 0)[0].clone(); - assert_eq!(band.appearance.stroke().unwrap().opacity, 0.3); + let st = band.appearance.stroke().unwrap(); + assert_eq!((st.opacity, st.blend), (0.5, BlendMode::Multiply)); +} + +/// A graphic style without a stroke applied to a marker whose stroke had paint replaces the stroke opacity and blend mode +/// the series' Filled Lines band left: the band's outline is drawn opaque and Normal again. A marker that had no stroke +/// before the style leaves them as they are. +#[test] +fn a_style_that_removes_a_marks_stroke_replaces_the_kept_stroke_opacity() { + for stroked in [true, false] { + let (mut s, id, band) = band_graph(); + let marker = series_parts(&s, id, 0)[1].id; + if stroked { + let row = top_row(&s, marker, false); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": row, "color": "#ff0000"})).unwrap(); + } + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 40, "blend": "multiply"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.4)); + blue_style(&mut s, "Blue", false); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marker.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + let want = if stroked { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) }; + assert_eq!((kept.stroke_opacity, kept.stroke_blend), want, "stroked {stroked}"); + redrawn_band(&mut s, id); + let st = series_parts(&s, id, 0)[0].appearance.stroke().unwrap().clone(); + assert_eq!((Some(st.opacity).filter(|o| *o < 1.0), Some(st.blend).filter(|b| *b != BlendMode::Normal)), want, "stroked {stroked}"); + } } /// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the @@ -1307,13 +1526,15 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap(); assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill"); - // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it. + // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it, and + // stores only the blend mode. let (mut s, id, _, marks) = column_marks(); let fill = top_row(&s, marks[0], true); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); - let kept = spec(&s, id); + let mut kept = spec(&s, id).series_paints; s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap(); - assert_eq!(spec(&s, id), kept); + kept[0].fill_blend = Some(vectorcraft_color::BlendMode::Multiply); + assert_eq!(spec(&s, id).series_paints, kept); } /// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index 9841d2abb..5b3f7ae7e 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -330,7 +330,7 @@ fn raised_area(face: &PathData, i: usize, areas: &[Option]) -> PathDat .filter(|a| a.bounds().is_some_and(|b| b.overlaps(near))) .map(|a| (a, FillRule::NonZero)), ); - let only = po::boolean_n(&paths, |inside| inside.first() == Some(&true) && inside.get(1) == Some(&true) && !inside.iter().skip(2).any(|b| *b)); + let only = po::boolean_n(&paths, |inside| inside == [0, 1]); if only.is_empty() { return face.clone(); } diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 4a48319f3..6d8e82e8d 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -206,7 +206,7 @@ pub(crate) fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result< /// Run a command's implementation without the enablement check or journaling. pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result { let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; - (c.run)(s, p) + super::run_spec(s, c, p) } /// Merge the undo entries pushed since the history had `from` entries into one labelled step. diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index f248294f2..a923ab2eb 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -251,7 +251,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}", + "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, set_props ), diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs index 716181b1e..1fb2faf76 100644 --- a/crates/engine/src/cmd/object/props.rs +++ b/crates/engine/src/cmd/object/props.rs @@ -65,7 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }) }; let d = &s.doc()?.doc; - if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { + // A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, so a value a mark + // already has still replaces the one another mark left on the graph. + let transparency = opacity.is_some() || blend.is_some(); + let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids); + if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() { // Hidden or locked objects can't stay selected. if hides { s.select(|d, sel| sel.deselect_uneditable(d))?; @@ -118,6 +122,9 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }); } } + if transparency { + crate::cmd::graph::capture_series_object_opacity(d, &ids); + } // Hidden or locked objects can't stay selected. if hides { sel.deselect_uneditable(d); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index a2cd9a14a..eadf47847 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Default Fill and Stroke", [], Some("D"), - "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, default_paint ), @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself)", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). On a path directly in a graph series, the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, transparency ), @@ -855,7 +855,7 @@ fn swap(s: &mut Session, p: &Value) -> Result { n.appearance.set_stroke(f); } // The swap writes the paint of the topmost fill and stroke, not their opacity. - let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true); + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false); Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } @@ -884,7 +884,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { _ => n.appearance = Appearance::default_art().into(), } } - let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false); Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 052a5c69c..363048c8f 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -111,9 +111,8 @@ fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, bool } /// [`boards_and_art`], with both values added to the running command's journal entry, so a replay -/// acts on the same artboards and art whatever the Artboard tool's state is then. `edit.cut` and -/// `edit.copy` call [`artboard_copy`] without this and take no such params. -fn boards_and_art_noted(s: &mut Session, p: &Value, cmd: &str) -> Result<(Vec, bool)> { +/// acts on the same artboards and art whatever the Artboard tool's state is then. +pub(crate) fn boards_and_art_noted(s: &mut Session, p: &Value, cmd: &str) -> Result<(Vec, bool)> { let (indices, art) = boards_and_art(s, p, cmd)?; note_artboards(s, p, &indices); s.note_journal("art", json!(art)); @@ -453,6 +452,19 @@ fn spacing_param(p: &Value, key: &str, c: &str) -> Result; 3 } } +/// The list level `listLevel` gives (None without one): a whole number from 1 to +/// [`vectorcraft_doc::text::LIST_LEVELS`], written `2` or `2.0`. +fn list_level_param(p: &Value, cmd: &str) -> Result> { + let key = "listLevel"; + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let (min, max) = (1, vectorcraft_doc::text::LIST_LEVELS); + match v.as_f64() { + Some(x) if x.fract() == 0.0 && (min as f64..=max as f64).contains(&x) => Ok(Some(x as u8)), + Some(x) => Err(bad(cmd, format!("`{key}` must be a whole number between {min} and {max} (got {x})"))), + None => Err(bad(cmd, format!("`{key}` must be a whole number"))), + } +} + fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; let ids = super::typecmd::text_targets(s, p, C)?; @@ -497,6 +509,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "allCaps", "smallCaps", "position", + "antiAlias", "leftIndent", "rightIndent", "firstLineIndent", @@ -505,6 +518,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "list", "bullet", "number", + "listAlign", + "listLevel", "hyphenate", "wordsLongerThan", "afterFirst", @@ -542,6 +557,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if !keys.iter().any(|k| p.get(*k).is_some()) { return Err(bad(C, "nothing to change")); } + // The anti-aliasing method belongs to the whole type object; each frame keeps its own method. + let anti_alias = match p.get("antiAlias") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(vectorcraft_doc::text::TypeAntiAlias::from_id) + .ok_or_else(|| bad(C, "`antiAlias` must be \"none\", \"sharp\", \"crisp\" or \"strong\""))?, + ), + }; let composer = match p.get("composer") { None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { @@ -625,6 +649,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result { &["none", "arabic", "upperLetters", "lowerLetters", "upperRoman", "lowerRoman", "twoDigits", "threeDigits"], )? .map(|n| NUMBERS.iter().find(|(k, _)| *k == n).map(|&(_, style)| style)); + // …and `listAlign` where the marker stands before the text, in every list paragraph. + let list_align = super::object::choice(p, C, "listAlign", &["left", "right"])? + .map(|a| if a == "right" { vectorcraft_doc::text::ListAlign::Right } else { vectorcraft_doc::text::ListAlign::Left }); + // …and `listLevel` the level of every list paragraph, 1 to 9. + let list_level = list_level_param(p, C)?; let indents_given = num("leftIndent").is_some() || num("firstLineIndent").is_some(); let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); @@ -721,6 +750,31 @@ fn set_format(s: &mut Session, p: &Value) -> Result { st.font_height_reference = v; } }; + // The list settings of one paragraph: `list`, `bullet`, `number`, `listAlign` and `listLevel`. + let lists = |para: &mut vectorcraft_doc::ParaStyle| { + if list.is_some() || bullet.is_some() || number.is_some() { + let on = list != Some("none") && bullet != Some(None) && number != Some(None); + let had = para.list; + // A numbered list for list "number", or without `list` for a number style. + let numbered = list == Some("number") || (list.is_none() && number.is_some()); + // The list's other settings (the marker's alignment) stay as they were. + para.list = on.then(|| vectorcraft_doc::text::ParaList { + bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), + number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()), + ..had.unwrap_or_default() + }); + // A paragraph that joins a list with no left or first-line indent gets a + // hanging indent, unless the call sets an indent. + if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { + para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; + para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; + } + } + if let Some(l) = para.list.as_mut() { + l.align = list_align.unwrap_or(l.align); + l.level = list_level.unwrap_or(l.level); + } + }; s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -746,22 +800,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = num("spaceAfter") { para.space_after = v.clamp(-MAX_PARA, MAX_PARA); } - if list.is_some() || bullet.is_some() || number.is_some() { - let on = list != Some("none") && bullet != Some(None) && number != Some(None); - let had = para.list; - // A numbered list for list "number", or without `list` for a number style. - let numbered = list == Some("number") || (list.is_none() && number.is_some()); - para.list = on.then(|| vectorcraft_doc::text::ParaList { - bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), - number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()), - }); - // A paragraph that joins a list with no left or first-line indent gets a - // hanging indent, unless the call sets an indent. - if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { - para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; - para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; - } - } + lists(para); if let Some(v) = flag("hyphenate") { para.hyphenate = v; } @@ -842,6 +881,13 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); } + if let Some(v) = anti_alias { + for id in &ids { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + t.anti_alias = v; + } + } + } Ok(()) })?; let mut result = json!({ "ids": ids.iter().map(|i| i.0).collect::>() }); @@ -853,14 +899,14 @@ fn set_format(s: &mut Session, p: &Value) -> Result { /// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, /// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it -/// and the numbered paragraphs the frame counts before it. +/// and the numbered paragraphs the frame counts before it at each list level. struct ParagraphPart { frame: usize, index: usize, bytes: std::ops::Range, list: Option, marker: Option, - count: u32, + count: vectorcraft_doc::text::ListCounts, } /// A paragraph of a story: its parts in thread order (a later frame's first paragraph goes on @@ -902,7 +948,7 @@ fn marker_extent( lay: &vectorcraft_text::TextLayout, index: usize, bytes: std::ops::Range, - count: u32, + count: vectorcraft_doc::text::ListCounts, ) -> (f64, f64) { let width = |lay: &vectorcraft_text::TextLayout, start: usize| { lay.markers.iter().find(|m| lay.glyphs.get(m.glyphs.start).is_some_and(|g| g.byte == start)).map(|m| { @@ -916,7 +962,7 @@ fn marker_extent( probe.runs = vectorcraft_text::edit::slice_runs(&t.runs, bytes.start, bytes.end); probe.ruby.clear(); probe.set_all_paras(t.para_at(index).clone()); - probe.list_count = count; + probe.set_list_counts(count); probe.first_para_continues = false; width(&vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe), 0).unwrap_or((0.0, 0.0)) }) @@ -1068,7 +1114,7 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { let plains: Vec = frames.iter().map(|t| t.plain_text()).collect(); for (f, ((id, t), plain)) in story.iter().zip(&frames).zip(&plains).enumerate() { let named = if ids.contains(id) { range.map_or(0..t.paragraph_count(), |r| r.paras(t)) } else { 0..0 }; - let mut count = t.list_count; + let mut count = t.list_counts(); let mut start = 0; for (k, piece) in plain.split('\n').enumerate() { let bytes = start..start + piece.len(); @@ -1130,8 +1176,9 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { // the left indent, nor one past it in a paragraph with no tab of its own. let style = t.para_at(para.index); let (width, gap) = marker_extent(t, lay, para.index, para.bytes.clone(), para.count); - let pen = style.left_indent + style.first_line_indent + width; - let indent = style.left_indent; + // A list level past the first sets the paragraph further in than its left indent. + let indent = style.laid_out_left_indent(); + let pen = indent + style.first_line_indent + width; let before = style.tabs.iter().any(|tab| tab.position > pen + 1e-6 && tab.position < indent - 1e-6); let end = pen + gap; let needed = |x: f64| !plain_stop_at(&style.tabs, x) || style.tabs.iter().any(|tab| aligned_between(tab, pen, x)); @@ -1163,6 +1210,8 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { // A paragraph in several frames is changed in its main part and in its empty parts: // re-flowing the thread gives the other parts with text the main part's style. let Some(mut style) = frames.get(para.frame).map(|t| t.para_at(para.index).clone()) else { continue }; + // The paragraph keeps the indent its list level set it at. + style.left_indent = style.laid_out_left_indent(); style.list = None; if direction.is_some() { style.direction = direction; diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 1d0601364..ad420eb87 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -6,7 +6,9 @@ //! by `|`; a `?` marks an optional one, and a `:`, `=` or `(` starts what the entry says about it. An entry that starts //! with `…` stands for keys listed elsewhere: those of each command it names after `as` or `of` //! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't -//! described key by key, so only the top level is checked. +//! described key by key, so only the top level is checked, except in an export's options: those +//! are checked against the options of the formats the command can write, in `marks`, `bleed` and +//! the other objects too ([`check_export_options`]). //! //! [`Session::execute`] answers a key the description doesn't name with a parameter error //! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the @@ -500,9 +502,100 @@ pub const UNCHECKED: &[&str] = &[ "webExport.settings", ]; -/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included, and -/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have -/// passes: running it reports that. +/// The object options of a PDF export, key by key: the Save PDF settings as a preset stores them, +/// with the passwords it leaves out. +fn pdf_option_tree() -> Value { + let mut tree = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap_or_default(); + if let Some(security) = tree.get_mut("security").and_then(Value::as_object_mut) { + security.extend(["openPassword", "permissionsPassword"].map(|k| (k.to_string(), Value::Null))); + } + tree +} + +/// `given`, the object at `within` (`marks.`), has only keys of `schema`, the objects inside it too. +fn check_option_tree(id: &str, within: &str, given: &serde_json::Map, schema: &serde_json::Map) -> Result<()> { + for (key, value) in given { + match (schema.get(key), value) { + (None, _) => { + let keys: Vec<&str> = schema.keys().map(String::as_str).collect(); + let why = match nearest(key, &keys) { + Some(near) => format!("did you mean `{within}{near}`?"), + None => format!("`{}` takes: {}", within.trim_end_matches('.'), keys.join(", ")), + }; + return Err(bad(id, format!("unknown parameter `{within}{key}`; {why}"))); + } + (Some(Value::Object(inner)), Value::Object(value)) => check_option_tree(id, &format!("{within}{key}."), value, inner)?, + _ => {} + } + } + Ok(()) +} + +/// The options of an export command `id`. `document.export`, `document.exportSelection` and +/// `document.serialize` take a key that some format reads (one object can carry several formats' +/// options; `document.formats` lists them). `document.exportPdf`, `document.pdfSettings` and +/// `pdf.preset.save` take a key that PDF reads, `document.exportEps` one that EPS reads and +/// `document.exportDxf` one that DXF reads. Where a command takes `marks`, `bleed`, `flattener` or +/// another object option of PDF or EPS, only the object's own keys pass. An SVG export's `svg` +/// object is checked when it is read. Saved presets are not checked: one with a key that is no +/// longer read keeps loading. +fn check_export_options(id: &str, params: &Value) -> Result<()> { + use super::fileio::{FORMATS, dxf, eps, pdf}; + let Value::Object(given) = params else { return Ok(()) }; + let mut keys: Vec<&str> = vec!["path", "format", "acknowledgeLoss"]; + let why_not = match id { + "document.export" | "document.exportSelection" | "document.serialize" => { + // `richBlack` is Appearance of Black's setting for a raster export, and `params` an + // exporter plug-in's own. + keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]); + keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name)); + "no format takes it" + } + "document.exportPdf" | "document.pdfSettings" | "pdf.preset.save" => { + keys.push("includeArtboardFill"); + keys.extend(pdf::OPTIONS.iter().map(|o| o.name)); + match id { + "document.pdfSettings" => keys.push("includeDocument"), + "pdf.preset.save" => keys.extend(["name", "newName", "description"]), + _ => {} + } + "PDF doesn't take it" + } + "document.exportEps" => { + keys.push("includeArtboardFill"); + keys.extend(eps::OPTIONS.iter().map(|o| o.name)); + "EPS doesn't take it" + } + "document.exportDxf" => { + keys.extend(dxf::OPTIONS.iter().map(|o| o.name)); + "DXF doesn't take it" + } + _ => return Ok(()), + }; + let mut trees = pdf_option_tree(); + if let Some(tree) = trees.as_object_mut() { + tree.insert("flattener".into(), serde_json::to_value(super::flatten::FlattenOptions::default()).unwrap_or_default()); + } + for (key, value) in given { + if !keys.contains(&key.as_str()) { + return Err(bad( + id, + match nearest(key, &keys) { + Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"), + None => format!("unknown parameter `{key}`; {why_not} (see document.formats)"), + }, + )); + } + if let (Some(Value::Object(schema)), Value::Object(value)) = (trees.get(key), value) { + check_option_tree(id, &format!("{key}."), value, schema)?; + } + } + Ok(()) +} + +/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included, +/// [`check_effect_params`] for the effect `effect.apply` adds and [`check_export_options`] for an +/// export. A command the engine doesn't have passes: running it reports that. pub fn check_params(id: &str, params: &Value) -> Result<()> { check_params_in(id, params, false) } @@ -516,6 +609,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> { { check_effect_params(id, effect, given)?; } + check_export_options(id, params)?; if UNCHECKED.contains(&id) { return Ok(()); } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 85e4dc8c1..591d0ffcc 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -14,6 +14,7 @@ use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; +use super::expand::{fill_shape, reshaped, seeded}; use super::{ CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_selected_path, has_selection, number_or, str_param, switch_or, @@ -891,24 +892,60 @@ pub(crate) fn outlined_stroke( /// Path `l` with every stroke outlined, in appearance order: runs of fills stay on copies of /// the path, each stroke becomes its outline (or brush art), grouped when there are several. +/// As on the canvas, the path's geometry effects ([`reshaped`]) shape the path first, and each +/// stroke's own geometry effects then shape the path that stroke outlines; [`outlined_stroke`] +/// gets the stroke without them. A fill with a random Transform effect paints the outline its +/// geometry effects make of the path, on a copy of its own. A path whose Transform effect copies +/// it with a gradient, or has Scale Strokes & Effects or Transform Patterns on, becomes the art the +/// canvas draws for it, each copy with its own gradient, stroke width and pattern, and its strokes +/// outlined before the art gets new ids. Random in a Transform effect takes its seed +/// from the object's id, so these effects give the outline the values the canvas drew. When the +/// geometry effects leave nothing to paint, an empty group takes the path's place. /// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape}; if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { return None; } + if has_transform_art(l) { + let mut art = reshape(Some(&*d), l, None)?; + outline_member_strokes(d, brushes, &mut art); + return Some(d.reid(&art)); + } + let reshaped = reshaped(l); + let l = reshaped.as_ref().unwrap_or(l); let (path, rule) = node_path(l)?; + let ctx = GeomContext::of(l); // A fill belongs to the source path, whether open or closed. Converting its stroke // preserves that fill on a separate copy (including compound member geometry). let mut parts: Vec = vec![]; let mut fills: Vec = vec![]; for item in &l.appearance.items { match item { - AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()), + AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => { + let shaped = f.effects.iter().any(seeded).then(|| path.bounds().and_then(|b| fill_shape(l, item, &path, b))).flatten(); + let Some((shape, _, _, moved)) = shaped else { + fills.push(item.clone()); + continue; + }; + if !fills.is_empty() { + parts.push(styled_copy(d, l, std::mem::take(&mut fills))); + } + let mut plain = moved; + plain.effects_mut().retain(|e| !is_geometry(&e.id)); + parts.push(styled_copy(d, &shape, vec![plain])); + } AppearanceItem::Stroke(st) if outlinable(st) => { if !fills.is_empty() { parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } - parts.extend(outlined_stroke(d, brushes, &path, rule, st)); + let mut plain = st.clone(); + plain.effects.retain(|e| !is_geometry(&e.id)); + let stroked = match path.bounds() { + Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)), + _ => path.clone(), + }; + parts.extend(outlined_stroke(d, brushes, &stroked, rule, &plain)); } _ => {} } @@ -916,9 +953,27 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br if !fills.is_empty() { parts.push(styled_copy(d, l, fills)); } + let reshapes = reshaped.is_some() + || l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st) && has_geometry(&st.effects))); + if parts.is_empty() && reshapes { + // The geometry effects leave nothing to paint, as on the canvas: an empty group takes the path's place. + parts.push(Node::group(d.alloc_id(), vec![])); + } assemble(d, l, parts) } +/// Outline the strokes of every member of group `n`, however deep ([`outline_strokes`]). +fn outline_member_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) { + let Some(children) = n.children_mut() else { return }; + for c in children.iter_mut() { + if let Some(o) = outline_strokes(d, brushes, c) { + *c = Arc::new(o); + } else if c.children().is_some() { + outline_member_strokes(d, brushes, Arc::make_mut(c)); + } + } +} + /// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`. /// The copy is shown and unlocked. pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec) -> Node { diff --git a/crates/engine/src/cmd/pdfcmds.rs b/crates/engine/src/cmd/pdfcmds.rs index b5a8778fe..35478cdd1 100644 --- a/crates/engine/src/cmd/pdfcmds.rs +++ b/crates/engine/src/cmd/pdfcmds.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Save PDF Preset", [], None, - "{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}", + "{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", always, save ), diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 2ff5a37c3..ffb546de9 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -20,6 +20,13 @@ //! `doc.write` an output that changes the document is refused, and without `state` it neither //! reads nor writes the data it keeps with the document. //! +//! A filter or a live effect may add menu commands of its own (its manifest's `commands`). Each +//! is the command `plugin..` while the plug-in is installed +//! ([`command_specs`], which [`super::find_command`] and the command listings read): under +//! Object › Plug-ins › the plug-in's name (Effect › Plug-ins for a live effect's), journaled so +//! actions record it, and run as a filter run of the plug-in ([`run_command`]) with the command's +//! id in `_context`, its own checked parameters and the plug-in's permissions, as one undo step. +//! //! A plug-in of API level 2 that lists `progress` may import the host's `vc_progress`, //! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a //! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its @@ -29,11 +36,11 @@ //! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size //! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them. -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; -use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry, safe_mode}; +use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects, registry, safe_mode}; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; @@ -67,6 +74,18 @@ pub fn describe(p: &Plugin) -> Value { if m.budget == Budget::Heavy { v["budget"] = json!("heavy"); } + if !m.commands.is_empty() { + let commands: Vec = m + .commands + .iter() + .map(|c| { + let run = m.for_command(c); + json!({"id": c.id, "command": command_id(&m.id, &c.id), "label": c.shown_label(), "params": run.params_doc(), + "paramsSchema": serde_json::to_value(&run).ok().and_then(|v| v.get("params").cloned())}) + }) + .collect(); + v["commands"] = json!(commands); + } v } @@ -97,6 +116,8 @@ fn plugin_by_id(cmd: &str, p: &Value) -> Result> { /// like any other command. pub struct FilterRun { plugin: Arc, + /// The plug-in command it runs (`plugin..`), if it isn't `plugin.run`'s run. + command: Option, input: Vec, params: Vec, /// The objects the plug-in is given, and the container new objects go in. @@ -138,13 +159,23 @@ impl FilterRun { )); } } - // Bad parameters fail before anything runs. - let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); + Self::prepare_with(s, plugin, None, C, p) + } + + /// The run of `plugin`, or of its `command`, that command `c`'s params `p` ask for, on the + /// active document. Bad parameters fail before anything runs. + fn prepare_with(s: &Session, plugin: Arc, command: Option, c: &str, p: &Value) -> Result { + let m = plugin.manifest(); + let resolved = match &command { + Some(cmd) => m.for_command(cmd).resolve_params(&plugin_params(p)), + None => m.resolve_params(&plugin_params(p)), + }; + let mut params = Value::Object(resolved.map_err(|e| bad(c, e.to_string()))?); let may = m.permissions(); let st = s.doc()?; // While an object of a dynamic symbol instance's art is selected, the selection is refused, // as `selected_roots` refuses it. - let selected = match ids_param(p, "ids", C)? { + let selected = match ids_param(p, "ids", c)? { Some(ids) => ids, None => { super::edit::refuse_symbol_part(s)?; @@ -160,10 +191,15 @@ impl FilterRun { // What the plug-in keeps with the document, as it last wrote it. let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); + // A command run says which command it is. + if let Some(cmd) = &command { + params["_context"]["mode"] = json!("command"); + params["_context"]["command"] = json!(cmd.id); + } let input = objects::encode_input(&st.doc, &inputs); - let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; + let params = serde_json::to_vec(¶ms).map_err(|e| bad(c, e.to_string()))?; let document = (st.uid, st.revision); - Ok(FilterRun { plugin, input, params, inputs, parent, document }) + Ok(FilterRun { plugin, command, input, params, inputs, parent, document }) } /// The plug-in, for its name and budget. @@ -214,7 +250,9 @@ impl FilterRun { } let may_write = may.contains(&Permission::DocWrite); let (inputs, parent) = (self.inputs, self.parent); - let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| { + // A command's undo step is named after the command. + let label = self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label); + let ids = s.edit(&label, |d, sel| { let before = (!may_write).then(|| d.layers.clone()); let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?; if before.is_some_and(|b| b != d.layers) { @@ -226,7 +264,11 @@ impl FilterRun { sel.set(ids.iter().copied().filter(|&id| d.node(id).is_some_and(objects::selectable))); Ok(ids) })?; - Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::>()})) + let mut out = json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::>()}); + if let Some(cmd) = &self.command { + out["command"] = json!(cmd.id); + } + Ok(out) } } @@ -256,6 +298,146 @@ fn run(s: &mut Session, p: &Value) -> Result { r.finish(s, &output) } +/// The command id of plug-in `plugin`'s command `command`: `plugin..`. +pub fn command_id(plugin: &str, command: &str) -> String { + format!("{}{plugin}.{command}", vectorcraft_plugins::PREFIX) +} + +/// Is `id` shaped as a plug-in command's id (`plugin..`)? This module's +/// own `plugin.*` commands have no dot after the prefix, and a command id has none. +pub fn is_command_id(id: &str) -> bool { + id.strip_prefix(vectorcraft_plugins::PREFIX).is_some_and(|rest| rest.contains('.')) +} + +/// The installed plug-in and its command that command id `id` names. +pub fn command_of(id: &str) -> Option<(Arc, Command)> { + let (plugin, command) = id.strip_prefix(vectorcraft_plugins::PREFIX)?.rsplit_once('.')?; + let p = registry::get(plugin)?; + let c = p.manifest().command(command)?.clone(); + Some((p, c)) +} + +/// The plug-in commands of the installed plug-ins, the registry revision they were listed at, and +/// a count that changes whenever the list does ([`commands_generation`]). +struct Listed { + revision: Option, + generation: u64, + specs: Vec<&'static CommandSpec>, +} + +static LISTED: RwLock = RwLock::new(Listed { revision: None, generation: 0, specs: Vec::new() }); + +/// Every command spec made for a plug-in command, made once: specs hold static strings, so memory +/// stays bounded by the plug-in commands ever installed. +static MADE: Mutex> = Mutex::new(Vec::new()); + +/// The commands the installed plug-ins add, in plug-in id order and then in each manifest's order. +pub fn command_specs() -> Vec<&'static CommandSpec> { + let revision = registry::revision(); + if let Ok(l) = LISTED.read() + && l.revision == Some(revision) + { + return l.specs.clone(); + } + let specs: Vec<&'static CommandSpec> = + registry::list().iter().flat_map(|p| p.manifest().commands.iter().map(|c| spec_of(p, c)).collect::>()).collect(); + // When a plug-in was installed or removed during the listing, the list may not match + // `revision`. It is returned without being cached, and the next call lists again. + if registry::revision() != revision { + return specs; + } + let mut l = LISTED.write().unwrap_or_else(|e| e.into_inner()); + if l.specs.len() != specs.len() || l.specs.iter().zip(&specs).any(|(a, b)| !std::ptr::eq(*a, *b)) { + l.generation += 1; + } + l.revision = Some(revision); + l.specs.clone_from(&specs); + specs +} + +/// Changes whenever the plug-in commands [`command_specs`] lists change, for caches built from +/// them (the keyboard shortcuts' catalogue). +pub fn commands_generation() -> u64 { + command_specs(); + LISTED.read().map(|l| l.generation).unwrap_or_else(|e| e.into_inner().generation) +} + +/// The spec of installed plug-in command `id`. +pub fn find_spec(id: &str) -> Option<&'static CommandSpec> { + if !is_command_id(id) { + return None; + } + command_specs().into_iter().find(|c| c.id == id) +} + +/// The command spec of plug-in `p`'s command `c`: its id, its label as menus show it (escaped and +/// capped), its menu (Object › Plug-ins › the plug-in's name, or Effect › Plug-ins › it for a live +/// effect: never a menu the manifest names) and its params description. +fn spec_of(p: &Plugin, c: &Command) -> &'static CommandSpec { + let m = p.manifest(); + let id = command_id(&m.id, &c.id); + let label = c.shown_label(); + let top = if m.kind == Kind::Effect { "Effect" } else { "Object" }; + let name = permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME); + let mut fields = + String::from("ids?: [object id] (left out or null: the selection), params?: {…} (its parameters; or give them as top-level keys)"); + let doc = m.for_command(c).params_doc(); + if let Some(own) = doc.strip_prefix('{').and_then(|d| d.strip_suffix('}')).filter(|d| !d.is_empty()) { + fields.push_str(", "); + fields.push_str(own); + } + let params = format!( + "{{{fields}}} run “{label}”, a command of plug-in {}, on the selected paths and compound paths as plugin.run runs a filter, with _context.command {:?}: one undo step → {{plugin, command, ids}}", + m.id, c.id + ); + let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); + let same = |s: &&&'static CommandSpec| s.id == id && s.label == label && s.menu == [top, "Plug-ins", name.as_str()] && s.params == params; + if let Some(s) = made.iter().find(same) { + return s; + } + let menu: &'static [&'static str] = Box::leak(vec![top, "Plug-ins", registry::intern(&name)].into_boxed_slice()); + let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec { + id: registry::intern(&id), + label: registry::intern(&label), + menu, + shortcut: None, + params: registry::intern(¶ms), + enabled: has_doc, + run: unrouted, + journal: true, + })); + made.push(spec); + spec +} + +/// A plug-in command's `run`: plug-in commands run through [`super::run_spec`], which passes +/// [`run_command`] the command id. +fn unrouted(_: &mut Session, _: &Value) -> Result { + Err(EngineError::Internal { cmd: "plugin".into(), msg: "a plug-in command runs through Session::execute".into() }) +} + +/// Runs plug-in command `id` (`plugin..`): a filter run of the plug-in on +/// `ids` (else the selection) with the command's own parameters, checked before it runs, and +/// `_context.command`, under the plug-in's permissions and budget, applied as one undo step named +/// after the command → `{plugin, command, ids}`. A run that fails (a trap, an exhausted budget, an +/// output it may not make) leaves the document as it was, and the error names the plug-in and the +/// command. +pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result { + // An output handed in is for `plugin.run` alone. + s.plugin_output = None; + let (plugin, command) = command_of(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + let failed = { + let (name, label) = (permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME), command.shown_label()); + move |e: EngineError| match e { + EngineError::Cancelled => EngineError::Cancelled, + e => EngineError::Other(format!("“{name}” › {label}: {e}")), + } + }; + let r = FilterRun::prepare_with(s, plugin, Some(command), id, p)?; + let output = r.run().map_err(&failed)?; + r.finish(s, &output).map_err(failed) +} + /// Admits the effect records a filter returned for object `n` (written, its effects still its own) /// as `effect.apply` would apply them: each new record a known effect (one of ours, or an installed /// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object @@ -669,7 +851,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -687,7 +869,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", has_doc, run ), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 5981396f1..d73f01eaa 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows; exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), @@ -349,14 +349,14 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { // The graph stores the rows of the kinds the style shows, now on top of each stack. let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); let kinds = (shows(true), shows(false)); - super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false); + super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false, false); } else { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); for id in &ids { apply_style(d, *id, &g, chars); } - super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } Ok(()) })?; @@ -476,7 +476,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { for nid in &follow { apply_style(d, *nid, &g, chars); } - super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false); + super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 4274ce6d5..a1bb2c64e 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1604,9 +1604,11 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + anti_alias: Default::default(), system_layout: false, fractional_widths: true, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), }; @@ -1892,9 +1894,11 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + anti_alias: Default::default(), system_layout: t.system_layout, fractional_widths: t.fractional_widths, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), }; diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 61fc7f33a..ae15ab8b9 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -195,6 +195,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { || optical != t.optical_margin || vertical != t.vertical || part.list_count != t.list_count + || part.list_level_counts != t.list_level_counts || continues != t.first_para_continues { t.runs = next.runs; @@ -203,6 +204,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { t.optical_margin = optical; t.vertical = vertical; t.list_count = part.list_count; + t.list_level_counts = part.list_level_counts; t.first_para_continues = continues; refresh_bounds(t); } @@ -216,14 +218,15 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } /// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count -/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a -/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of -/// its thread) gets 0 and is laid out again. +/// from 1. Each such type object with another [`TextObject::list_count`] or +/// [`TextObject::list_level_counts`] (a copy of a frame, a frame taken out of its thread, a frame +/// converted to point type, which [`reflow`] takes out of its thread) gets 0 at every level and is +/// laid out again. pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc) { let mut stale = vec![]; doc.walk(|n| { if let NodeKind::Text(t) = &n.kind - && t.list_count != 0 + && (t.list_count != 0 || !t.list_level_counts.is_empty()) { stale.push(n.id); } @@ -240,6 +243,7 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc) { for id in stale { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { t.list_count = 0; + t.list_level_counts.clear(); refresh_bounds(t); } } @@ -444,13 +448,13 @@ fn cut_to_shown(n: &mut Node, ids: &[NodeId]) { { // Split as `take_overflow` splits it: the first part is what the frame shows. let from: &TextObject = t; - let parts = vectorcraft_text::thread::distribute_continuing( + let parts = vectorcraft_text::thread::distribute_continuing_levels( vectorcraft_text::FontDb::global(), &[from, from], &from.runs, &from.paragraph_styles(), from.first_para_continues, - from.list_count, + from.list_counts(), ); if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) { t.runs = head.runs.clone(); @@ -485,13 +489,13 @@ fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) { // `from`'s text split as if it flowed on into a second frame: the second part is what `from` // has no room for. Its share is measured as it is laid out, continuing a paragraph or not and // with its numbered list paragraphs counted on from the frames before it. - let parts = vectorcraft_text::thread::distribute_continuing( + let parts = vectorcraft_text::thread::distribute_continuing_levels( db, &[from, from], &from.runs, &from.paragraph_styles(), from.first_para_continues, - from.list_count, + from.list_counts(), ); let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return }; let mut tail = from.clone(); diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 1ead2e823..48a1e46fb 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -981,6 +981,74 @@ fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() { assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); } +/// As above for a list at level 2: the last frame's numbered paragraphs are counted on from the +/// count the frames before it reached at that level. +#[test] +fn outlining_a_last_frame_numbered_at_a_deeper_level_keeps_only_the_text_it_has_no_room_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); + let story = format!("{items}{}", TEXT.repeat(4)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); + let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})).unwrap(); + let shown = [a, c].map(|id| text(&s, id)); + assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}"); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; + assert_eq!((t.list_count, t.list_level_counts.as_slice()), (0, &[7][..])); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII.")); + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t }); + assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text"); + let hidden = shown[1][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); +} + +/// New Symbol and Redefine Symbol on a thread's last frame numbered at level 2: the symbol's +/// art holds the text the frame shows, measured with the count the frames before it reached at +/// that level, and the frame before it takes the rest. +#[test] +fn a_symbol_of_a_last_frame_numbered_at_a_deeper_level_holds_the_text_it_shows() { + for command in ["symbol.new", "symbol.update"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); + let story = format!("{items}{}", TEXT.repeat(4)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); + let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})) + .unwrap(); + let shown = [a, c].map(|id| text(&s, id)); + assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{command}: {shown:?}"); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(laid.overflow, "{command}: the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t }); + assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "{command}: counted from 1, the frame would show other text"); + let hidden = shown[1][end..].trim_start_matches(' '); + let name = if command == "symbol.update" { + let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()); + s.execute("select.set", &json!({"ids": [mark]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + "Mark" + } else { + "Story" + }; + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute(command, &json!({"name": name})).unwrap(); + let held = symbol_texts(&s, name); + assert_eq!(held.iter().map(|t| t.trim_end()).collect::>(), [shown[1][..end].trim_end()], "{command}: the symbol's art"); + assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]), "{command}: the frame before it takes the rest"); + } +} + /// A story of one-line paragraphs that each start with a quotation mark, threaded from area /// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for. fn quoted_thread() -> (Session, u64, u64) { @@ -1066,6 +1134,65 @@ fn a_thread_takes_its_first_frames_optical_margin_alignment() { assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]); } +/// The anti-aliasing method (`text.setFormat {antiAlias}`) belongs to each frame and is saved +/// only when not Sharp. A range sets its frame in one undo step. Threading and opening preserve +/// different methods on the frames of a thread. +#[test] +fn the_anti_aliasing_method_is_set_for_each_frame_of_a_thread() { + use vectorcraft_doc::text::TypeAntiAlias::{self, Crisp, None as Hard, Sharp, Strong}; + let method = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).map(|t| t.anti_alias); + let saved = |s: &Session, id: u64| serde_json::to_value(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap()).unwrap().get("antiAlias").cloned(); + let (mut s, a, b) = quoted_thread(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Sharp))); + assert_eq!(saved(&s, a), None, "Sharp is not saved"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let err = s.execute("text.setFormat", &json!({"antiAlias": "smooth"})).unwrap_err().to_string(); + assert!(err.contains("`antiAlias` must be"), "{err}"); + s.execute("text.setFormat", &json!({"antiAlias": "crisp"})).unwrap(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Crisp)), "only the selected frame takes it"); + assert_eq!(saved(&s, b), Some(json!("crisp"))); + // A range of characters sets its whole frame. + s.execute("text.setFormat", &json!({"ids": [a], "start": 0, "end": 1, "antiAlias": "none"})).unwrap(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Hard), Some(Crisp))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Crisp)), "one undo step"); + // A new frame does not change the methods of the existing frames. + s.execute("text.setFormat", &json!({"ids": [b], "antiAlias": "strong"})).unwrap(); + let c = new_id(s.execute("text.thread.add", &json!({"from": b, "at": [400, 10]})).unwrap()); + assert_eq!([a, b, c].map(|id| method(&s, id)), [Some(Sharp), Some(Strong), Some(Strong)]); + s.execute("edit.undo", &json!({})).unwrap(); + // Threading existing frames with different methods preserves both. + let (mut joined, first_id, second_id) = quoted_thread(); + joined.execute("text.thread.remove", &json!({})).unwrap(); + joined.execute("text.setFormat", &json!({"ids": [first_id], "antiAlias": "crisp"})).unwrap(); + joined.execute("text.setFormat", &json!({"ids": [second_id], "antiAlias": "strong"})).unwrap(); + joined.execute("select.set", &json!({"ids": [first_id, second_id]})).unwrap(); + joined.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong))); + joined.execute("text.editRange", &json!({"id": first_id, "start": 0, "end": 0, "insert": "New "})).unwrap(); + assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong)), "reflow keeps both methods"); + joined.execute("text.thread.remove", &json!({})).unwrap(); + assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong)), "unthreading keeps both methods"); + let first: [TypeAntiAlias; 2] = [Crisp, Hard]; + for (one, name) in [(first, "old.vectorcraft"), ([Hard, Crisp], "old.vectorcraft"), (first, "old.eps"), ([Hard, Crisp], "old.eps")] { + for (id, m) in [(a, one[0]), (b, one[1])] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.anti_alias = m; + } + let bytes = if name.ends_with(".eps") { + let r = s.execute("document.exportEps", &json!({})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } else { + vectorcraft_format::save(&s.doc().unwrap().doc, false) + }; + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); + assert_eq!(threads(&s), [[a, b]], "{name}"); + assert_eq!((method(&s, a), method(&s, b)), (Some(one[0]), Some(one[1])), "{name}, frame methods"); + } +} + /// A file saved with the frames of one thread set differently opens with the first frame's /// setting in every frame: a native file, and an EPS file restored from the document it carries. #[test] @@ -1325,6 +1452,41 @@ fn numbered_list_paragraphs_count_on_through_a_thread() { assert_eq!(numbers(&s, b), all(1), "the frame left holds the story"); } +/// List levels in a thread: a deeper level's count runs on into the next frame, which saves +/// it (`listLevelCounts`), and a pasted copy of that frame counts every level from 1. +#[test] +fn list_levels_count_on_through_a_thread() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap(); + let frame = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).cloned().unwrap(); + let numbers = + |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>(); + let first = numbers(&frame(&s, a)); + assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}"); + let expected: Vec = std::iter::once("1.".to_string()).chain((1..=9).map(|n| format!("{n}."))).collect(); + assert_eq!([first.clone(), numbers(&frame(&s, b))].concat(), expected); + let b_counts = frame(&s, b).list_counts(); + assert_eq!(b_counts.parts(), (1, vec![first.len() as u32 - 1])); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.list_counts(), numbers(back)), (b_counts, numbers(&frame(&s, b))), "reopened"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.objects.first().copied().unwrap(); + assert_ne!(copy, NodeId(b)); + let pasted = frame(&s, copy.0); + assert_eq!((pasted.list_count, pasted.list_level_counts.as_slice()), (0, &[][..])); + assert_eq!(numbers(&pasted).first().map(String::as_str), Some("1."), "a pasted copy counts from 1"); +} + /// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art /// looks as it did; Break Link puts the frames in no thread, and each counts from 1. #[test] diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index c5663b58a..c0e5b37fe 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1279,7 +1279,7 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> { let cell = lay.frames.first()?; let line = lay.lines.first()?; let para = t.para_at(0); - let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent; + let avail = cell.width() - 2.0 * t.area.inset - para.laid_out_left_indent() - para.right_indent; Some((line.x1 - line.x0, avail, line.end)) } diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 4748dcc8d..0891bec5c 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -437,7 +437,7 @@ fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. let read = |v: serde_json::Value| serde_json::from_value::(json!({ "list": v })).unwrap().list; - assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); + assert_eq!((read(json!({})), read(json!({"bullet": "dash", "restart": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None)); @@ -500,7 +500,7 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() { s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square))); s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap(); - let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) }); + let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters), ..ParaList::default() }); assert_eq!(list(&s, 0), letters); assert_eq!(numbers(&s), ["a.", "2.", "3."]); assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); @@ -536,6 +536,70 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() { assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); } +/// Bullets and Numbering… › Alignment: `text.setFormat {listAlign}` sets where the marker +/// stands in the list paragraphs it touches, in one undo step, and refuses unknown values; +/// a paragraph in no list gets no list from it. A list keeps its alignment when it changes +/// between bullets and numbers, and a call that puts a paragraph in a list gives it the +/// alignment too. The alignment is saved only when it is right, opens with the file, and +/// paragraph styles carry it both ways. +#[test] +fn list_markers_are_aligned_per_paragraph_saved_and_styled() { + use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let list = |s: &Session, i: usize| text(s).para_at(i).list; + let align = |s: &Session| (0..3).map(|i| list(s, i).map(|l| l.align)).collect::>(); + let (left, right) = (Some(ListAlign::Left), Some(ListAlign::Right)); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 4})).unwrap(); + assert_eq!(align(&s), [left, left, None]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"listAlign": "right", "start": 0, "end": 0})).unwrap(); + assert_eq!(align(&s), [right, left, None]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(align(&s), [left, left, None]); + s.execute("text.setFormat", &json!({"listAlign": "right"})).unwrap(); + assert_eq!(align(&s), [right, right, None], "the paragraph in no list stays out of one"); + for bad in [json!({"listAlign": "center"}), json!({"listAlign": "Right"}), json!({"listAlign": 1}), json!({"listAlign": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!(align(&s), [right, right, None], "an unknown value changes nothing"); + // Bullets and numbers keep it; a call that makes a list sets it there too. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right))); + s.execute("text.setFormat", &json!({"list": "bullet", "listAlign": "right", "start": 8, "end": 8})).unwrap(); + assert_eq!(list(&s, 2), Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))); + s.execute("text.setFormat", &json!({"listAlign": "left", "start": 8, "end": 8})).unwrap(); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "align": "right"})); + assert_eq!(serde_json::to_value(text(&s).para_at(2)).unwrap()["list"], json!({"bullet": "dot"})); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).list, back.para_at(1).list), (list(&s, 0), Some(ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right)))); + // A list turned off and on again starts aligned left. + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap(); + assert_eq!(align(&s)[1], left); + // A style captured from a list aligned right gives other lists that alignment, and one + // captured from a list aligned left takes it back. + let other = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Other"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [other], "list": "number"})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Aligned left", "id": other})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Aligned right", "id": id})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Aligned right", "id": other})).unwrap(); + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(other)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!(t.para_at(0).list.map(|l| l.align), right); + s.execute("paraStyle.apply", &json!({"name": "Aligned left", "id": id})).unwrap(); + assert_eq!(align(&s), [left, left, left]); +} + /// The text object `id`. fn text_at(s: &Session, id: u64) -> vectorcraft_doc::text::TextObject { match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { @@ -724,6 +788,56 @@ fn point_and_vertical_type_convert(s: &mut Session) { assert_eq!(s.doc().unwrap().history.undo.len(), steps); } +/// Convert to Text of a list whose markers are aligned right writes the same characters as for +/// a list aligned left, the marker and then a tab, and gives the paragraphs the same tab stops: +/// the text after the tab stays where it was, and the marker moves to the first line's start, +/// where a list aligned left draws it. Numbers narrower than the hanging indent (their tab went +/// to the left indent) and wider (their tab went to the next default stop), and bullets. +#[test] +fn convert_to_text_of_markers_aligned_right_keeps_the_text_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let text_glyphs = |s: &Session, id: u64| { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let milli = |v: f64| (v * 1000.0).round() as i64; + lay.glyphs + .iter() + .filter(|g| g.len > 0 && !g.outline.elements().is_empty()) + .map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y))) + .collect::>() + }; + let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("text"), + }; + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + for (list, converted) in [ + ("number", (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n")), + ("bullet", (1..=10).map(|i| format!("\u{2022}\tItem {i}")).collect::>().join("\n")), + ] { + let make = |s: &mut Session, align: &str| { + let id = s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap() + ["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": list, "listAlign": align})).unwrap(); + id + }; + let (left, right) = (make(&mut s, "left"), make(&mut s, "right")); + let drawn_left = drawn_glyphs(&s, left); + let text_before = text_glyphs(&s, right); + assert_eq!(text_before, text_glyphs(&s, left), "{list}: the text starts at the same place with either alignment"); + assert_ne!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers aligned right are drawn elsewhere"); + s.execute("text.convertListToText", &json!({"ids": [left, right]})).unwrap(); + assert_eq!((plain(&s, left), plain(&s, right)), (converted.clone(), converted), "{list}: the marker, then a tab"); + let after = text_glyphs(&s, right); + assert!(text_before.iter().all(|g| after.contains(g)), "{list}: the text stays where it was"); + assert_eq!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers start where a list aligned left draws them"); + assert_eq!(drawn_glyphs(&s, left), drawn_left, "{list}: aligned left, every glyph stays"); + } +} + /// A right-to-left list paragraph converted to text starts with the number, its period and a /// tab, which are read first: the line draws its glyphs in the order it drew them with the /// marker, the number at the right with its period at its left. Letters and roman numerals @@ -907,6 +1021,130 @@ fn convert_to_text_moves_the_type_tools_selection() { assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected"); } +/// Convert to Text of list paragraphs at deeper levels writes each level's own numbers, those +/// a thread's earlier frames counted at each level included, and every glyph is drawn where it +/// was: each converted paragraph's left indent takes its level's indent, with the plain left +/// stop there. +#[test] +fn convert_to_text_keeps_deeper_list_levels_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let numbers = |s: &Session, id: u64| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.iter().map(|m| m.text.clone()).collect::>() + }; + let text_in = "One\nTwo\nThree\nFour\nFive"; + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": text_in, "area": {"width": 400, "height": 400}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 2, "start": 4, "end": 9})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 3, "start": 14, "end": 14})).unwrap(); + let before = drawn_glyphs(&s, id); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was"); + let t = text(&s, id); + assert_eq!(t.plain_text(), "1.\tOne\n1.\tTwo\n2.\tThree\n1.\tFour\n2.\tFive"); + let paras = (0..5) + .map(|k| { + let p = t.para_at(k); + (p.list.is_none(), p.left_indent, p.tabs.iter().map(|tab| tab.position).collect::>()) + }) + .collect::>(); + assert_eq!(paras, [18.0, 36.0, 36.0, 54.0, 18.0].map(|x| (true, x, vec![x])), "out of the list, at the level's indent"); + // A thread whose second frame goes on with the numbers of level 2. + let items = std::iter::once("Head".to_string()).chain((1..=9).map(|i| format!("Item {i}"))).collect::>().join("\n"); + let a = + s.execute("text.create", &json!({"x": 10, "y": 500, "text": items, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 500, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap(); + let first = numbers(&s, a).len(); + assert!((3..10).contains(&first) && text(&s, a).plain_text().ends_with('\n'), "both frames hold items: {first}"); + assert_eq!(numbers(&s, b).first(), Some(&format!("{first}.")), "the second frame counts level 2 on"); + let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); + s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap(); + assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames"); + let story = std::iter::once("1.\tHead".to_string()).chain((1..=9).map(|i| format!("{i}.\tItem {i}"))).collect::>().join("\n"); + assert_eq!(text(&s, a).plain_text() + &text(&s, b).plain_text(), story); +} + +/// List levels: `text.setFormat {listLevel}` sets the level of the list paragraphs it touches +/// as one undo step, from 1 to 9, and refuses other values; bullets and numbers keep it, a +/// list turned off and on starts at level 1 again, each level counts its own numbers, and the +/// level is saved past level 1, reopened and carried by paragraph styles. +#[test] +fn list_levels_are_set_per_paragraph_saved_and_styled() { + use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree\nFour", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let levels = |s: &Session| (0..4).map(|i| text(s).para_at(i).list.map(|l| l.level)).collect::>(); + let numbers = |s: &Session| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::>() + }; + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 9})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"listLevel": 2, "start": 4, "end": 9})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(2), Some(2), None]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(numbers(&s), ["1.", "1.", "2."]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]); + s.execute("text.setFormat", &json!({"listLevel": 2.0, "start": 4, "end": 4})).unwrap(); + assert_eq!(levels(&s)[1], Some(2)); + s.execute("text.setFormat", &json!({"listLevel": 9})).unwrap(); + assert_eq!(levels(&s), [Some(9), Some(9), Some(9), None], "the paragraph in no list stays out of one"); + let bad = [json!({"listLevel": 0}), json!({"listLevel": 10}), json!({"listLevel": 1.5}), json!({"listLevel": "2"}), json!({"listLevel": true})]; + for p in bad { + assert!(s.execute("text.setFormat", &p).is_err(), "{p}"); + } + let err = s.execute("text.setFormat", &json!({"listLevel": 10})).unwrap_err().to_string(); + assert!(err.contains("`listLevel` must be a whole number between 1 and 9 (got 10)"), "{err}"); + assert_eq!(levels(&s)[0], Some(9), "an unknown value changes nothing"); + // A deeper paragraph counts from 1, and one back at a shallower level goes on. + s.execute("text.setFormat", &json!({"listLevel": 1, "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"listLevel": 3, "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"listLevel": 1, "start": 8, "end": 8})).unwrap(); + assert_eq!(numbers(&s), ["1.", "1.", "2."]); + // Bullets and numbers keep the level; a call that makes a list sets it there too. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 4, "end": 4})).unwrap(); + assert_eq!(text(&s).para_at(1).list, Some(ParaList::bulleted(Bullet::Square).at_level(3))); + s.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 4, "start": 14, "end": 14})).unwrap(); + assert_eq!(text(&s).para_at(3).list, Some(ParaList::bulleted(Bullet::Dot).at_level(4))); + assert_eq!(serde_json::to_value(text(&s).para_at(1)).unwrap()["list"], json!({"bullet": "square", "level": 3})); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "dot", "number": "arabic"})); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((0..4).map(|i| back.para_at(i).list).collect::>(), (0..4).map(|i| text(&s).para_at(i).list).collect::>()); + // A list turned off and on again starts at level 1. + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap(); + assert_eq!(text(&s).para_at(1).list, Some(ParaList::numbered(NumberStyle::Arabic))); + // A style captured from a paragraph at level 4 sets that level, and one captured at level 1 + // takes it back. + s.execute("paraStyle.new", &json!({"name": "Level 4", "id": id, "start": 14, "end": 14})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Level 1", "id": id, "start": 0, "end": 0})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Level 4", "id": id, "start": 0, "end": 0})).unwrap(); + assert_eq!(levels(&s)[0], Some(4)); + s.execute("paraStyle.apply", &json!({"name": "Level 1", "id": id})).unwrap(); + assert_eq!(levels(&s), [Some(1); 4]); +} + /// Type › Create Outlines makes a path of a list item's bullet as well as of its characters. #[test] fn create_outlines_makes_a_path_of_the_bullet() { @@ -1627,9 +1865,10 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap(); assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap(); - s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap(); + s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Source Sans 3"}})).unwrap(); assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); - // A static font has no axes to set. + // A static font has no axes to set (Source Sans 3, not Inter: an installed Inter + // Variable would shadow the bundled static and give it axes). s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); assert_eq!(saved(&s), None); // A missing font keeps them as given, for when it is installed. @@ -1651,7 +1890,7 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { {"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false}, ]) ); - assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([])); + assert_eq!(s.execute("text.fontList", &json!({"family": "Source Sans 3"})).unwrap()["axes"], json!([])); } #[test] diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 58b4e2e6b..0b0ea1042 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -373,8 +373,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result { } // The point origin sits where the alignment anchors the first line. let p0 = t.para_at(0); - let (x0, x1) = - lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent)); + let (x0, x1) = lay + .lines + .first() + .map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.laid_out_left_indent() - p0.first_line_indent, l.avail.1 + p0.right_indent)); let ox = match p0.justify { Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5, Justify::Right | Justify::JustifyRight => x1, diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 9fcb7cd4c..9708d9af3 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -25,9 +25,10 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo /// `(command, param)` pairs the journal records only so a replay reproduces the original run /// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the /// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the -/// Artboard tool had selected for its commands; the view's center the app passes to Edit Symbol); -/// an action leaves them out ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 22] = [ +/// Artboard tool had selected for its commands; the artboards and art Cut and Copy took with that +/// tool chosen; the view's center the app passes to Edit Symbol); an action leaves them out +/// ([`Session::journal_for_action`]). +const REPLAY_ONLY: [(&str, &str); 26] = [ ("file.new", "created"), ("edit.newDocumentFromState", "created"), ("document.save", "modified"), @@ -46,6 +47,10 @@ const REPLAY_ONLY: [(&str, &str); 22] = [ ("artboard.cut", "selectedArtboards"), ("artboard.rename", "selectedArtboards"), ("artboard.switchOrientation", "selectedArtboards"), + ("edit.copy", "selectedArtboards"), + ("edit.copy", "art"), + ("edit.cut", "selectedArtboards"), + ("edit.cut", "art"), ("select.allOnArtboard", "artboard"), ("symbol.edit", "artboard"), ("symbol.edit", "viewCenter"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 27972ef20..03082e7f5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -175,6 +175,9 @@ pub struct Session { /// 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, + /// Every composite font (lowercase name) this session has registered in the font database: + /// type read for opening may have been laid out in one that is gone by the time it opens. + pub(crate) composite_names_registered: std::collections::HashSet, /// 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 b0619c992..c3c112bd3 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -1,3 +1,7 @@ +use std::collections::HashSet; + +use vectorcraft_doc::{Node, TextObject}; + use super::{ Arc, Clipboard, Color, CommandInfo, DocState, Document, DrawMode, EngineError, GradientPaint, Journal, NodeKind, PaintDefaults, Prefs, Result, Session, ViewInfo, cmd, command_specs, file_access, @@ -84,6 +88,7 @@ impl Session { batch_steps: None, batch_paint_docs: Vec::new(), composite_names: Vec::new(), + composite_names_registered: HashSet::new(), reading: false, place_hand_off: None, queue_hand_off: None, @@ -182,10 +187,8 @@ impl Session { 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); + if switched { + self.sync_front_composite_fonts(); } true } else { @@ -193,6 +196,16 @@ impl Session { } } + /// Register the composite fonts of the document now in front ([`cmd::compositefonts::sync`]), + /// after a switch or a close brought it there. Its type set in a composite font that differs + /// from the one of that name before (added, changed or removed) is measured again, and type not + /// measured yet (in a document replaced while in the back) is measured now. + fn sync_front_composite_fonts(&mut self) { + let changed: HashSet = cmd::compositefonts::sync(self).into_iter().collect(); + let Some(index) = self.active else { return }; + self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &changed)); + } + /// The name [`Session::next_untitled`] gives next (New Document's Name field). pub fn peek_untitled(&self) -> String { format!("Untitled-{}", self.untitled_counter + 1) @@ -211,19 +224,44 @@ impl Session { pub(crate) fn refresh_text_bounds(doc: &mut Document) { // Inline graphics in text take their size from their symbols' art (not saved either). doc.resolve_inline_art(); - let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art)); - let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art)); - for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) { - refresh_tree_text_bounds(tree); + let mut uncached = |t: &TextObject| t.cached_bounds.is_none(); + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut uncached, &mut cmd::typecmd::refresh_bounds_for_first_frame); } } - /// [`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))); + /// Measure the type in open document `index` that `stale` picks (in its trees as + /// [`Session::refresh_text_bounds`] walks them), as a change that is not an edit: a saved + /// document stays saved, and its undo history keeps its documents as they are. The document is + /// left as it is when `stale` picks nothing. + fn refresh_document_text_bounds(&mut self, index: usize, mut stale: impl FnMut(&TextObject) -> bool) { + let Some(st) = self.docs.get_mut(index) else { return }; + if !text_trees(&st.doc).any(|n| has_text_where(n, &mut stale)) { + return; } + st.change_unsaved(|doc| { + let doc = Arc::make_mut(doc); + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut stale, &mut cmd::typecmd::refresh_bounds_for_first_frame); + } + // Inline graphics take their size from their symbols' art, whose type may just have + // been measured again: the text holding one whose size changed is measured again too. + if !doc.resolve_inline_art().is_empty() { + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut |t| t.cached_bounds.is_none(), &mut cmd::typecmd::refresh_bounds_for_first_frame); + } + } + }); + } + + /// Register the composite fonts of open document `index`, which is in front, and measure its + /// type not measured yet and its type set in a font of a name that a composite font of an open + /// document or of the font database has, or had before: that type may have been laid out in + /// another document's composite fonts (those registered while its file was read for opening). + fn sync_opened_composite_fonts(&mut self, index: usize) { + let mut names = cmd::compositefonts::names_in_use(self); + names.extend(cmd::compositefonts::sync(self)); + self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &names)); } /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, @@ -234,6 +272,8 @@ impl Session { // (importers and the native reader have seen to theirs already, with a warning). doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); + // Inline graphics in text take their size from their symbols' art (not saved). + doc.resolve_inline_art(); // Guides from files saved before guides had layers go on a layer. doc.adopt_guides(); cmd::dimension::conform(&mut doc); @@ -244,8 +284,7 @@ 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); + self.sync_opened_composite_fonts(i); if self.depth == 0 && let Some(label) = self.explicit_undo_group.clone() { @@ -262,11 +301,26 @@ impl Session { } doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); + doc.resolve_inline_art(); doc.adopt_guides(); cmd::dimension::conform(&mut doc); + // In the back, type set in a font named like a composite font is measured when the document + // comes to the front and its own composite fonts are registered, not in the front + // document's. + let mut back_names = None; if self.active == Some(index) { // Pending tool work (typing, a drag) belongs to the content being thrown away. self.reset_tool_for_doc_switch(); + } else { + let mut names = cmd::compositefonts::names_in_use(self); + names.extend(doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + for tree in text_trees_mut(&mut doc) { + each_text_where(tree, &mut |t| t.cached_bounds.is_some() && sets_in_any(t, &names), &mut |t| { + t.cached_bounds = None; + t.cached_baselines.clear(); + }); + } + back_names = Some(names); } let old = &self.docs[index]; let mut st = DocState::new(doc, old.path.clone()); @@ -297,12 +351,10 @@ 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); + match back_names { + Some(names) => self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() && !sets_in_any(t, &names)), + None => self.sync_opened_composite_fonts(index), } - self.refresh_document_text_bounds(index); true } @@ -319,7 +371,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); + self.sync_front_composite_fonts(); true } @@ -344,33 +396,56 @@ impl Session { true } - /// Commands with enablement (for menus, palette, MCP `list_commands`). + /// Commands with enablement (for menus, palette, MCP `list_commands`), the installed plug-ins' + /// commands last. pub fn commands(&self) -> Vec { - command_specs().iter().map(|c| c.info(self)).collect() + command_specs().iter().chain(crate::cmd::plugin::command_specs()).map(|c| c.info(self)).collect() } } -/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the -/// nodes on the way to such type are copied. -fn refresh_tree_text_bounds(n: &mut Arc) { - fn stale(n: &vectorcraft_doc::Node) -> bool { - matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none()) - || n.extras().mask.as_deref().is_some_and(|m| stale(&m.art)) - || n.children().is_some_and(|ch| ch.iter().any(|c| stale(c))) - } - if !stale(n) { +/// Whether some of type `t` is set in a font named (in any case) one of `names`, lowercased. +fn sets_in_any(t: &TextObject, names: &HashSet) -> bool { + !names.is_empty() && t.runs.iter().any(|r| names.contains(&r.style.font_family.to_lowercase())) +} + +/// The trees of `doc` that hold type: its layers, symbol definitions, graph designs and pattern +/// tiles. +fn text_trees(doc: &Document) -> impl Iterator> { + let trees = doc.layers.iter().chain(doc.symbols.iter().map(|s| &s.art)); + let trees = trees.chain(doc.graph_designs.iter().map(|g| &g.art)); + trees.chain(doc.patterns.iter().flat_map(|p| p.art.iter())) +} + +/// [`text_trees`], to change. +fn text_trees_mut(doc: &mut Document) -> impl Iterator> { + let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art)); + let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art)); + trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) +} + +/// Whether tree `n` (opacity mask art included) holds type that `pick` picks. +fn has_text_where(n: &Node, pick: &mut dyn FnMut(&TextObject) -> bool) -> bool { + matches!(&n.kind, NodeKind::Text(t) if pick(t)) + || n.extras().mask.as_deref().is_some_and(|m| has_text_where(&m.art, pick)) + || n.children().is_some_and(|ch| ch.iter().any(|c| has_text_where(c, pick))) +} + +/// Run `act` on the type in tree `n` (opacity mask art included) that `pick` picks. Only the nodes +/// on the way to such type are copied. +fn each_text_where(n: &mut Arc, pick: &mut dyn FnMut(&TextObject) -> bool, act: &mut dyn FnMut(&mut TextObject)) { + if !has_text_where(n, pick) { return; } let n = Arc::make_mut(n); if let NodeKind::Text(t) = &mut n.kind - && t.cached_bounds.is_none() + && pick(t) { - cmd::typecmd::refresh_bounds_for_first_frame(t); + act(t); } if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { - refresh_tree_text_bounds(&mut m.art); + each_text_where(&mut m.art, pick, act); } for c in n.children_mut().into_iter().flatten() { - refresh_tree_text_bounds(c); + each_text_where(c, pick, act); } } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 5ad84885f..5f245ce90 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -76,9 +76,13 @@ impl Session { cmd::ids_param(params, "ids", id)?; } // Clear's menu needs a selection; an agent supplying explicit targets only needs a - // document. The command validates those targets before making any edit. + // document. The command validates those targets before making any edit. Cut and Copy given + // the artboards the journal recorded need only a document too, whatever is selected and + // whichever tool is chosen. let given = given_ids(spec.params, params); - let enabled = if id == "edit.clear" && params.get("ids").is_some() { + let enabled = if (id == "edit.clear" && params.get("ids").is_some()) + || (matches!(id, "edit.copy" | "edit.cut") && params.get("selectedArtboards").is_some()) + { cmd::has_doc(self) } else if let Some(ids) = given.clone() { // Objects given as `ids` (or `id`) stand in for the selection: the command is enabled @@ -113,7 +117,7 @@ impl Session { if let Some(ids) = &select_first { s.execute("select.set", &serde_json::json!({ "ids": ids }))?; } - let r = (spec.run)(s, params); + let r = cmd::run_spec(s, spec, params); if r.is_err() && let Some((uid, selection, layer)) = selected_before && let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid) @@ -131,6 +135,9 @@ impl Session { let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) }; if r.is_err() && self.depth == 0 { self.batch_paint_docs.clear(); + // A failed or panicked command's documents are put back: type is drawn in their + // composite fonts again, not in those the command set. + cmd::compositefonts::sync(self); } let r = r?; if self.depth == 0 { diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index a94696d16..60a9bac8e 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2969,6 +2969,91 @@ fn image_trace_make_places_paths_over_the_image() { assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0))); } +#[test] +fn image_trace_keep_colors_traces_only_the_colors_listed() { + // A light field with a black ring, a red square and a blue bar on it. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 60.0); + if (34.0..=40.0).contains(&r) { + [0, 0, 0, 255] + } else if (130..170).contains(&x) && (30..70).contains(&y) { + [200, 40, 50, 255] + } else if (30..170).contains(&x) && (110..130).contains(&y) { + [40, 80, 200, 255] + } else { + [240, 240, 220, 255] + } + }); + let traced = |keep: Value| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 4, "keepColors": keep}}); + let r = s.execute("imageTrace.makeAndExpand", ¶ms)?; + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let fills: Vec = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect(); + Ok::<_, EngineError>((r["colors"].clone(), fills)) + }; + let (black, red, blue, light) = (Color::rgb8(0, 0, 0), Color::rgb8(200, 40, 50), Color::rgb8(40, 80, 200), Color::rgb8(240, 240, 220)); + let has = |fills: &[Paint], c: Color| fills.contains(&Paint::solid(c)); + // Nothing listed: every color. + let (colors, fills) = traced(json!([])).unwrap(); + assert_eq!(colors, 4); + assert!([black, red, blue, light].into_iter().all(|c| has(&fills, c)), "{fills:?}"); + // The black and the red only. + let (colors, fills) = traced(json!(["#000000", "#c8283c"])).unwrap(); + assert_eq!(colors, 2); + assert!(has(&fills, black) && has(&fills, red), "{fills:?}"); + assert!(fills.iter().all(|f| *f == Paint::solid(black) || *f == Paint::solid(red)), "{fills:?}"); + // A color that is none is refused. + let e = traced(json!(["#000000", "black"])).unwrap_err().to_string(); + assert!(e.contains("\"black\"") && e.contains("#rrggbb"), "{e}"); + assert!(find_command("imageTrace.make").unwrap().params.contains("keepColors?: [\"#rrggbb\"…]")); + assert!(find_command("imageTrace.makeAndExpand").unwrap().params.contains("warnings: [text…] (as imageTrace.make)")); +} + +#[test] +fn image_trace_keep_colors_warns_about_colors_that_match_nothing() { + // A light field with a black ring and a red square on it. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 60.0); + if (34.0..=40.0).contains(&r) { + [0, 0, 0, 255] + } else if (130..170).contains(&x) && (30..70).contains(&y) { + [200, 40, 50, 255] + } else { + [240, 240, 220, 255] + } + }); + let traced = |params: Value| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let r = s.execute("imageTrace.makeAndExpand", &json!({ "params": params })).unwrap(); + (r["paths"].as_u64().unwrap(), r["warnings"].clone()) + }; + let color = |keep: Value| traced(json!({"mode": "color", "palette": "limited", "colors": 3, "keepColors": keep})); + // Every color listed is in the image: no warning. + let (paths, warnings) = color(json!(["#000000", "#c8283c"])); + assert!(paths > 0); + assert_eq!(warnings, json!([])); + // No color matches: nothing is traced, and the reply says why. + assert_eq!( + color(json!(["#ff00ff", "#00ffff"])), + (0, json!(["Nothing was traced: none of the colors the trace found matches keepColors (#ff00ff, #00ffff)."])) + ); + // One color matches nothing: the others are traced, and that one is named. + let (paths, warnings) = color(json!(["#000000", "#00ffff"])); + assert!(paths > 0); + assert_eq!(warnings, json!(["keepColors: none of the colors the trace found matches #00ffff, so nothing of it is traced."])); + // Ignore Color leaves out the only color listed. + let ignored = + traced(json!({"mode": "color", "palette": "limited", "colors": 3, "keepColors": ["#000000"], "ignoreWhite": true, "ignoreColor": "#000000"})); + assert_eq!(ignored, (0, json!(["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists."]))); + // Flat Logo mode does not use the list. + let (paths, warnings) = traced(json!({"mode": "logo", "keepColors": ["#ff00ff"]})); + assert!(paths > 0); + assert_eq!(warnings, json!(["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds."])); +} + /// #3098: a trace made away from the command (off the UI thread) and handed in makes the command's /// own edit, as one undo step; Cancel stops it; once its image has changed it no longer applies. #[test] @@ -3012,6 +3097,42 @@ fn a_trace_run_hands_its_trace_to_the_command() { assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}"); } +#[test] +fn image_trace_ignore_background_keeps_the_color_inside_the_art() { + // A black field, a light panel on it and a black ring on the panel. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 75.0); + if !((20..180).contains(&x) && (20..130).contains(&y)) || (37.0..=43.0).contains(&r) { [0, 0, 0, 255] } else { [240, 240, 220, 255] } + }); + let traced = |background: bool| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "ignoreWhite": true, "ignoreColor": "#000000", "ignoreBackground": background}}); + let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap(); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let black: Vec<_> = g + .children() + .unwrap() + .iter() + .filter(|n| n.appearance.fill_paint() == Paint::solid(Color::rgb8(0, 0, 0))) + .map(|n| outline(n).0.bounds().unwrap()) + .collect(); + (r["colors"].clone(), black) + }; + // The whole color: the ring goes with the field. + let (colors, black) = traced(false); + assert_eq!(colors, 1); + assert!(black.is_empty(), "{black:?}"); + // The background only: the ring stays, and nothing black reaches the image's edge. + let (colors, black) = traced(true); + assert_eq!(colors, 2); + assert_eq!(black.len(), 1, "{black:?}"); + let bb = black[0]; + assert!((bb.x0 - 57.0).abs() < 1.5 && (bb.x1 - 143.0).abs() < 1.5 && (bb.y0 - 32.0).abs() < 1.5 && (bb.y1 - 118.0).abs() < 1.5, "{bb:?}"); + // The params doc tells agents about the option. + assert!(find_command("imageTrace.make").unwrap().params.contains("ignoreBackground: false")); +} + /// A Document Library trace made away from its command no longer applies once the library's /// colours change: the settings' JSON leaves them out (#4605 review). #[test] diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index f6902e7a9..76c3f5f8a 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1440,17 +1440,17 @@ fn spiral_drags_with_alt_and_ctrl_change_its_segments_and_decay() { }; s.select_tool("spiral", v).unwrap(); let undo = undo_steps(&s); - // Alt from a radius of 100 to 200: 10 segments become 14, one full wind of four quarter turns. + // Alt from a radius of 100 to 200: preserving the inner end adds three full winds. let (anchors, outer) = spiral(&mut s, Mods { alt: true, ..Mods::default() }, (300.0, 100.0)); - assert_eq!(anchors, 15); + assert_eq!(anchors, 23); assert!(near(outer, Point::new(300.0, 100.0)), "{outer:?}"); - assert_eq!(s.tool_options()["segments"], json!(14)); - // Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(1/14) %. + assert_eq!(s.tool_options()["segments"], json!(22)); + // Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(4/22) %. let (anchors, outer) = spiral(&mut s, Mods { cmd: true, ..Mods::default() }, (150.0, 100.0)); - assert_eq!(anchors, 15); + assert_eq!(anchors, 23); assert!(near(outer, Point::new(150.0, 100.0)), "{outer:?}"); let decay = s.tool_options()["decay"].as_f64().unwrap(); - assert!((decay - 80.0 * 2f64.powf(1.0 / 14.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 80.0 * 2f64.powf(4.0 / 22.0)).abs() < 1e-9, "{decay}"); assert_eq!(undo_steps(&s) - undo, 2, "one undo step a spiral"); } diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 9ec00cd24..b946f7192 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -4,6 +4,7 @@ //! Fill or Stroke changes it. use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -107,6 +108,314 @@ fn static_symbols_and_bad_targets_are_refused() { assert_eq!(overrides(&s, inst), 0); } +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The frontmost object of the active layer. +fn top(s: &Session) -> NodeId { + let d = &s.doc().unwrap().doc; + d.node(d.default_layer().unwrap()).unwrap().children().unwrap().last().unwrap().id +} + +/// Every pixel of the page. +fn page(s: &Session) -> Vec<[u8; 4]> { + let r = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, true); + (0..r.height).flat_map(|y| (0..r.width).map(move |x| (x, y))).map(|(x, y)| r.pixel(x, y)).collect() +} + +/// A dynamic symbol "Art" of the frontmost object, made in place: its instance. +fn art_of_top(s: &mut Session) -> NodeId { + let t = top(s); + id(&s.execute("symbol.new", &json!({"name": "Art", "ids": [t.0], "type": "dynamic"})).unwrap()) +} + +/// Put a red fill and stroke on the root of instance `inst`'s art directly, past the command. +fn paint_root_red(s: &mut Session, inst: NodeId) { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(red.clone()), stroke: Some(red), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); +} + +/// A 30×30 red image placed with its frame from (50, 20) to (80, 50). +fn place_image(s: &mut Session) { + let png = crate::tests_links::png(30, 30, [255, 0, 0]); + let data = vectorcraft_format::base64_encode(&png); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": data, "at": [65, 35], "link": false})).unwrap(); +} + +fn select_top(s: &mut Session, n: usize) { + let d = &s.doc().unwrap().doc; + let kids = d.node(d.default_layer().unwrap()).unwrap().children().unwrap(); + let ids: Vec = kids[kids.len() - n..].iter().map(|c| c.id.0).collect(); + s.execute("select.set", &json!({ "ids": ids })).unwrap(); +} + +#[test] +fn objects_that_draw_no_fill_or_stroke_of_their_own_refuse_one() { + let folder = crate::tests_links::Folder::new("dynamic-symbol-kinds"); + let source = folder.file("placed.vectorcraft"); + let mut doc = vectorcraft_doc::Document::new(40.0, 40.0); + let layer = doc.layers[0].id; + let look = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let rect = vectorcraft_doc::Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), look); + doc.insert(Some(layer), 0, rect).unwrap(); + crate::tests_links::write(&source, &vectorcraft_format::save_file(&doc)); + type Make = Box; + let cases: Vec<(&str, &str, Make)> = vec![ + ( + "guide", + "a guide", + Box::new(|s| { + square(s, 10.0); + select_top(s, 1); + s.execute("view.guides.make", &json!({})).unwrap(); + }), + ), + ( + "blend", + "a blend", + Box::new(|s| { + let (a, b) = (square(s, 10.0), square(s, 60.0)); + s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap(); + }), + ), + ( + "envelope", + "an envelope", + Box::new(|s| { + square(s, 10.0); + select_top(s, 1); + s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 50})).unwrap(); + }), + ), + ("radial Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.radial"))), + ("grid Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.grid"))), + ("mirror Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.mirror"))), + ( + "Intertwine", + "an Intertwine group", + Box::new(|s| { + square(s, 10.0); + square(s, 30.0); + select_top(s, 2); + s.execute("object.intertwine.make", &json!({})).unwrap(); + }), + ), + ( + "gradient mesh", + "a gradient mesh", + Box::new(|s| { + let r = square(s, 10.0); + s.execute("object.mesh.create", &json!({"ids": [r.0], "rows": 1, "cols": 1})).unwrap(); + }), + ), + ("image", "an image", Box::new(place_image)), + ( + "placed document", + "a placed document", + Box::new(move |s| { + let r = s.execute("file.place", &json!({"path": source, "at": [40, 40]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + }), + ), + ]; + // Every case runs, and the failure lists each object that fails. + let mut failures = Vec::new(); + for (label, kind, make) in cases { + let mut s = session(); + make(&mut s); + let inst = art_of_top(&mut s); + if let Err(why) = refuses_own_paint(&mut s, inst, kind) { + failures.push(format!("{label}: {why}")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// Check that the root of instance `inst`'s art, an object of `kind` that draws no fill or stroke +/// of its own, refuses both, takes null, opacity and blend, and draws no stored fill or stroke. +fn refuses_own_paint(s: &mut Session, inst: NodeId, kind: &str) -> std::result::Result<(), String> { + let set = |s: &mut Session, p: &Value| s.execute("symbol.instance.setAppearance", p); + let undo = undo_len(s); + for key in ["fill", "stroke"] { + for paint in [json!("#ff0000"), json!({"none": true})] { + // With a valid opacity too: nothing of the call is applied. + let p = json!({"id": inst.0, "path": [], key: paint, "opacity": 50}); + match set(s, &p) { + Ok(_) => return Err(format!("{p} was taken")), + Err(e) if !e.to_string().contains(&format!("{key} cannot be set on {kind}")) => return Err(format!("{p}: {e}")), + Err(_) => {} + } + } + } + if (overrides(s, inst), undo_len(s)) != (0, undo) { + return Err("a refused call changed the instance or added an undo step".into()); + } + // Null (taking a change back), opacity and blend are taken. + let clear = json!({"id": inst.0, "path": [], "fill": null, "stroke": null}); + for p in [clear, json!({"id": inst.0, "path": [], "opacity": 50, "blend": "multiply"})] { + set(s, &p).map_err(|e| format!("{p}: {e}"))?; + } + if overrides(s, inst) != 1 { + return Err("opacity and blend were not stored".into()); + } + // The renderer draws no fill or stroke of the object's own: a stored one changes nothing. + let before = page(s); + paint_root_red(s, inst); + if page(s) != before { + return Err("a stored fill and stroke changed the canvas".into()); + } + Ok(()) +} + +fn repeat(s: &mut Session, cmd: &str) { + square(s, 10.0); + select_top(s, 1); + s.execute(cmd, &json!({})).unwrap(); +} + +#[test] +fn a_symbol_instance_in_the_art_takes_a_fill_that_stains_it_and_no_stroke() { + let mut s = session(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string(); + assert!(e.contains("stroke cannot be set on a symbol instance"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo)); + // A stroke stored past the command draws nothing. + let before = page(&s); + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], stroke: Some(red), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a stroke on a symbol instance draws nothing"); + // The fill stains the nested instance's art. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 30, 30), RED, "the fill stains the art"); +} + +#[test] +fn a_symbol_instance_in_the_art_refuses_a_gradient_fill() { + let mut s = session(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let gradient = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#00ff00"}]}); + let p = json!({"id": inst.0, "path": [], "fill": {"gradient": gradient}, "opacity": 50}); + let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string(); + assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step"); + // A gradient fill stored past the command draws nothing. + let before = page(&s); + let paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a gradient fill on a symbol instance draws nothing"); + // A solid fill and none are taken. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": {"none": true}})).unwrap(); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 30, 30), RED); +} + +#[test] +fn a_symbol_instance_in_the_art_refuses_a_pattern_fill() { + let mut s = session(); + let tile = id(&s.execute("shape.rectangle", &json!({"x": 150, "y": 60, "width": 10, "height": 10})).unwrap()); + s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let p = json!({"id": inst.0, "path": [], "fill": {"swatch": "Dots"}, "opacity": 50}); + let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string(); + assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step"); + // The pattern draws on a path. + let probe = square(&mut s, 110.0); + let before = page(&s); + s.execute("paint.setFill", &json!({"ids": [probe.0], "swatch": "Dots"})).unwrap(); + assert!(page(&s) != before, "the pattern draws on a path"); + // A pattern fill stored on the nested instance past the command draws nothing. + let before = page(&s); + let paint = s.doc().unwrap().doc.swatch("Dots").unwrap().paint.clone(); + assert!(matches!(paint, Paint::Pattern { .. }), "{paint:?}"); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a pattern fill on a symbol instance draws nothing"); +} + +#[test] +fn null_takes_back_an_old_change_an_object_does_not_draw() { + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap(); + let inst = art_of_top(&mut s); + // A change stored before the command refused one, as an older document may hold. + paint_root_red(&mut s, inst); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": null})).unwrap(); + assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_none()); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": null})).unwrap(); + assert_eq!(overrides(&s, inst), 0); +} + +#[test] +fn paths_type_groups_and_shaped_image_paint_take_a_fill_and_stroke() { + let set = |s: &mut Session, inst: NodeId, path: Value| { + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": path, "fill": "#ff0000", "stroke": "#ff0000"})).unwrap() + }; + // A group's own fill paints over its members. + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + assert_eq!(set(&mut s, inst, json!([]))["overrides"], 1); + assert_eq!(pixel(&s, 30, 30), RED, "the group's own fill"); + // A compound path, type and a clipping path. + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap(); + let inst = art_of_top(&mut s); + set(&mut s, inst, json!([])); + assert_eq!(pixel(&s, 30, 30), RED, "the compound path"); + let mut s = session(); + s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + let inst = art_of_top(&mut s); + set(&mut s, inst, json!([])); + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0)); + s.execute("object.clippingMask.make", &json!({"ids": [a.0, b.0]})).unwrap(); + let inst = art_of_top(&mut s); + assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { .. })); + set(&mut s, inst, json!([0])); + set(&mut s, inst, json!([])); + // An image whose fill has Convert to Shape: the fill paints 9 pt past its frame. + let mut s = session(); + place_image(&mut s); + s.execute("appearance.addFill", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#00ff00", "item": 0})).unwrap(); + s.execute("effect.apply", &json!({"effect": "convertToShape.rectangle", "item": 0})).unwrap(); + assert_eq!(pixel(&s, 44, 35), [0, 255, 0, 255]); + let inst = art_of_top(&mut s); + let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string(); + assert!(e.contains("stroke cannot be set on an image without a Convert to Shape or Outline Object effect on it"), "{e}"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 44, 35), RED, "the shaped fill takes the instance's color"); +} + #[test] fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() { let mut s = session(); diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 6d4e1a084..7dd2983c2 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -343,23 +343,139 @@ fn artboard_commands_read_the_recorded_selected_artboards() { } } -/// `edit.copy` and `edit.cut` run the Artboard tool's copy and take no artboards, so their entries -/// stay as given, apart from the locked-art preference the Artboard tool's copy notes. +/// A Cut or Copy with the Artboard tool replays on the artboards it took in a new session, where +/// another tool is chosen: with nothing selected there, and with an object selected that a Cut or +/// Copy of objects would take. What it pastes is the same. #[test] -fn edit_copy_and_cut_with_the_artboard_tool_record_their_params_as_given() { +fn edit_copy_and_cut_with_the_artboard_tool_replay_on_its_artboards() { + for id in ["edit.copy", "edit.cut"] { + for select in ["select.all", "select.none"] { + let mut s = Session::new(); + second_artboard_selected(&mut s); + s.execute(select, &json!({})).unwrap(); + s.execute(id, &json!({})).unwrap(); + let boards = if id == "edit.cut" { 2 } else { 3 }; + assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "{id} after {select}"); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id} after {select}"); + let entry = s.journal.last().unwrap().clone(); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), boards + 1, "{id} after {select}"); + let r = replayed(&s); + assert_ne!(r.tool_id(), "artboard"); + assert_eq!(documents(&r), documents(&s), "{id} after {select}"); + assert_eq!(entry, (id.to_string(), json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false})), "{id} after {select}"); + } + } +} + +/// A Copy after a Paste takes the pasted artboards, which the Artboard tool selected, and so does its +/// replay, though `edit.paste` selects nothing in a new session's tool. +#[test] +fn edit_copy_after_a_paste_with_the_artboard_tool_replays_on_the_pasted_artboards() { + let mut s = Session::new(); + second_artboard_selected(&mut s); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [3], "art": true, "lockedAndHidden": false})); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 5); + assert_eq!(documents(&replayed(&s)), documents(&s)); +} + +/// A recorded action keeps `edit.copy` and `edit.cut` as given, so when it plays they follow the tool +/// chosen then, and a batch step records and leaves out the same values. The locked-art preference +/// that the Artboard tool's copy notes stays in the action. +#[test] +fn edit_copy_and_cut_with_the_artboard_tool_record_nothing_for_an_action() { for id in ["edit.copy", "edit.cut"] { let mut s = Session::new(); - second_artboard_selected(&mut s); + let start = second_artboard_selected(&mut s); s.execute(id, &json!({})).unwrap(); - assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({"lockedAndHidden": false})), "{id}"); + let action = s.journal_for_action(start); + assert_eq!(action, [(id.to_string(), json!({"lockedAndHidden": false}))], "{id}"); + let mut played = Session::new(); + played.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + played.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + played.execute(id, &action[0].1).unwrap(); + assert_eq!(played.doc().unwrap().doc.artboards.len(), 1, "{id}"); + assert!(played.clipboard.artboards.is_empty(), "{id}"); + assert_eq!(played.clipboard.nodes.len(), 1, "{id}"); + + let mut s = Session::new(); + let start = second_artboard_selected(&mut s); + let steps = json!([{"command": id, "params": {}}, {"command": "edit.paste", "params": {}}]); + s.execute("command.batch", &json!({ "commands": steps })).unwrap(); + assert_eq!( + s.journal.last().unwrap().1["commands"][0]["params"], + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + "{id}" + ); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id}"); + assert_eq!(s.journal_for_action(start)[0].1["commands"][0]["params"], json!({"lockedAndHidden": false}), "{id}"); } } +/// Without the Artboard tool, Cut and Copy of objects record nothing more than they did. +#[test] +fn edit_copy_and_cut_of_objects_record_their_params_as_given() { + for id in ["edit.copy", "edit.cut"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute(id, &json!({})).unwrap(); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({})), "{id}"); + assert!(s.clipboard.artboards.is_empty(), "{id}"); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id}"); + } +} + +/// A recorded `selectedArtboards` needs only a document and is checked as `indices` is, as are `art` +/// and the rule that a cut leaves one artboard. A failed call changes neither the document nor the +/// clipboard. +#[test] +fn edit_copy_and_cut_read_the_recorded_artboards() { + for id in ["edit.copy", "edit.cut"] { + assert!(matches!(Session::new().execute(id, &json!({"selectedArtboards": [0]})), Err(crate::EngineError::Disabled(..)))); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 1010, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id}"); + let before = documents(&s); + for bad in [ + json!({"selectedArtboards": [9]}), + json!({"selectedArtboards": []}), + json!({"selectedArtboards": "1"}), + json!({"selectedArtboards": null}), + json!({"selectedArtboards": [1], "art": "yes"}), + ] { + let err = s.execute(id, &bad).unwrap_err().to_string(); + assert!(err.contains(id) || err.contains("no such artboard"), "{id} {bad}: {err}"); + assert_eq!(documents(&s), before, "{id} {bad}"); + assert!(s.clipboard.artboards.is_empty(), "{id} {bad}"); + } + s.execute(id, &json!({"selectedArtboards": [1], "art": false})).unwrap(); + assert_eq!(s.clipboard.artboards.len(), 1, "{id}"); + assert!(s.clipboard.nodes.is_empty(), "{id}"); + assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [1], "art": false}), "{id}"); + // Objects selected, `art` is still checked. + s.execute("select.all", &json!({})).unwrap(); + let err = s.execute(id, &json!({"art": "yes"})).unwrap_err().to_string(); + assert!(err.contains(id), "{id}: {err}"); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + assert!(s.execute("edit.cut", &json!({"selectedArtboards": [0]})).is_err()); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 1); +} + /// `artboard.duplicate` and the cut and copy record the tool's Move/Copy Artwork option: replayed in /// a new session, where the option is on, the art stays behind as it did. #[test] fn artboard_commands_record_the_tools_move_art_option() { - for id in ["artboard.duplicate", "artboard.cut", "artboard.copy"] { + for id in ["artboard.duplicate", "artboard.cut", "artboard.copy", "edit.cut", "edit.copy"] { let mut s = Session::new(); second_artboard_selected(&mut s); s.set_tool_option("moveArt", &json!(false)); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 6c5c05572..644120d34 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -164,6 +164,48 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_ } } +#[test] +fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { + // The object's own effect, and the fill's (the first appearance item). + for (effect, params, item) in [ + ("warp.arc", json!({"bend": 40}), json!(null)), + ("warp.bulge", json!({"bend": 40, "orientation": "vertical"}), json!(null)), + ("warp.inflate", json!({"bend": 40}), json!(0)), + ] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(40.0, 70.0, 160.0, 130.0), white_black(), false); + // Placed from a quarter of the way across to the right edge. + run(&mut s, "paint.setGradientGeom", json!({"start": [70, 100], "end": [160, 100], "stroke": false})); + let placed = s.doc().unwrap().doc.node(id).and_then(|n| match n.appearance.fill_paint() { + vectorcraft_color::Paint::Gradient(g) => g.geom, + _ => None, + }); + assert!(placed.is_some(), "the fill's gradient is placed"); + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + let before = render(&s); + // Fill alone: the gradient becomes strips inside the outline the warp makes. + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + let diff = mean_diff(&before, &after); + assert!(diff < 1.0, "{effect} {params} on item {item}: mean difference {diff}"); + // The ramp's midpoint, 115 pt before the warp, moves and scales with the warped bounds. + let doc = &s.doc().unwrap().doc; + let b = doc.node(doc.layers[0].id).and_then(|l| l.geometric_bounds()).unwrap(); + let want = b.x0 + (115.0 - 40.0) / 120.0 * b.width(); + assert!((want - 115.0).abs() > 2.0, "{effect} {params}: the warp moves the midpoint ({b:?})"); + let red = |x: usize| f64::from(after.pixels[(100 * 200 + x) * 4]); + // White turns dark once on row 100, and gradually, so the crossing is the ramp's midpoint + // and not an edge of the outline. + let crossings: Vec = + (b.x0.ceil() as usize + 3..b.x1.floor() as usize - 4).filter(|&x| red(x) >= 127.5 && red(x + 1) < 127.5).collect(); + assert_eq!(crossings.len(), 1, "{effect} {params}: white turns dark once on row 100: {crossings:?}"); + let x = crossings[0]; + assert!(red(x - 2) - red(x + 3) < 100.0, "{effect} {params}: the ramp, not an edge: {} to {}", red(x - 2), red(x + 3)); + let half = x as f64 + 0.5 + (red(x) - 127.5) / (red(x) - red(x + 1)); + assert!((half - want).abs() < 1.0, "{effect} {params} on item {item}: the midpoint is at {half}, want {want}"); + } +} + #[test] fn eight_strips_match_the_gradient_samples() { let mut s = session(); diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index 955e9798e..166c88725 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -1,6 +1,8 @@ //! Outline Stroke (`object.path.outlineStroke`) and the live Outline Stroke effect produce the //! stroke as the canvas paints it: dashes, arrowheads, width profiles, alignment, every stroke -//! item in order, with the stroke's opacity and blend mode moved to the new fill. +//! item in order, with the stroke's opacity and blend mode moved to the new fill. Outline Stroke, +//! Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines apply the +//! object's geometry effects and each stroke's own to the path before outlining that stroke. use serde_json::{Value, json}; use vectorcraft_color::BlendMode; @@ -180,6 +182,329 @@ fn the_live_effect_outlines_the_stroke_it_sits_on() { assert!(close(g.bounds().unwrap(), Rect::new(0.0, 49.0, 100.0, 51.0), 0.01), "{:?}", g.bounds()); } +fn render(s: &Session) -> vectorcraft_render::Rendered { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&doc, 400, 300, vectorcraft_geom::Affine::IDENTITY, &opts) +} + +/// Pixels whose color differs by more than `tol` in any channel. +fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize { + a.pixels + .as_chunks::<4>() + .0 + .iter() + .zip(b.pixels.as_chunks::<4>().0) + .filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)) + .count() +} + +/// Does `n` or anything in it have a geometry effect, its own or a fill's or stroke's? +fn has_geometry_effects(n: &Node) -> bool { + let geometry = |e: &vectorcraft_doc::Effect| vectorcraft_render::effects::is_geometry(&e.id); + let mut found = false; + n.walk(&mut |m| found |= m.appearance.effects.iter().any(geometry) || m.appearance.items.iter().any(|i| i.effects().iter().any(geometry))); + found +} + +/// A selected 100 pt square at (150, 100) with a 4 pt black stroke and fill `fill` (paint.setFill's), +/// and `effects` (effect.apply's `effect` and `params`) on appearance item `item` (`null`: on the +/// object). +fn square_with(s: &mut Session, fill: Value, item: Value, effects: &[(&str, Value)]) { + s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + for (effect, params) in effects { + s.execute("effect.apply", &json!({"effect": effect, "params": params, "item": item})).unwrap(); + } +} + +/// The geometry effects the tests put on the stroke or on the object. +fn geometry_effects() -> [(&'static str, Value); 2] { + [("path.offsetPath", json!({"offset": 10})), ("distort.zigZag", json!({"size": 6, "ridges": 3}))] +} + +/// The commands that outline strokes: Outline Stroke, Expand with Stroke alone, and Flatten +/// Transparency with Convert All Strokes to Outlines. +fn outlining() -> [(&'static str, Value); 3] { + [ + ("object.path.outlineStroke", json!({})), + ("object.expand", json!({"object": false, "fill": false, "stroke": true})), + ("object.flattenTransparency", json!({"strokesToOutlines": true})), + ] +} + +/// What `command` with `params` leaves in place of the selected object. +fn outlined_by(s: &mut Session, command: &str, params: &Value) -> Node { + let r = s.execute(command, params).unwrap_or_else(|e| panic!("{command}: {e}")); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// For each command in [`outlining`] and each of [`geometry_effects`] on `item` of a square filled +/// with `fill`: the pixels that differ by more than 64 from the canvas before, and whether a +/// geometry effect is left on the result. +fn outlined_as_drawn(fill: Value, item: Value) -> Vec<(&'static str, &'static str, usize, bool)> { + let mut results = vec![]; + for (command, p) in outlining() { + for (effect, params) in geometry_effects() { + let mut s = session(); + square_with(&mut s, fill.clone(), item.clone(), &[(effect, params)]); + let before = render(&s); + let n = outlined_by(&mut s, command, &p); + results.push((command, effect, differing(&before, &render(&s), 64), has_geometry_effects(&n))); + } + } + results +} + +/// What [`outlined_as_drawn`] gives when every outline looks as the canvas drew the object. +fn as_drawn() -> Vec<(&'static str, &'static str, usize, bool)> { + outlining().iter().flat_map(|(command, _)| geometry_effects().map(|(effect, _)| (*command, effect, 0, false))).collect() +} + +/// A stroke's own geometry effects reshape the path it strokes, as on the canvas: the outline is +/// the stroke of the offset or zig-zagged path, not the stroke's outline offset or zig-zagged. +#[test] +fn a_geometry_effect_on_the_stroke_shapes_the_path_it_outlines() { + assert_eq!(outlined_as_drawn(json!({"none": true}), json!(1)), as_drawn()); + // The stroke of the square offset by 10: 4 pt wide around a 120 pt square. + let mut s = session(); + square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10}))]); + let n = outline(&mut s); + assert!(close(n.geometric_bounds().unwrap(), Rect::new(138.0, 88.0, 262.0, 212.0), 0.01), "{:?}", n.geometric_bounds()); + assert!(near(area(&n), 124.0 * 124.0 - 116.0 * 116.0, 2.0), "{}", area(&n)); +} + +/// A stroke whose geometry effect leaves no path paints nothing on the canvas, and outlining it +/// replaces the live stroke with an empty group. +#[test] +fn a_stroke_its_effects_empty_is_outlined_to_nothing() { + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": -60}))]); + let before = render(&s); + let n = outlined_by(&mut s, command, &p); + assert!(matches!(&n.kind, NodeKind::Group { children, .. } if children.is_empty()), "{command}: {n:?}"); + let mut stroked = false; + for layer in &s.doc().unwrap().doc.layers { + layer.walk(&mut |m| stroked |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))); + } + assert!(!stroked, "{command} left a live stroke"); + assert_eq!(differing(&before, &render(&s), 64), 0, "{command}"); + } +} + +/// A drop shadow on a stroke with a geometry effect stays on the outline, which draws as the +/// stroke did. +#[test] +fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() { + let mut s = session(); + square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10})), ("stylize.dropShadow", json!({}))]); + let before = render(&s); + let n = outline(&mut s); + let kept: Vec<&str> = n.appearance.items.iter().flat_map(|i| i.effects()).map(|e| e.id.as_str()).collect(); + assert_eq!(kept, ["stylize.dropShadow"]); + assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did"); +} + +/// The brush art of a brushed stroke with a geometry effect follows the path the effect makes. +#[test] +fn a_brush_draws_along_the_path_the_stroke_effects_make() { + let mut s = session(); + square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10}))]); + s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned().unwrap(); + assert!(st.brush.is_some() && st.effects.len() == 1, "{st:?}"); + let before = render(&s); + let n = outline(&mut s); + assert!(!vectorcraft_brush::has_brush(&n) && !has_geometry_effects(&n), "brush art: {n:?}"); + assert_eq!(differing(&before, &render(&s), 64), 0, "the brush art draws as the brushed stroke did"); +} + +/// The object's geometry effects reshape the path its fills paint and its strokes outline, as on +/// the canvas, and do not stay on the result. +#[test] +fn a_geometry_effect_on_the_object_shapes_the_path_it_outlines() { + assert_eq!(outlined_as_drawn(json!({"color": "#33aa55"}), Value::Null), as_drawn()); +} + +/// The canvas paints a gradient fill or stroke on each copy that the object's Transform effect +/// makes by itself; the outlined result draws the same, with the gradient fitted to each copy. +#[test] +fn each_transform_copy_keeps_its_own_gradient() { + let mut differ = vec![]; + for on_stroke in [false, true] { + for (command, p) in outlining() { + let mut s = session(); + let fill = if on_stroke { json!({"none": true}) } else { json!({"gradient": {"kind": "linear"}}) }; + square_with(&mut s, fill, Value::Null, &[("distort.transform", json!({"moveH": 120, "copies": 1}))]); + if on_stroke { + s.execute("paint.setStroke", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("stroke.set", &json!({"weight": 12})).unwrap(); + } + let before = render(&s); + outlined_by(&mut s, command, &p); + differ.push((on_stroke, command, differing(&before, &render(&s), 64))); + } + } + let none_differ: Vec<_> = [false, true].into_iter().flat_map(|on_stroke| outlining().map(|(command, _)| (on_stroke, command, 0))).collect(); + assert_eq!(differ, none_differ); +} + +/// With Scale Strokes & Effects on, the canvas draws the stroke of a solid square that its +/// Transform effect scales to 200% at 8 pt; the outlined result draws the same. +#[test] +fn a_transform_that_scales_strokes_outlines_the_scaled_stroke() { + let scale = json!({"scaleH": 200, "scaleV": 200, "strokes": true}); + let mut differ = vec![]; + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, json!({"color": "#33aa55"}), Value::Null, &[("distort.transform", scale.clone())]); + let before = render(&s); + outlined_by(&mut s, command, &p); + differ.push((command, differing(&before, &render(&s), 64))); + } + let none_differ: Vec<_> = outlining().map(|(command, _)| (command, 0)).into_iter().collect(); + assert_eq!(differ, none_differ); +} + +/// Random in a Transform effect takes its seed from the object's id. The outline draws as the +/// canvas drew the object with Random in a Transform effect that copies a gradient square, in a +/// Transform effect on the stroke of such a square, and in a Transform effect on the fill of a +/// plain square. +#[test] +fn outlines_keep_the_random_transforms_the_canvas_drew() { + let random = json!({"moveH": 30, "moveV": 30, "rotate": 40, "random": true}); + let cases = [ + ("random copies", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "rotate": 30, "copies": 1, "random": true}), None), + ("copies, random stroke", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "copies": 1}), Some(1)), + ("random fill", json!({"color": "#33aa55"}), json!({}), Some(0)), + ]; + let mut differ = vec![]; + for (case, fill, object, item) in &cases { + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, fill.clone(), Value::Null, &[]); + if object != &json!({}) { + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": object})).unwrap(); + } + s.execute("stroke.set", &json!({"weight": 12})).unwrap(); + if let Some(item) = item { + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": random, "item": item})).unwrap(); + } + let before = render(&s); + outlined_by(&mut s, command, &p); + differ.push((*case, command, differing(&before, &render(&s), 64))); + } + } + let none_differ: Vec<_> = cases.iter().flat_map(|(case, ..)| outlining().map(|(command, _)| (*case, command, 0))).collect(); + assert_eq!(differ, none_differ); +} + +/// For each command in [`outlining`] on a stroke-only square with `effects` on appearance item +/// `item`: the pixels that differ by more than 64 from the canvas before, whether a geometry +/// effect is left on the result, and (for Outline Stroke) the outline's box and area. +fn outlined_square(item: Value, effects: &[(&str, Value)], hide: bool) -> (Vec<(&'static str, usize, bool)>, Rect, f64) { + let mut results = vec![]; + let mut shape = (Rect::ZERO, 0.0); + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, json!({"none": true}), item.clone(), effects); + if hide { + for index in 0..effects.len() { + s.execute("effect.setParams", &json!({"index": index, "visible": false, "item": item})).unwrap(); + } + } + let before = render(&s); + let n = outlined_by(&mut s, command, &p); + if command == "object.path.outlineStroke" { + shape = (n.geometric_bounds().unwrap(), area(&n)); + } + results.push((command, differing(&before, &render(&s), 64), has_geometry_effects(&n))); + } + (results, shape.0, shape.1) +} + +/// What [`outlined_square`] gives for each command when the outline draws as the canvas did and +/// keeps no geometry effect. +fn drawn_plain() -> Vec<(&'static str, usize, bool)> { + outlining().map(|(command, _)| (command, 0, false)).into_iter().collect() +} + +/// A path with no effects outlines its own stroke: 4 pt wide around the 100 pt square. +#[test] +fn a_path_without_effects_outlines_its_own_stroke() { + let (results, bounds, a) = outlined_square(Value::Null, &[], false); + assert_eq!(results, drawn_plain()); + assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "{bounds:?}"); + assert!(near(a, 104.0 * 104.0 - 96.0 * 96.0, 0.5), "{a}"); +} + +/// Round Corners on the object rounds the path whose stroke is outlined, with the radius in +/// points as the canvas draws it: corners of radius 12 outside and 8 inside. +#[test] +fn round_corners_on_the_object_round_the_stroke_it_outlines() { + let (results, bounds, a) = outlined_square(Value::Null, &[("stylize.roundCorners", json!({"radius": 10}))], false); + assert_eq!(results, drawn_plain()); + assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "{bounds:?}"); + let rounded = 104.0 * 104.0 - 96.0 * 96.0 - (4.0 - std::f64::consts::PI) * (12.0 * 12.0 - 8.0 * 8.0); + assert!(near(a, rounded, 2.0), "{a}, rounded {rounded}"); +} + +/// A hidden geometry effect, on the object or on the stroke, does not draw on the canvas and does +/// not shape the outline. +#[test] +fn a_hidden_geometry_effect_does_not_shape_the_outline() { + for item in [Value::Null, json!(1)] { + let (results, bounds, a) = outlined_square(item.clone(), &geometry_effects(), true); + let drawn: Vec<_> = results.iter().map(|(command, differ, _)| (*command, *differ)).collect(); + assert_eq!(drawn, drawn_plain().into_iter().map(|(command, differ, _)| (command, differ)).collect::>(), "item {item}"); + assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "item {item}: {bounds:?}"); + assert!(near(a, 104.0 * 104.0 - 96.0 * 96.0, 0.5), "item {item}: {a}"); + } +} + +/// An effect on the object that is not a geometry effect, such as a Gaussian Blur, stays on the +/// result of Outline Stroke and Expand while the geometry effect shapes the path. Flatten +/// Transparency renders raster effects into an image instead. +#[test] +fn an_effect_that_is_not_geometry_stays_on_the_result() { + for (command, p) in outlining().into_iter().filter(|(command, _)| *command != "object.flattenTransparency") { + let mut s = session(); + square_with( + &mut s, + json!({"color": "#33aa55"}), + Value::Null, + &[("distort.zigZag", json!({"size": 6, "ridges": 3})), ("blur.gaussian", json!({}))], + ); + let n = outlined_by(&mut s, command, &p); + let kept: Vec<&str> = n.appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(kept, ["blur.gaussian"], "{command}"); + assert!(!has_geometry_effects(&n), "{command}: {n:?}"); + } +} + +/// Undo after outlining a path with geometry effects on the object and on its stroke gives back +/// the path as it was, effects included, and its selection. +#[test] +fn undo_gives_back_the_path_with_its_effects() { + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, json!({"color": "#33aa55"}), Value::Null, &[("distort.zigZag", json!({"size": 6, "ridges": 3}))]); + s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1})).unwrap(); + let before = s.doc().unwrap().doc.layers.clone(); + let selected = s.doc().unwrap().selection.objects.clone(); + outlined_by(&mut s, command, &p); + assert_ne!(s.doc().unwrap().doc.layers, before, "{command} changed nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before, "{command}"); + assert_eq!(s.doc().unwrap().selection.objects, selected, "{command}"); + } +} + #[test] fn brushed_strokes_outline_to_their_brush_art() { let mut s = session(); diff --git a/crates/engine/src/tests/paragraphs.rs b/crates/engine/src/tests/paragraphs.rs index 93cd13e57..5355cee5b 100644 --- a/crates/engine/src/tests/paragraphs.rs +++ b/crates/engine/src/tests/paragraphs.rs @@ -151,6 +151,42 @@ fn return_in_the_type_tool_continues_the_paragraph_it_splits() { assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]); } +#[test] +fn tab_at_the_start_of_a_list_item_moves_it_a_level_down_in_one_undo_step() { + let mut s = session(); + let v = ViewInfo::default(); + let id = area(&mut s, "one\ntwo"); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + s.select_tool("type", v).unwrap(); + // Click into the second line, then Home: the caret before "two". + let t = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let l1 = &lay.lines[1]; + let at = t.xf * vectorcraft_geom::Point::new((l1.x0 + l1.x1) / 2.0, l1.baseline - 3.0); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, at.x, at.y), v).unwrap(); + } + s.tool_key(ToolKey::Home, Mods::default(), v).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + s.tool_key(ToolKey::Tab, Mods::default(), v).unwrap(); + let levels = |s: &Session| text(s, id).paragraph_styles().iter().map(|p| p.list.map(|l| l.level)).collect::>(); + assert_eq!(levels(&s), [Some(1), Some(2)]); + assert_eq!((text(&s, id).plain_text(), s.doc().unwrap().history.undo.len()), ("one\ntwo".to_string(), undo + 1)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)); + let starts: Vec = lay.markers.iter().map(|m| lay.glyphs[m.glyphs.start].origin.x).collect(); + assert!((starts[1] - starts[0] - 18.0).abs() < 1e-6, "the item moved 18 pt in: {starts:?}"); + assert_eq!(lay.markers.iter().map(|m| m.text.as_str()).collect::>(), ["1.", "1."]); + // Shift+Tab moves it back up, and typing then goes on at the caret. + s.tool_key(ToolKey::Tab, Mods { shift: true, ..Mods::default() }, v).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1)]); + s.tool_text("and ", v).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(text(&s, id).plain_text(), "one\nand two"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((text(&s, id).plain_text(), levels(&s)), ("one\ntwo".to_string(), vec![Some(1), Some(2)])); +} + #[test] fn paragraph_style_applies_to_one_paragraph() { let mut s = session(); diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 2eba6c81a..275d75044 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -64,7 +64,7 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() { ("file.new", json!(null)), // Keys listed by reference, and any key where the description says so. ("file.newPresets.save", json!({"name": "a", "width": 300})), - ("document.export", json!({"format": "png", "anyFormatOption": 1})), + ("document.export", json!({"format": "png", "styling": "style"})), // Not a command: running it says so. ("no.such", json!({"x": 1})), ] { @@ -74,6 +74,109 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() { assert!(check_keys("x", "{a, …as file.new}", &json!({"b": 1})).is_err()); } +/// An export's options: a key no format reads is refused, at the top level and in the PDF objects, +/// under each command that takes them. +#[test] +fn export_options_refuse_a_key_no_format_reads() { + for id in ["document.export", "document.exportSelection", "document.serialize"] { + assert_eq!( + refusal(id, json!({"format": "pdf", "standard": "pdfX4", "noSuchOption": true})), + "unknown parameter `noSuchOption`; no format takes it (see document.formats)" + ); + assert_eq!(refusal(id, json!({"format": "pdf", "standardd": "pdfX4"})), "unknown parameter `standardd`; did you mean `standard`?"); + assert_eq!( + refusal(id, json!({"format": "pdf", "options": {"standard": "pdfX4"}})), + "unknown parameter `options`; no format takes it (see document.formats)" + ); + } + // A command that writes one format refuses another format's option, and says which format. + for (id, why) in [ + ("document.exportPdf", "PDF doesn't take it"), + ("document.pdfSettings", "PDF doesn't take it"), + ("pdf.preset.save", "PDF doesn't take it"), + ("document.exportEps", "EPS doesn't take it"), + ("document.exportDxf", "DXF doesn't take it"), + ] { + for key in ["noSuchOption", "quality"] { + assert_eq!(refusal(id, json!({key: 1})), format!("unknown parameter `{key}`; {why} (see document.formats)")); + } + } + assert_eq!( + refusal("document.exportEps", json!({"flattener": {"balanse": 50}})), + "unknown parameter `flattener.balanse`; did you mean `flattener.balance`?" + ); + assert!(refusal("document.exportDxf", json!({"flattener": {}})).contains("DXF doesn't take it")); + assert!(refusal("document.exportEps", json!({"marks": {}})).contains("EPS doesn't take it")); + for id in ["document.pdfSettings", "pdf.preset.save"] { + assert!(refusal(id, json!({"marks": {"useDocumentBleed": true}})).starts_with("unknown parameter `marks.useDocumentBleed`;"), "{id}"); + } + for (given, message) in [ + (json!({"marks": {"useDocumentBleed": true}}), "unknown parameter `marks.useDocumentBleed`; `marks` takes:"), + (json!({"bleed": {"useDocumen": true}}), "unknown parameter `bleed.useDocumen`; did you mean `bleed.useDocument`?"), + (json!({"output": {"intent": "x"}}), "unknown parameter `output.intent`;"), + (json!({"advanced": {"outlineTxt": true}}), "unknown parameter `advanced.outlineTxt`; did you mean `advanced.outlineText`?"), + (json!({"security": {"openPasswrd": "x"}}), "unknown parameter `security.openPasswrd`; did you mean `security.openPassword`?"), + (json!({"compression": {"color": {"dpi": 300}}}), "unknown parameter `compression.color.dpi`;"), + (json!({"flattener": {"balanse": 50}}), "unknown parameter `flattener.balanse`; did you mean `flattener.balance`?"), + ] { + for id in ["document.export", "document.exportPdf"] { + let got = refusal(id, given.clone()); + assert!(got.starts_with(message), "{id} {given}: {got}"); + } + } +} + +/// The check passes each key `document.formats` lists, each key a command's own description names, +/// and each field of the PDF settings and the flattener options. A key an encoder reads that none +/// of these names is outside this test. +#[test] +fn export_options_pass_every_listed_key() { + use crate::cmd::fileio::{FORMATS, dxf, eps, pdf::OPTIONS}; + for f in FORMATS.iter().filter(|f| f.write) { + for o in f.options { + let params = json!({"format": f.id, o.name: null}); + assert!(check_params("document.export", ¶ms).is_ok(), "{params}"); + } + } + for (id, options) in [("document.exportEps", eps::OPTIONS), ("document.exportDxf", dxf::OPTIONS), ("document.exportPdf", OPTIONS)] { + for o in options { + assert!(check_params(id, &json!({o.name: null})).is_ok(), "{id} {}", o.name); + } + } + for id in [ + "document.export", + "document.exportSelection", + "document.serialize", + "document.exportPdf", + "document.pdfSettings", + "pdf.preset.save", + "document.exportEps", + "document.exportDxf", + ] { + // An export reads these two when it writes over the file a lossy document came from. The + // PDF settings and preset commands take them through document.exportPdf's description. + check_params(id, &json!({"path": "a", "acknowledgeLoss": true})).unwrap_or_else(|e| panic!("{id}: {e}")); + let keys = doc_keys(find_command(id).unwrap().params).unwrap().keys; + let refused: Vec<&str> = keys.into_iter().filter(|key| check_params(id, &json!({*key: null})).is_err()).collect(); + // `document.exportPdf`'s description explains the page boxes as `BleedBox = …` between its + // keys, which reads as two more keys. + let prose = if id == "document.exportPdf" { vec!["BleedBox", "MediaBox"] } else { vec![] }; + assert_eq!(refused, prose, "{id} names and refuses these"); + } + let settings = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap(); + for key in settings.as_object().unwrap().keys() { + assert!(OPTIONS.iter().any(|o| o.name == key), "document.formats doesn't list the PDF option `{key}`"); + } + let mut every = settings; + every["flattener"] = serde_json::to_value(crate::cmd::flatten::FlattenOptions::default()).unwrap(); + every["security"]["openPassword"] = json!("a"); + every["security"]["permissionsPassword"] = json!("b"); + every["preset"] = json!("High Quality Print"); + for id in ["document.export", "document.exportPdf", "document.pdfSettings", "pdf.preset.save"] { + check_params(id, &every).unwrap_or_else(|e| panic!("{id}: {e}")); + } +} + /// `stroke.set` names every key it reads (its own, `item`, and `ids`/`id`/`target`), so a /// misnamed one is refused instead of answering `objects: 1` with nothing changed. #[test] diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index dad65195f..5d947b900 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1055,6 +1055,225 @@ fn a_cancelled_filter_run_changes_nothing() { assert_eq!(s.doc().unwrap().doc, before); } +/// The manifest of a filter `id` named "Cmd Test" of `kind` with `commands` (a JSON list). +fn with_commands(id: &str, kind: &str, commands: &str) -> String { + format!(r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"{kind}","commands":{commands}}}"#) +} + +/// Two commands: Grow, with a parameter, and Tidy, whose `menu` names another menu. +const GROW_AND_TIDY: &str = r#"[{"id":"grow","label":"Grow","params":{"by":{"type":"number","min":0,"max":10,"default":2}}}, + {"id":"tidy","label":"Tidy","menu":"File"}]"#; + +/// A plug-in that adds a triangle when it runs as its command `grow` (`_context.command`) and +/// returns its input unchanged otherwise. +fn grower(id: &str) -> String { + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + tpl::when_given(&with_commands(id, "filter", GROW_AND_TIDY), tpl::Given::Params, r#""command":"grow""#, triangle) +} + +/// The command listing's entry for `id`. +fn listed(s: &Session, id: &str) -> Option { + s.commands().into_iter().find(|c| c.id == id) +} + +fn objects(s: &Session) -> usize { + s.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() +} + +/// A plug-in's commands are commands of their own while it is installed: listed under Object › +/// Plug-ins › its name whatever menu the manifest names, run with their checked parameters and +/// `_context.command` as one undo step named after them, and gone once it is removed or replaced +/// by a module without them. +#[test] +fn plugin_commands_are_registered_run_and_removed() { + let mut s = session(); + let id = "test.engine.cmds"; + let (grow, tidy) = ("plugin.test.engine.cmds.grow", "plugin.test.engine.cmds.tidy"); + let info = install_wat(&mut s, &grower(id)); + assert_eq!(info["commands"][0]["command"], grow); + assert_eq!(info["commands"][1]["label"], "Tidy"); + for (cmd, label) in [(grow, "Grow"), (tidy, "Tidy")] { + let c = listed(&s, cmd).unwrap_or_else(|| panic!("{cmd} is listed")); + assert_eq!((c.label, c.menu.as_slice()), (label, &["Object", "Plug-ins", "Cmd Test"][..]), "{cmd}"); + assert!(c.enabled, "{cmd}"); + } + assert!(crate::cmd::find_command(grow).is_some()); + + run(&mut s, "select.none", json!({})); + let (before, undo) = (objects(&s), s.doc().unwrap().history.undo.len()); + let r = run(&mut s, grow, json!({"by": 3})); + assert_eq!((r["plugin"].as_str(), r["command"].as_str()), (Some(id), Some("grow"))); + assert_eq!(objects(&s), before + 1, "the command added its triangle"); + let st = s.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Grow")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(objects(&s), before); + // Its parameters are checked before it runs. + let e = s.execute(grow, &json!({"by": "lots"})).unwrap_err(); + assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == grow), "{e}"); + assert_eq!(objects(&s), before); + // Another command of the same plug-in is told apart by its id. + run(&mut s, tidy, json!({})); + assert_eq!(objects(&s), before, "tidy returns its input unchanged"); + + // Replaced by a module without commands, its commands are gone; installed again, back. + install_wat(&mut s, &tpl::module(&tpl::manifest(id, "filter", "{}"), tpl::ECHO, "", 1)); + assert!(listed(&s, grow).is_none() && crate::cmd::find_command(grow).is_none()); + install_wat(&mut s, &grower(id)); + assert!(listed(&s, grow).is_some()); + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(listed(&s, grow).is_none() && listed(&s, tidy).is_none()); + assert!(s.execute(grow, &json!({})).is_err(), "removed, it no longer runs"); +} + +/// A command run is journaled like any other, so an action records it with its parameters and +/// plays it back. +#[test] +fn actions_record_and_play_plugin_commands() { + let mut s = session(); + let id = "test.engine.cmds.actions"; + let grow = crate::cmd::plugin::command_id(id, "grow"); + install_wat(&mut s, &grower(id)); + run(&mut s, "select.none", json!({})); + let before = objects(&s); + let start = s.journal.len(); + run(&mut s, &grow, json!({"by": 4})); + let steps = s.journal_for_action(start); + assert_eq!(steps, [(grow.clone(), json!({"by": 4}))]); + run(&mut s, "edit.undo", json!({})); + let commands: Vec = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect(); + // The triangle a run adds is selected, and the next run would be given it: deselect between. + let none = json!({"command": "select.none", "params": {}}); + run(&mut s, "command.batch", json!({"label": "Grow Twice", "commands": [commands[0].clone(), none, commands[0].clone()]})); + assert_eq!(objects(&s), before + 2, "both steps ran"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A command that traps leaves the document and its history as they were, and the error names +/// the plug-in and the command. +#[test] +fn a_failing_plugin_command_changes_nothing_and_names_itself() { + let mut s = session(); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "select.all", json!({})); + let id = "test.engine.cmds.trap"; + install_wat(&mut s, &tpl::module(&with_commands(id, "filter", r#"[{"id":"boom","label":"Boom"}]"#), "unreachable", "", 1)); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute(&crate::cmd::plugin::command_id(id, "boom"), &json!({})).unwrap_err().to_string(); + assert!(e.contains("“Cmd Test” › Boom: ") && e.contains("trapped"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A command that runs out of its instruction budget leaves the document and its history as they +/// were, and the error names the plug-in and the command. +#[test] +fn a_plugin_command_out_of_budget_changes_nothing_and_names_itself() { + let mut s = session(); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "select.all", json!({})); + let id = "test.engine.cmds.spin"; + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + // More steps than a light budget pays for (as the plug-in crate's budget test counts them). + install_wat(&mut s, &tpl::spinning(&with_commands(id, "filter", r#"[{"id":"spin","label":"Spin"}]"#), 100_000_000, triangle)); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute(&crate::cmd::plugin::command_id(id, "spin"), &json!({})).unwrap_err().to_string(); + assert!(e.contains("“Cmd Test” › Spin: ") && e.contains("instruction budget"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// An `ids` that is not a list, or that holds an entry that is not an object id, is an error naming +/// `ids` for a plug-in command and for `plugin.run`, and is never read as the selection; left out +/// or null, `ids` is the selection. +#[test] +fn a_malformed_ids_is_refused_not_read_as_the_selection() { + let mut s = session(); + let id = "test.engine.cmds.ids"; + let grow = crate::cmd::plugin::command_id(id, "grow"); + install_wat(&mut s, &grower(id)); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "select.all", json!({})); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let cases = [ + (grow.as_str(), json!(5)), + (grow.as_str(), json!([1, "x"])), + (grow.as_str(), json!([-1])), + ("plugin.run", json!("all")), + ("plugin.run", json!([1.5])), + ]; + for (cmd, ids) in cases { + let mut p = json!({"ids": ids}); + if cmd == "plugin.run" { + p["id"] = json!(id); + } + let e = s.execute(cmd, &p).unwrap_err(); + assert!(matches!(&e, EngineError::BadParams { cmd: c, .. } if c == cmd), "{cmd} {ids}: {e}"); + let said = e.to_string(); + assert!( + said.contains("ids must be an array of non-negative integer object ids") || said.contains("not a non-negative integer object id"), + "{cmd} {ids}: {e}" + ); + assert_eq!(s.doc().unwrap().doc, before, "{cmd} {ids}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {ids}"); + } + // Left out or null, the command runs on the selection. + for p in [json!({}), json!({"ids": null})] { + let r = run(&mut s, &grow, p.clone()); + assert_eq!(r["command"], "grow", "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().doc, before, "{p}"); + } + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A live effect's commands are listed under Effect › Plug-ins and run under its permissions (with +/// commands it gets `doc.write` unless its list leaves it out), and labels show bidirectional +/// characters escaped. A malformed +/// `commands` entry is refused at install, and the error names it. +#[test] +fn effect_commands_labels_and_malformed_entries() { + let mut s = session(); + let id = "test.engine.cmds.effect"; + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + install_wat(&mut s, &tpl::returning(&with_commands(id, "effect", "[{\"id\":\"flip\",\"label\":\"Flip\u{202E}txt\"}]"), triangle)); + let c = listed(&s, &crate::cmd::plugin::command_id(id, "flip")).expect("listed"); + assert_eq!((c.label, c.menu.as_slice()), ("Flip\\u{202E}txt", &["Effect", "Plug-ins", "Cmd Test"][..])); + run(&mut s, "select.none", json!({})); + // Without `permissions`, a live effect with commands may change the document: its command adds + // the triangle, as one undo step. + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let out = run(&mut s, &crate::cmd::plugin::command_id(id, "flip"), json!({})); + assert_eq!(out["ids"].as_array().map(Vec::len), Some(1), "{out}"); + assert_ne!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + run(&mut s, "plugin.remove", json!({"id": id})); + // One that lists its permissions without `doc.write` is refused the change, and the document + // stays as it was. + let listed = format!( + r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"effect","permissions":["doc.read","effect.render"],"commands":[{{"id":"flip","label":"Flip"}}]}}"# + ); + let wasm = wat::parse_str(tpl::returning(&listed, triangle)).unwrap(); + let hash = vectorcraft_plugins::permissions::sha256_hex(&wasm); + let grant = json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read", "effect.render"], "hash": hash}); + run(&mut s, "plugin.install", grant); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute(&crate::cmd::plugin::command_id(id, "flip"), &json!({})).unwrap_err().to_string(); + assert!(e.contains("wasn't granted `doc.write`"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + run(&mut s, "plugin.remove", json!({"id": id})); + + let bad = id.replace("effect", "bad"); + let wasm = wat::parse_str(tpl::module(&with_commands(&bad, "filter", r#"[{"id":"ok","label":"A"},{"id":"Bad","label":"B"}]"#), tpl::ECHO, "", 1)); + let e = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm.unwrap())})).unwrap_err().to_string(); + assert!(e.contains("command `Bad`"), "{e}"); + assert!(vectorcraft_plugins::registry::get(&bad).is_none()); +} + /// A filter `id` of API level 2 that lists `settings` and `cache`, whose `vc_run` runs `first` /// (instructions that trap unless each storage call returns what it should), with the keys "zoom" /// at 0x2000 and "tile" at 0x2004 and the value "hello" at 0x2100. @@ -1086,10 +1305,11 @@ fn replace_storing(s: &mut Session, id: &str, first: &str) -> Value { /// A plug-in's settings and cache are kept in the plug-in store under its id: the next session /// has them, replacing the plug-in keeps them, another plug-in can't read them, `plugin.clearCache` -/// empties the cache alone and `plugin.remove` deletes both. The caps and the bytes each call is -/// charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`], -/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test, since -/// clearing every plug-in's cache would empty theirs. +/// with its id empties its cache alone and `plugin.remove` deletes both. The caps and the bytes +/// each call is charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`], +/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test. +/// `plugin.clearCache` without an id empties the cache of every plug-in installed in the process, +/// so it is tested in a binary of its own (`tests/plugin_clear_cache.rs`). #[test] fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() { let folder = crate::tests_links::Folder::new("plugin-storage"); @@ -1116,16 +1336,13 @@ fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() { install_storing(&mut next, other, READS); assert!(next.execute("plugin.run", &json!({"id": other, "ids": []})).is_err()); - // Clearing the cache leaves the settings. - assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3})); - assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0)); - run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + // Clearing the cache leaves the settings, and the other plug-in's cache. replace_storing(&mut next, other, WRITES); run(&mut next, "plugin.run", json!({"id": other, "ids": []})); - let all = run(&mut next, "plugin.clearCache", json!({})); - let cleared = all["cleared"].as_array().unwrap(); - assert!(cleared.contains(&json!(id)) && cleared.contains(&json!(other)) && all["bytesFreed"] == json!(3), "{all}"); - assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(0)); + assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3})); + assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0)); + assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(3)); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); assert!(next.execute("plugin.clearCache", &json!({"id": "test.engine.storing.missing"})).is_err()); run(&mut next, "plugin.remove", json!({"id": id})); diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index c9f5cef44..fcb4581c2 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -461,6 +461,25 @@ fn svg_export_writes_the_filter_when_it_is_the_last_effect() { assert!(!out.contains(""#); + let xml = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [id.0], "params": {"xml": xml}})); + assert_eq!(&render(&s).pixel(100, 100)[..3], &[255, 0, 0]); + let out = svg(&mut s); + assert!(out.contains(" 240 && px[1] < 15 && px[2] < 15, "{px:?}"); +} + /// A filter SVG 1.1 can't hold (a blend mode SVG 1.1 lacks) is exported as an image, as the canvas /// draws it; an SVG 1.1 one stays a filter. #[test] diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index a895655b9..885ce09e1 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1276,6 +1276,45 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() { assert!(s.execute("text.startList", &json!({"at": 3})).is_err(), "id required"); } +/// A list typed with "1. " starts at level 1. Tab at the start of the item Return makes moves it +/// to level 2, where it is numbered from 1, Return continues the list at that level, and +/// Shift+Tab moves an item back up, where it goes on with level 1's count. "1. " typed at the +/// start of an item at level 2 stays text. +#[test] +fn a_typed_list_takes_its_levels_from_tab_and_shift_tab() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let v = ViewInfo::default(); + let mut s = session(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + let typed = |s: &mut Session, text: &str| { + for c in text.chars() { + s.tool_text(&c.to_string(), v).unwrap(); + } + }; + let shift = Mods { shift: true, ..Mods::default() }; + typed(&mut s, "1. Apples"); + key(&mut s, ToolKey::Enter, Mods::default()); + key(&mut s, ToolKey::Tab, Mods::default()); + typed(&mut s, "Pears"); + key(&mut s, ToolKey::Enter, Mods::default()); + typed(&mut s, "Plums"); + key(&mut s, ToolKey::Enter, Mods::default()); + key(&mut s, ToolKey::Tab, shift); + typed(&mut s, "Figs"); + let arabic = ParaList::numbered(NumberStyle::Arabic); + let t = obj(&s, id); + assert_eq!(t.plain_text(), "Apples\nPears\nPlums\nFigs"); + assert_eq!((0..4).map(|i| t.para_at(i).list).collect::>(), [arabic, arabic.at_level(2), arabic.at_level(2), arabic].map(Some)); + assert_eq!(list_markers(&s, id), ["1.", "1.", "2.", "2."]); + key(&mut s, ToolKey::Enter, Mods::default()); + key(&mut s, ToolKey::Tab, Mods::default()); + typed(&mut s, "1. "); + let t = obj(&s, id); + assert_eq!((t.plain_text().as_str(), t.para_at(4).list), ("Apples\nPears\nPlums\nFigs\n1. ", Some(arabic.at_level(2)))); +} + /// A byte offset given as a negative or fractional number is an error, not the end of the text /// (`at`) or its start or end (`start`, `end`), and the text is left as it was. Null keeps the default. #[test] @@ -2318,6 +2357,18 @@ fn convert_area_point_keeps_text_in_place_for_every_alignment() { } } +#[test] +fn convert_area_to_point_keeps_a_deeper_list_item_in_place() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Item", "size": 18, "area": {"width": 150, "height": 200}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet", "listLevel": 3})).unwrap(); + let before = first_glyph_doc_pos(&obj(&s, id)); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let after = first_glyph_doc_pos(&obj(&s, id)); + assert!((after - before).hypot() < 0.01, "{before:?} → {after:?}"); +} + #[test] fn commands_are_listed_with_params() { let s = session(); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d46688bdf..6d19d820f 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4034,6 +4034,12 @@ proptest! { let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + // A symbol library: the same symbols and list, no layers. + let library = mutate_text(&symbol_library_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&library)].concat(); + survive("mutated .ai symbol library", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = vectorcraft_testkit::ai::eps(library.as_bytes(), vectorcraft_testkit::ai::page_ps()); + survive("mutated symbol library in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; } /// A `.ai` whose art styles, as the writer writes them, are damaged: the styles, the style @@ -4052,13 +4058,13 @@ proptest! { const STYLES_FUZZ_CHARS: usize = 64_000; /// The editing data VectorCraft writes for a document with a drop shadow on a path, a gradient fill -/// and a width profile on others, a stroked even-odd path with an opacity mask and Pathfinder › Add -/// on a group. +/// and a width profile on others, a stroked even-odd path with an opacity mask, Pathfinder › Add on +/// a group and a graphic style of its own with effects on one of its fills. fn styles_text() -> &'static str { static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); TEXT.get_or_init(|| { use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; - use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile}; + use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, GraphicStyle, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile}; use vectorcraft_geom::{FillRule, Rect, shapes}; let mut doc = Document::new(200.0, 100.0); let layer = doc.layers[0].id; @@ -4101,6 +4107,10 @@ fn styles_text() -> &'static str { let mut merged = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(a), std::sync::Arc::new(b)]); merged.appearance = Appearance { effects: vec![fx("pathfinder.add")], ..Appearance::default() }.into(); doc.insert(Some(layer), usize::MAX, merged).unwrap(); + doc.graphic_styles.push(GraphicStyle::new("Shaded", ap)); + let chained = FillLayer { opacity: 0.5, effects: vec![fx("distort.twist")], ..FillLayer::new(Paint::solid(Color::WHITE)) }; + let chained = Appearance { items: vec![AppearanceItem::Fill(chained)], ..Appearance::default() }; + doc.graphic_styles.push(GraphicStyle::new("Chained", chained)); let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap(); String::from_utf8(out.program).unwrap() }) @@ -4112,14 +4122,46 @@ fn styles_text() -> &'static str { fn the_written_styles_fuzz_data_opens_live() { let text = styles_text(); assert!(text.chars().count() < STYLES_FUZZ_CHARS, "{}", text.chars().count()); - for mark in ["%AI9_BeginArtStyles", "%AI5_BeginLayer", "\n6 () XW", "\n1 (Anon", "(BSLeftWidthsArray)", "0 /Bool (UseEvenOdd)", "Bb"] { - assert!(text.contains(mark), "{mark}"); + for mark in [ + "%AI9_BeginArtStyles", + "%AI9_BeginArtStyleList", + "%AI5_BeginLayer", + "\n6 () XW", + "\n1 (Anon", + "(BSLeftWidthsArray)", + "0 /Bool (UseEvenOdd)", + "(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter", + "Bb", + ] { + assert!(text.replace("\r\n", "\n").contains(mark), "{mark}"); } let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat(); let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); assert!(layered, "{warnings:?}"); let art = d.layers[0].children().unwrap(); assert_eq!(art.iter().map(|n| n.appearance.effects.len()).collect::>(), [1, 1, 1, 1, 1]); + assert!(d.graphic_style("Shaded").is_some() && d.graphic_style("Chained").is_some()); +} + +/// [`symbols_text`] without its layer: a symbol library. +fn symbol_library_text() -> String { + let full = symbols_text(); + match (full.find("%AI5_BeginLayer"), full.find("%AI5_EndLayer--\n")) { + (Some(a), Some(b)) if a < b => format!("{}{}", &full[..a], &full[b + "%AI5_EndLayer--\n".len()..]), + _ => full, + } +} + +/// The library [`ai_symbols_never_panic`] damages, as it is, opens with its symbols. +#[test] +fn the_symbol_library_fuzz_data_opens_with_its_symbols() { + let text = symbol_library_text(); + assert!(!text.contains("%AI5_BeginLayer")); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0); + assert!(!layered, "{warnings:?}"); + let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["S2", "S1"], "{warnings:?}"); } /// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the @@ -4212,6 +4254,9 @@ fn ai_symbol_limits_never_panic() { ai::layer("L", &inst("(S1)", "1 0 0 -1 20 50")) ), format!("{}{}", def("S1", &inst("(S2)", "1 0 0 -1 0 0")), def("S2", &inst("(S1)", "1 0 0 -1 0 0"))), + // Symbol libraries without layers: definitions nested too deep and a long chain. + nested, + chain, ]; for body in cases { let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%EndComments", 1); diff --git a/crates/engine/tests/it/param_keys.rs b/crates/engine/tests/it/param_keys.rs index 25814357e..adf75178a 100644 --- a/crates/engine/tests/it/param_keys.rs +++ b/crates/engine/tests/it/param_keys.rs @@ -109,6 +109,22 @@ fn a_journal_with_a_key_no_longer_read_still_replays() { assert!(s.execute("object.move", &json!({"dx": 5, "dyy": 5})).is_err()); } +/// `Session::replay` still runs an export with an option that is no longer read, and a preset +/// file that holds one still loads and exports. `Session::execute` refuses the same options. +#[test] +fn an_export_step_or_preset_with_a_key_no_longer_read_still_runs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let old = json!({"format": "pdf", "noSuchOption": true, "marks": {"useDocumentBleed": true}}); + assert!(check_params("document.export", &old).is_err()); + assert!(s.execute("document.export", &old).is_err()); + s.replay("document.export", &old).unwrap(); + let file = json!({"format": "vcpdfpresets", "presets": [{"name": "Old", "settings": {"standard": "none", "noLongerRead": 1, "marks": {"retired": true}}}]}); + s.execute("pdf.preset.import", &json!({"data": file.to_string()})).unwrap(); + check_params("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap(); + s.execute("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap(); +} + /// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't /// take, or a value of another type, replays and opens; the effect reads what it knows, clamped. #[test] diff --git a/crates/engine/tests/plugin_clear_cache.rs b/crates/engine/tests/plugin_clear_cache.rs new file mode 100644 index 000000000..74ba69d64 --- /dev/null +++ b/crates/engine/tests/plugin_clear_cache.rs @@ -0,0 +1,65 @@ +//! `plugin.clearCache` without an id empties the cache of every installed plug-in and leaves their +//! settings. +//! +//! A test binary of its own: installed plug-ins are process-wide, so clearing every plug-in's cache +//! in the engine's other tests would empty the caches those tests check. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; + +/// Sets "zoom" to "hello" and writes "hel" to the cache entry "tile". +const WRITES: &str = "(if (call $vc_settings_set (i32.const 0x2000) (i32.const 4) (i32.const 0x2100) (i32.const 5)) (then unreachable)) + (if (call $vc_cache_write (i32.const 0x2004) (i32.const 4) (i32.const 0x2100) (i32.const 3)) (then unreachable))"; + +/// Traps unless "zoom" is "hello". +const READS: &str = + "(if (i32.ne (call $vc_settings_get (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 16)) (i32.const 5)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 0x3000)) (i32.load (i32.const 0x2100))) (then unreachable))"; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// Installs a filter `id` of API level 2 that lists `settings` and `cache` and whose `vc_run` runs +/// `first`, in place of any module installed with its id (`replace: true`). +fn install_storing(s: &mut Session, id: &str, first: &str) { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); + let data = format!(r#"{} (data (i32.const 0x2000) "zoomtile") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS); + let wasm = wat::parse_str(tpl::with_imports(&manifest, &data, first, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&wasm); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); +} + +#[test] +fn clearing_every_cache_empties_each_plugins_cache_and_keeps_its_settings() { + let folder = std::env::temp_dir().join(format!("vc-plugin-clear-cache-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&folder); + std::fs::create_dir_all(&folder).unwrap(); + let ids = ["test.clearcache.a", "test.clearcache.b"]; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); + s.attach_plugin_store(folder.clone()); + for id in ids { + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + } + + assert_eq!(run(&mut s, "plugin.clearCache", json!({})), json!({"cleared": ids, "bytesFreed": 6})); + for id in ids { + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0)); + // The settings stay: a module that reads them runs. + install_storing(&mut s, id, READS); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + } + // Nothing is left to clear. + assert_eq!(run(&mut s, "plugin.clearCache", json!({})), json!({"cleared": ids, "bytesFreed": 0})); + + for id in ids { + run(&mut s, "plugin.remove", json!({"id": id})); + } + let _ = std::fs::remove_dir_all(&folder); +} diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index a7e45f690..94b702ca8 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -599,7 +599,7 @@ proptest! { let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; if let Ok(man) = &parsed { - prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind)), "{}", text); + prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind, !man.commands.is_empty())), "{}", text); let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME); prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown); prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); @@ -617,6 +617,80 @@ proptest! { vectorcraft_plugins::registry::revoke("fuzz.permissions"); } + /// Manifests with hostile `commands` (junk of every type, bad and repeated ids, labels with + /// control and bidirectional override characters, a `menu` naming another menu, junk + /// parameters) parse to an error or to commands whose menu labels show no hidden character and + /// that sit under Plug-ins alone; installing one and running each of its commands ends as an + /// error or an edit, never a panic, and the document then renders and exports. + #[test] + fn hostile_manifest_commands_never_crash( + kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + entries in prop::collection::vec( + prop_oneof![ + 3 => ( + prop_oneof![Just(json!("grow")), Just(json!("a_1")), Just(json!("Grow")), Just(json!("")), Just(json!("a.b")), arb_junk()], + prop_oneof![ + 3 => "[a-zA-Z\u{202E}\u{2066}\u{200F} ]{0,70}".prop_map(Value::from), + 1 => "[a-z\u{0007}\n]{1,8}".prop_map(Value::from), + 1 => arb_junk(), + ], + prop_oneof![Just(Value::Null), Just(json!("File")), arb_junk()], + prop_oneof![Just(Value::Null), Just(json!({"by": {"type": "number", "min": 0, "max": 10, "default": 2}})), arb_junk()], + ) + .prop_map(|(id, label, menu, params)| { + let mut c = json!({"id": id, "label": label}); + if !menu.is_null() { + c["menu"] = menu; + } + if !params.is_null() { + c["params"] = params; + } + c + }), + 1 => arb_junk(), + ], + 0..5, + ), + junk in prop::option::weighted(0.1, arb_junk()), + given in prop_oneof![Just(json!({})), Just(json!({"by": 3})), arb_junk()], + ) { + let mut m = json!({"id": "fuzz.commands", "name": "Fuzz \u{202E}Commands", "kind": kind}); + if kind == "exporter" { + m["extensions"] = json!(["fz"]); + } + m["commands"] = junk.unwrap_or(json!(entries)); + let text = m.to_string(); + let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); + let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; + if let Ok(man) = &parsed { + for c in &man.commands { + let shown = c.shown_label(); + prop_assert!(!shown.chars().any(|ch| ch.is_control() || matches!(ch, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown); + prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + } + } + let mut s = rich_session(); + s.execute("select.all", &json!({})).unwrap(); + let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap(); + let r = catch_quiet(|| { + let mut ran = vec![]; + if install(&mut s, &module).is_ok() { + for c in s.commands().into_iter().filter(|c| c.id.starts_with("plugin.fuzz.commands.")) { + ran.push((c.id, c.menu.clone())); + let _ = s.execute(c.id, &given); + } + } + ran + }); + let ran = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?; + prop_assert!(ran.is_empty() || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text); + for (id, menu) in &ran { + prop_assert!(menu.len() == 3 && menu[1] == "Plug-ins" && matches!(menu[0], "Object" | "Effect"), "{id}: {menu:?}"); + } + renders(&s, "after hostile commands")?; + vectorcraft_plugins::registry::remove("fuzz.commands"); + } + /// A plug-in of API level 2 that calls the host imports with hostile arguments (any progress /// counts, any log level, pointers and lengths in and out of its memory, text that isn't /// UTF-8) runs to an error or an output, never a panic, and its report stays within the caps. @@ -681,7 +755,17 @@ proptest! { use vectorcraft_plugins::host::STORAGE_BUDGET; use vectorcraft_plugins::storage::Meter; const FUNCS: [&str; 6] = ["vc_settings_get", "vc_settings_set", "vc_cache_read", "vc_cache_write", "vc_settings_delete", "vc_cache_clear"]; - let folder = std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id())); + // Each case starts with an empty store, and the store is deleted when the case ends, also + // when it fails. + struct Store(std::path::PathBuf); + impl Drop for Store { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let store = Store(std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&store.0); + let folder = store.0.clone(); let mut s = Session::new(); s.attach_plugin_store(folder.clone()); let storing = |id: &str| format!(r#"{{"id":"{id}","name":"F","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); @@ -704,8 +788,11 @@ proptest! { let text: String = key.iter().map(|b| format!("\\{b:02x}")).collect(); let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::STORAGE_IMPORTS); let wasm = wat::parse_str(tpl::with_imports(&storing("fuzz.storage"), &imports, &format!("(local $r i32) {body}"), r#"{"objects":[]}"#)).unwrap(); + // A failed case leaves its module installed, and the cases that shrink it install others + // under the same id. let r = catch_quiet(|| { - s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant})).map_err(|e| e.to_string())?; + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true})) + .map_err(|e| e.to_string())?; let p = vectorcraft_plugins::registry::get("fuzz.storage").ok_or("not installed")?; p.run(b"{\"objects\":[]}", b"{}").map_err(|e| e.to_string()) }); diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 9ae80c3c0..3ca8a66b5 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -122,16 +122,17 @@ //! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and //! a gradient's take the art to the canvas's space turned y up about art space's origin there. //! - Art styles, as the reader reads them (`style.rs`, `read/ops.rs`): a path, compound path or -//! group with live effects is written as its drawn look (`u`, its vector art as its geometry -//! effects leave it, without shadows, glows or feathers, `U`, its transparency, `0 0 Xd`, -//! `6 () XW`), then the object itself on `%_` lines (its points painted +//! group with live effects or linked to a graphic style is written as its drawn look (`u`, its +//! vector art as its geometry effects leave it, without shadows, glows or feathers, `U`, its +//! transparency, `0 0 Xd`, `6 () XW`), then the object itself on `%_` lines (its points painted //! with nothing, or with its gradient fill; a group with its members) with its name and opacity //! mask, then `1 (style) XW`. The styles are written in a non-printing block after the symbols //! (`%AI9_BeginArtStyles`, each `/KnownStyle :` with its name and a filter tree: a chain of the -//! stack of fills and strokes with the contents among them, the effects and the blend filter). A -//! fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that fills by -//! the even-odd rule is written as its drawn look. A style is written only when the reader reads it -//! back as the object's appearance. +//! stack of fills and strokes with the contents among them, the effects and the blend filter), the +//! Graphic Styles panel's first; the panel's order follows the palette (`%AI9_BeginArtStyleList`). +//! A fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that +//! fills by the even-odd rule is written as its drawn look. A style is written only when the +//! reader reads it back as the object's appearance (or the graphic style's). //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the @@ -166,9 +167,10 @@ //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on //! layers and opacity masks nested too deep, and, on objects written without an art style, live -//! effects other than geometry effects left out; graphic styles, links to them and every art style -//! on a large canvas are left out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all -//! (the most the reader reads), counted as the reader counts them, is left out. +//! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to +//! graphic styles on objects not written with them, and every art style on a large canvas, are left +//! out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), +//! counted as the reader counts them, is left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). @@ -255,7 +257,8 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re // The objects that may keep their effects in an art style, as they are before baking (not on // a large canvas, whose scale the styles' strokes and effects would need). let styled = writer::styled_objects(doc); - let graphic_styles = writer::has_graphic_styles(doc); + let own_styles = doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles; + let links = writer::style_links(doc); // Live geometry effects as their result, as the PDF part draws them. let baked = baked(doc); let doc = baked.as_ref().unwrap_or(doc); @@ -290,15 +293,16 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re w.reserve_swatch_gradients(); if scale == 1.0 { w.styled = styled; - } else if !styled.is_empty() { + w.panel_styles(); + } else if !styled.is_empty() || own_styles || links > 0 { w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string()); } - if graphic_styles { - w.warnings.push(writer::GRAPHIC_STYLES.to_string()); - } for layer in &doc.layers { w.layer(layer); } + if scale == 1.0 { + w.lost_links(links); + } // The symbols no instance uses too; their art adds definitions. w.symbols(); // The palette first: its gradient and pattern swatches add definitions. @@ -322,6 +326,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re w.symbol_definitions(&mut s); w.art_styles(&mut s); s.push_str(&palette); + w.art_style_list(&mut s); w.symbol_list(&mut s); artboards(&mut s, doc, to_art, origin, &library); if !w.texts.is_empty() { diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 3895c4f45..85d8a03ea 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4887,6 +4887,48 @@ fn symbols_painting_patterns_through_brush_art_are_placed_as_art() { assert_eq!(instances(&back.layers[0]), 0, "the instance is its art"); } +/// A symbol first defined from the drawn look of a group with a style, through a member's scatter +/// brush, still warns that its art's own effects are left out, and so does a later instance of it. +#[test] +fn a_symbol_defined_inside_a_drawn_look_warns_of_its_own_effects() { + let mut doc = Document::new(200.0, 100.0); + let mut shadowed = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK); + shadowed.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }.into(); + doc.symbols.push(Symbol { dynamic: false, name: "Shadowed".into(), art: Arc::new(shadowed) }); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK); + let placed = instance(&mut doc, "Shadowed", Affine::translate((8.0, 0.0))); + let art = group(&mut doc, vec![dot, placed], false); + let brush = vectorcraft_brush::Brush { + name: "Scattered".into(), + kind: vectorcraft_brush::BrushKind::Scatter(vectorcraft_brush::Scatter { + art, + size: (100.0, 100.0), + spacing: (100.0, 100.0), + scatter: (0.0, 0.0), + rotation: (0.0, 0.0), + colorization: vectorcraft_brush::Colorization::None, + ..Default::default() + }), + }; + vectorcraft_brush::store(&mut doc, &[brush]); + let mut line = + Node::path(doc.alloc_id(), shapes::line((10.0, 50.0).into(), (90.0, 50.0).into()), Appearance::basic(Paint::None, solid(Color::BLACK), 1.0)); + line.appearance.stroke_mut().unwrap().brush = Some("Scattered".into()); + let edge = square(&mut doc, Rect::new(10.0, 60.0, 30.0, 80.0), LEAF); + let mut styled = group(&mut doc, vec![line, edge], false); + styled.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, named(styled, "Styled")); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(p.contains("%AI14_BeginSymbol") && p.contains("1 (Anon 1) XW"), "{p}"); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + + let later = instance(&mut doc, "Shadowed", Affine::translate((150.0, 20.0))); + put(&mut doc, later); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); +} + /// A symbol whose stroke is written as its outline (an outside stroke): a scaled instance, a scaled /// instance of a symbol holding it and a turned and mirrored instance are written as their art, /// which keeps the stroke the canvas draws; a moved instance stays an instance. @@ -5560,6 +5602,33 @@ fn styles_document() -> Document { let mut merged = group(&mut doc, vec![a, b], false); merged.appearance = Appearance { effects: vec![fx("pathfinder.add", serde_json::json!({}))], ..Appearance::default() }.into(); add(&mut doc, named(merged, "Merged")); + // A graphic style of its own, and an object linked to one. + let mut fill_top = FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9))); + fill_top.opacity = 0.5; + fill_top.blend = BlendMode::Screen; + fill_top.effects = vec![fx("distort.twist", serde_json::json!({"angle": 30.0}))]; + let style_ap = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.9, 0.9, 0.1)))), + AppearanceItem::Fill(fill_top), + AppearanceItem::Stroke(StrokeLayer::new(solid(Color::BLACK), 3.0)), + ], + effects: vec![fx("warp.arc", serde_json::json!({"bend": 30.0})), shadow], + // The contents over every fill and stroke. + contents_index: Some(3), + }; + let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", style_ap); + layered.id = 9; + layered.opacity = 0.8; + doc.graphic_styles.push(layered); + let sunshine = doc.graphic_style("Sunshine").unwrap().clone(); + let mut linked_obj = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(170.0, 130.0, 220.0, 170.0)), + sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(), + ); + linked_obj.extras_mut().graphic_style = Some(sunshine.id); + add(&mut doc, named(linked_obj, "Sunny")); doc } @@ -5683,12 +5752,24 @@ fn near_lines(got: &str, want: &str) { } #[test] -fn objects_with_live_effects_are_read_back() { +fn objects_with_live_effects_and_graphic_styles_are_read_back() { let doc = styles_document(); let out = export_ai_editing(&doc, &opts()).unwrap(); - assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::PANEL_STYLES), "{:?}", out.warnings); let back = read_back(&doc); same_styles(&back, &doc); + // The panel's styles, in its order, with their appearance and transparency. + let names = |d: &Document| d.graphic_styles.iter().map(|g| g.name.clone()).collect::>(); + assert_eq!(names(&back), names(&doc)); + for (g, w) in back.graphic_styles.iter().zip(doc.graphic_styles.iter()) { + assert!(writer::same_appearance(&g.appearance, &w.appearance), "{}:\n got {:?}\n want {:?}", w.name, g.appearance, w.appearance); + assert_eq!( + (r3(f64::from(g.opacity)), g.blend, g.isolate, g.knockout), + (r3(f64::from(w.opacity)), w.blend, w.isolate, w.knockout), + "{}", + w.name + ); + } // A group's members come back as they were. let members = |d: &Document, name: &str| { let mut out = vec![]; @@ -5700,13 +5781,39 @@ fn objects_with_live_effects_are_read_back() { same_lines(&members(&back, "Merged"), &members(&doc, "Merged")); } +/// In a document with only a new document's graphic styles, an object linked to one of them keeps +/// its link: the panel's styles are written. +#[test] +fn a_link_to_a_built_in_graphic_style_is_read_back() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let sunshine = doc.graphic_style("Sunshine").unwrap().clone(); + let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(); + // Without a link, a new document's styles are not written. + let mut plain = doc.clone(); + let unlinked = Node::path(plain.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap.clone()); + plain.insert(Some(layer), usize::MAX, unlinked).unwrap(); + assert!(!program(&plain).contains("%AI9_BeginArtStyleList")); + let mut sunny = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap); + sunny.extras_mut().graphic_style = Some(sunshine.id); + doc.insert(Some(layer), usize::MAX, named(sunny, "Sunny")).unwrap(); + assert_eq!(doc.graphic_styles, Document::new(1.0, 1.0).graphic_styles); + let back = read_back(&doc); + same_styles(&back, &doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Sunshine")); +} + #[test] fn art_styles_are_written_in_their_forms() { let p = program_shown(&styles_document()); - // The styles in a non-printing block after the symbols' and before the palette. - let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Anon 1) /Name ,\n/ActiveStyle :\n").unwrap(); + // The styles in a non-printing block after the symbols' and before the palette; the panel's in + // its order first, then the objects' own. + let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Default Graphic Style) /Name ,\n/ActiveStyle :\n").unwrap(); assert!(styles < p.find("%AI5_BeginPalette").unwrap(), "{p}"); - assert!(!p.contains("%AI9_BeginArtStyleList"), "{p}"); + let list = "%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n(Default Graphic Style)\n(Black Outline)\n(Heavy Ink)\n(Sunshine)\n(Layered)\n%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n"; + let at = p.find(list).unwrap(); + assert!(p.find("%AI5_EndPalette").unwrap() < at && at < p.find("%AI9_BeginDocumentData").unwrap(), "{p}"); // A drop shadow on the object, after its stack and before its transparency. assert!( p.contains( @@ -5720,6 +5827,8 @@ fn art_styles_are_written_in_their_forms() { assert!(p.contains("U\n0 1 0 2 0 Xy\n0 0 Xd\n6 () XW\n%_0 XR\n%_0 1 0 0 0 Xy\n%_10 -10 m\n"), "{p}"); assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Shadowed) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon 1) XW\n"), "{p}"); assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Offset) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); + // The object linked to a graphic style names it. + assert!(p.contains("%_/XMLUID : (Sunny) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Sunshine) XW\n"), "{p}"); // A group's members on `%_` lines, then its name and style. assert!(p.contains("6 () XW\n%_u\n"), "{p}"); assert!(p.contains("%_U\n%_/ArtDictionary :\n%_/XMLUID : (Merged) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); @@ -5739,8 +5848,9 @@ fn art_styles_are_written_in_their_forms() { } /// Objects whose appearance the reader wouldn't read with their style are written as their -/// drawn look, with the warnings they were written with before; and a document without effects has -/// no art styles. +/// drawn look, with the warnings they were written with before; graphic styles it wouldn't read +/// are left out with a warning; and a document without effects or styles of its own has no art +/// styles. #[test] fn appearances_the_reader_wouldnt_read_are_their_drawn_look() { let mut doc = Document::new(200.0, 100.0); @@ -5768,10 +5878,41 @@ fn appearances_the_reader_wouldnt_read_are_their_drawn_look() { assert!(!p.contains("%AI9_BeginArtStyles") && !p.lines().any(|l| l.starts_with("1 (")), "{p}"); let back = read_back(&doc); assert!(back.layers[0].children().unwrap().iter().all(|n| n.appearance.effects.is_empty())); + // A graphic style with a gradient: left out, the others listed. + let ramp = Gradient { stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)], ..Gradient::default() }; + let graded = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(ramp))), Paint::None, 0.0); + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", graded)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let p = String::from_utf8(out.program).unwrap().replace("\r\n", "\n"); + assert!(p.contains("(Sunshine)\n%AI9_EndArtStyleList") && !p.contains("(Graded)"), "{p}"); // Nothing styled: no art styles. assert!(!program(&sample()).contains("ArtStyle")); } +/// A graphic style's opacity is read back within the written number's precision: the style and an +/// object's link to it stay. +#[test] +fn a_graphic_styles_opacity_is_compared_at_the_written_precision() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(solid(Color::BLACK), Paint::None, 1.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Fine", ap.clone()); + g.id = 1; + g.opacity = 0.123456; + *doc.graphic_styles = vec![g]; + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap); + n.opacity = 0.123456; + n.extras_mut().graphic_style = Some(1); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.graphic_styles.len(), 1); + assert!((back.graphic_styles[0].opacity - 0.123456).abs() < 1e-3); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, Some(back.graphic_styles[0].id)); +} + /// A compound shape draws as one area, which an art style's object wouldn't keep: it and a group /// holding one are written as their drawn look, with a warning. #[test] @@ -5802,6 +5943,45 @@ fn compound_shapes_are_not_written_with_an_art_style() { } } +/// Graphic styles the reader's style list wouldn't hold (a name over its limit, styles past its +/// count) are left out with a warning, and an object linked to one has no link. +#[test] +fn graphic_styles_past_the_lists_limits_are_left_out() { + let plain = || Appearance::basic(solid(Color::BLACK), Paint::None, 1.0); + let styled = |name: String, id: u32| { + let mut g = vectorcraft_doc::GraphicStyle::new(name, plain()); + g.id = id; + g + }; + // A name one byte over the limit, among others. + let mut doc = Document::new(200.0, 100.0); + let long = "n".repeat(1025); + *doc.graphic_styles = vec![styled("Before".into(), 1), styled(long.clone(), 2), styled("After".into(), 3)]; + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), plain()); + linked.extras_mut().graphic_style = Some(2); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, linked).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + assert!(!shown(&out.program).contains(&long)); + let back = read_back(&doc); + let names: Vec<&str> = back.graphic_styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Before", "After"]); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None); + // A name at the limit is kept. + let at_limit = "n".repeat(1024); + doc.graphic_styles[1].name = at_limit.clone(); + assert!(read_back(&doc).graphic_styles.iter().any(|g| g.name == at_limit)); + // Styles past the count. + let mut many = Document::new(200.0, 100.0); + *many.graphic_styles = (1..=4097).map(|i| styled(format!("Style {i}"), i)).collect(); + let out = export_ai_editing(&many, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let back = read_back(&many); + assert_eq!(back.graphic_styles.len(), 4096); + assert_eq!(back.graphic_styles.last().map(|g| g.name.as_str()), Some("Style 4096")); +} + /// A member of a group whose Transform effect moves it is written where baking put it, not with /// its own effects where it was. #[test] @@ -5953,7 +6133,8 @@ fn read_one(make: impl FnOnce(&mut Document) -> Node) -> (Arc, Vec /// A filled path or compound path with live effects that fills by the even-odd rule is written as its /// drawn look with a warning, since a style's fill fills by the non-zero rule. A stroke-only object -/// keeps its style and its own fill rule. +/// keeps its style and its own fill rule. A filled even-odd object linked to a graphic style isn't +/// written with that style, and its link is left out with a warning. #[test] fn a_filled_even_odd_object_is_written_as_its_drawn_look() { let mut doc = Document::new(300.0, 100.0); @@ -5982,9 +6163,28 @@ fn a_filled_even_odd_object_is_written_as_its_drawn_look() { let back = read_back(&doc); same_styles(&back, &doc); assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. })); + + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(7); + if let NodeKind::Path { rule, .. } = &mut n.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }), "{n:?}"); + assert_eq!(n.extras().graphic_style, None); } -/// A compound path filled by the non-zero rule keeps its effects and its gradient fill. +/// A compound path filled by the non-zero rule keeps its effects, its gradient fill and its link to +/// a graphic style. #[test] fn a_compound_path_keeps_its_style() { let mut doc = Document::new(300.0, 100.0); @@ -6002,8 +6202,16 @@ fn a_compound_path_keeps_its_style() { let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(graded, Paint::None, 0.0) }; let n = named(non_zero(ring(&mut doc, ap)), "Graded"); put(&mut doc, n); + let sunshine = doc.graphic_style("Sunshine").unwrap().clone(); + let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(); + let mut linked_ring = named(ring(&mut doc, ap), "Linked"); + if let NodeKind::Compound { rule, .. } = &mut linked_ring.kind { + *rule = FillRule::NonZero; + } + linked_ring.extras_mut().graphic_style = Some(sunshine.id); + put(&mut doc, linked_ring); let out = export_ai_editing(&doc, &opts()).unwrap(); - assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::STYLE_LINKS), "{:?}", out.warnings); let back = read_back(&doc); same_styles(&back, &doc); assert!(back.layers[0].children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Compound { .. }))); @@ -6029,36 +6237,136 @@ fn a_group_holding_an_image_is_written_as_its_drawn_look() { assert!(im.link.is_some()); } -/// Graphic styles and links to them are left out with a warning: an object linked to a style -/// keeps its effects in a style of its own. A new document's styles without a link don't warn. +/// A link to a graphic style that isn't written warns; one that is written doesn't. #[test] -fn graphic_styles_and_links_are_left_out_with_a_warning() { - let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::GRAPHIC_STYLES); - assert!(!warns(&Document::new(200.0, 100.0))); - // A style of the document's own. - let mut own = Document::new(200.0, 100.0); - own.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0))); - assert!(warns(&own)); - assert!(!shown(&export_ai_editing(&own, &opts()).unwrap().program).contains("ArtStyle")); - assert_eq!(read_back(&own).graphic_style("Mine"), None); - // A link to a new document's style, on an object with an effect. - let mut linked = Document::new(200.0, 100.0); - let id = linked.graphic_style("Sunshine").unwrap().id; - let mut n = plain_rect(&mut linked, "Linked", vec![drop_shadow()]); - n.extras_mut().graphic_style = Some(id); - put(&mut linked, n); - assert!(warns(&linked)); - let back = read_back(&linked); - let n = &back.layers[0].children().unwrap()[0]; - assert_eq!(n.extras().graphic_style, None); - assert_eq!(n.appearance.effects.len(), 1); +fn a_link_left_out_warns() { + let warns = |make: &dyn Fn(&mut Document, u32) -> Node| { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer::new(black_fill())), AppearanceItem::Fill(FillLayer::new(solid(Color::WHITE)))], + ..Appearance::default() + }; + let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", ap); + layered.id = 7; + doc.graphic_styles.push(layered); + let mut plain = vectorcraft_doc::GraphicStyle::new("Plain", Appearance::basic(black_fill(), Paint::None, 0.0)); + plain.id = 8; + doc.graphic_styles.push(plain); + let n = make(&mut doc, 7); + put(&mut doc, n); + let warned = export_ai_editing(&doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::STYLE_LINKS); + (warned, read_back(&doc)) + }; + let linked = |mut n: Node, id: u32| { + n.extras_mut().graphic_style = Some(id); + n + }; + // A style of two fills: a path isn't read with it. + let (warned, back) = warns(&|doc, id| { + let ap = doc.graphic_style_by_id(id).unwrap().appearance.clone(); + linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap), id) + }); + assert!(warned); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None); + assert!(back.graphic_style("Layered").is_some()); + // An image, a symbol instance and a knockout shape are never written with a style. + let (warned, _) = warns(&|doc, _| { + let art = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + linked(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }), 8) + }); + assert!(warned); + let (warned, _) = warns(&|doc, _| { + let mut n = plain_rect(doc, "Out", vec![]); + n.knockout_shape = true; + linked(n, 8) + }); + assert!(warned); + // A group with a member the style can't hold. + let (warned, _) = warns(&|doc, _| { + let member = plain_rect(doc, "Member", vec![drop_shadow()]); + linked(group(doc, vec![member], false), 8) + }); + assert!(warned); + // A group with an effect and a member linked to a style: the group keeps its effect, and the + // member's link is left out. + let (warned, back) = warns(&|doc, _| { + let member = linked(plain_rect(doc, "Member", vec![]), 8); + let mut g = group(doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + g + }); + assert!(warned); + let g = &back.layers[0].children().unwrap()[0]; + assert_eq!(g.appearance.effects.len(), 1, "{g:?}"); + assert_eq!(g.children().unwrap()[0].extras().graphic_style, None); + // A group whose link isn't written: its member keeps its own. + let (warned, back) = warns(&|doc, id| { + let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone(); + let member = linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1); + linked(group(doc, vec![member], false), id) + }); + assert!(warned); + let g = &back.layers[0].children().unwrap()[0]; + assert_eq!(g.extras().graphic_style, None); + let member = g.children().unwrap()[0].extras().graphic_style.unwrap(); + assert_eq!(back.graphic_style_by_id(member).map(|s| s.name.as_str()), Some("Plain")); // A link in a symbol's art. - let mut in_symbol = Document::new(200.0, 100.0); - let id = in_symbol.graphic_style("Sunshine").unwrap().id; - let mut art = square(&mut in_symbol, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK); - art.extras_mut().graphic_style = Some(id); - in_symbol.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); - assert!(warns(&in_symbol)); + let (warned, _) = warns(&|doc, _| { + let art = linked(square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK), 8); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }) + }); + assert!(warned); + // A link that is kept. + let (warned, back) = warns(&|doc, id| { + let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone(); + linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1) + }); + assert!(!warned); + let id = back.layers[0].children().unwrap()[0].extras().graphic_style.unwrap(); + assert_eq!(back.graphic_style_by_id(id).map(|g| g.name.as_str()), Some("Plain")); +} + +/// A panel with no style written warns that a new document's styles come back. +#[test] +fn an_empty_graphic_styles_panel_warns() { + let mut doc = Document::new(200.0, 100.0); + doc.graphic_styles.clear(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("ArtStyle")); + assert_eq!(read_back(&doc).graphic_styles, Document::new(1.0, 1.0).graphic_styles); + // Every style left out. + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", Appearance::basic(graded, Paint::None, 0.0))); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY) && out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + // A new document's styles aren't written and don't warn. + let out = export_ai_editing(&Document::new(200.0, 100.0), &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings); +} + +/// A second style of one name is left out with a warning, and an object linked to it has no link. +#[test] +fn a_graphic_style_of_a_name_another_has_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let style = |id: u32, c: Color| { + let mut g = vectorcraft_doc::GraphicStyle::new("Twin", Appearance::basic(solid(c), Paint::None, 0.0)); + g.id = id; + g + }; + *doc.graphic_styles = vec![style(1, Color::BLACK), style(2, Color::WHITE)]; + let mut n = styled_rect(&mut doc, "Second", Rect::new(10.0, 10.0, 50.0, 40.0), solid(Color::WHITE), Paint::None, vec![]); + n.extras_mut().graphic_style = Some(2); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES) && out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.graphic_styles.len(), 1); + assert_eq!(back.graphic_styles[0].appearance.fill().and_then(|f| f.paint.color()), Some(Color::BLACK)); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None); } /// A path or compound path with no points writes nothing, with or without a style; a group with no @@ -6183,6 +6491,93 @@ fn every_effect_setting_written_on_an_object_is_read_back() { assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}"); } +/// Each effect a graphic style can hold, in a style of its own, reads back with its settings, whatever +/// the writer's conversions to the file's units. +#[test] +fn every_effect_a_graphic_style_holds_is_read_back() { + use serde_json::json; + let mut cases = vec![ + ( + "distort.transform", + json!({"scaleH": 50.0, "scaleV": 150.0, "moveH": 12.5, "moveV": -7.5, "rotate": 30.0, "copies": 2, "reference": 8, "reflectX": true, "objects": false, "strokes": true, "patterns": true}), + ), + ("distort.freeDistort", json!({"corners": [[0.1, 0.05], [0.9, 0.1], [1.0, 1.0], [0.0, 0.8]]})), + ("distort.puckerBloat", json!({"amount": -40.0})), + ("distort.puckerBloat", json!({"amount": 60.0})), + ("distort.twist", json!({"angle": -45.0})), + ("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})), + ("distort.zigZag", json!({"size": 25.0, "relative": true, "ridges": 3, "points": "smooth"})), + ("blur.gaussian", json!({"radius": 2.5})), + ]; + let warps = [ + "arc", + "arcLower", + "arcUpper", + "arch", + "bulge", + "shellLower", + "shellUpper", + "flag", + "wave", + "fish", + "rise", + "fisheye", + "inflate", + "squeeze", + "twist", + ]; + let ids: Vec = warps.iter().map(|w| format!("warp.{w}")).collect(); + for id in &ids { + cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"}))); + cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); + } + for (id, params) in cases { + let effect = fx(id, params.clone()); + let mut doc = Document::new(200.0, 100.0); + let mut g = vectorcraft_doc::GraphicStyle::new( + "Held", + Appearance { effects: vec![effect.clone()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }, + ); + g.id = 3; + *doc.graphic_styles = vec![g]; + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{id} {params}: {:?}", out.warnings); + let back = read_back(&doc); + let got = &back.graphic_styles[0].appearance.effects; + assert_eq!(got.len(), 1, "{id} {params}"); + assert_eq!(got[0].id, effect.id); + let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params)); + for (key, value) in w.as_object().unwrap() { + // Random isn't written, and a style with it isn't read; its seed is the default. + let read = g.get(key).unwrap_or(&serde_json::Value::Null); + match value { + serde_json::Value::Array(rows) => { + let got = read.as_array().unwrap(); + assert_eq!(got.len(), rows.len(), "{id}"); + for (r, w) in got.iter().zip(rows) { + for (r, w) in r.as_array().unwrap().iter().zip(w.as_array().unwrap()) { + assert!((r.as_f64().unwrap() - w.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {value}"); + } + } + } + serde_json::Value::Number(v) => assert!((read.as_f64().unwrap() - v.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {v}"), + serde_json::Value::String(v) => assert_eq!(read.as_str().map(str::to_ascii_lowercase), Some(v.to_ascii_lowercase()), "{id} {key}"), + _ => assert_eq!(read, value, "{id} {key}"), + } + } + } + // A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians. + let mut doc = Document::new(200.0, 100.0); + let effects = vec![ + fx("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})), + fx("distort.transform", json!({"moveV": -7.5, "rotate": 30.0})), + ]; + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Forms", Appearance { effects, ..Appearance::default() })); + let p = program_shown(&doc); + assert!(p.contains("7.5 /Real (amount) ,") && p.contains("0 /Real (roundness) ,"), "{p}"); + assert!(p.contains("7.5 /Real (moveV_Pts) ,") && p.contains("0.5236 /Real (rotate_Radians) ,"), "{p}"); +} + /// Every kind of appearance a style isn't written for is written as its drawn look, with a warning. #[test] fn appearances_written_as_their_drawn_look() { @@ -6324,6 +6719,60 @@ fn appearances_written_as_their_drawn_look() { } } +/// A link to a graphic style whose Transform moves the outline is kept on a path the reader reads +/// that style on, and left out with a warning on a path with an opacity mask, which keeps its mask. +#[test] +fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it() { + let make = |masked: bool| { + let mut doc = Document::new(200.0, 100.0); + let moved = fx("distort.transform", serde_json::json!({"copies": 0, "scaleH": 50.0})); + let ap = Appearance { effects: vec![moved], ..Appearance::basic(black_fill(), Paint::None, 0.0) }; + let mut g = vectorcraft_doc::GraphicStyle::new("Moved", ap.clone()); + g.id = 9; + doc.graphic_styles.push(g); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(9); + if masked { + let art = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 25.0), Color::gray(0.5)); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into()); + } + put(&mut doc, n); + doc + }; + let doc = make(false); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Moved")); + + let doc = make(true); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style, None); + assert!(n.extras().mask.is_some(), "{n:?}"); +} + +/// Twist and Transform on an object that isn't linked to a graphic style are not written as a style +/// of its own: the object reads back without live effects. +#[test] +fn distort_effects_on_an_unlinked_object_write_no_style() { + let cases = [fx("distort.twist", serde_json::json!({"angle": 30.0})), fx("distort.transform", serde_json::json!({"copies": 0, "moveH": 5.0}))]; + for e in cases { + let what = e.id.clone(); + let mut doc = Document::new(200.0, 100.0); + let n = plain_rect(&mut doc, "D", vec![e]); + put(&mut doc, n); + let p = shown(&export_ai_editing(&doc, &opts()).unwrap().program); + assert!(!p.contains("%AI9_BeginArtStyles") && !p.contains("(Anon 1) XW"), "{what}:\n{p}"); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.effects.is_empty(), "{what}: {n:?}"); + } +} + /// A gradient fill that fits its object is placed on the outline its geometry effects draw: an /// object with Offset Path reads back with the gradient a plain object of the drawn size has. #[test] @@ -6622,14 +7071,26 @@ fn a_styled_objects_gradient_is_placed_as_a_plain_objects_is() { } } -/// On a large canvas an object with an effect warns and comes back as its drawn look at its place. +/// On a large canvas a document with only graphic styles, an object linked to one and an object with +/// an effect all warn, and the object comes back as its drawn look at its place. #[test] fn a_large_canvas_writes_every_object_as_its_look() { let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES); + let mut styles_only = Document::new(20000.0, 100.0); + styles_only.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0))); + assert!(warns(&styles_only)); + assert_eq!(read_back(&styles_only).graphic_style("Mine"), None); let mut plain = Document::new(20000.0, 100.0); let n = plain_rect(&mut plain, "None", vec![]); put(&mut plain, n); assert!(!warns(&plain)); + let mut linked_doc = Document::new(20000.0, 100.0); + let id = linked_doc.graphic_style("Sunshine").unwrap().id; + let mut n = plain_rect(&mut linked_doc, "Linked", vec![]); + n.extras_mut().graphic_style = Some(id); + put(&mut linked_doc, n); + assert!(warns(&linked_doc)); + assert_eq!(read_back(&linked_doc).layers[0].children().unwrap()[0].extras().graphic_style, None); let mut shaded = Document::new(20000.0, 100.0); let n = plain_rect(&mut shaded, "Shaded", vec![drop_shadow()]); put(&mut shaded, n); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 90182c8fd..0c3587e58 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -22,9 +22,9 @@ mod symbols; mod texts; mod variables; +pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; #[cfg(test)] -pub(super) use styles::same_appearance; -pub(super) use styles::{GRAPHIC_STYLES, LARGE_CANVAS_STYLES, has_graphic_styles, styled_objects}; +pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance}; pub(super) use images::PAYLOAD; #[cfg(test)] diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 8f52a0daa..65fe91859 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -1,17 +1,18 @@ -//! Art styles: objects with live effects a plain path can't hold, written as their drawn look, the -//! object itself on `%_` lines and `1 (style) XW`, and the styles they name in -//! `%AI9_BeginArtStyles` (see [`crate::ai_editing`] for the form). Each style is read back before it -//! is written ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of -//! a graphic style, with the object's own gradient): one that reads back differently is not -//! written, and the object is written as its drawn look alone. +//! Art styles: objects whose appearance a plain path can't hold, written as their drawn look, the +//! object itself on `%_` lines and `1 (style) XW`; the styles they name and the Graphic Styles +//! panel's styles in `%AI9_BeginArtStyles`, and the panel's order in `%AI9_BeginArtStyleList` (see +//! [`crate::ai_editing`] for the form). Each style is read back before it is written +//! ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of a graphic +//! style, with the object's own gradient): one that reads back differently is not written, and the +//! object is written as its drawn look alone. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::sync::Arc; use serde_json::Value; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, GraphicStyle, Knockout, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{BezPath, FillRule, Rect, Shape}; use super::{Writer, Xy, blend_code, compound_path, hide}; @@ -19,33 +20,35 @@ use crate::ai_ops; use crate::ps; pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written: objects with live effects are written with their geometry effects applied to the artwork, where they can no longer be edited, and their other effects are left out of the editing data for other apps"; -pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps"; +pub(in crate::ai_editing) const PANEL_STYLES: &str = "graphic styles with gradient or pattern fills, with fills, strokes or effects the editing data can't hold as a graphic style, with a name another style has, with names over 1,024 bytes, or past 4,096 styles, are left out of the editing data for other apps"; +pub(in crate::ai_editing) const PANEL_EMPTY: &str = + "a Graphic Styles panel with no style written opens from the editing data with a new document's styles"; +pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style"; -/// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity -/// masks or symbols linked to a graphic style? -pub(in crate::ai_editing) fn has_graphic_styles(doc: &Document) -> bool { - fn linked(n: &Node) -> bool { - n.extras().graphic_style.is_some() - || n.extras().mask.as_ref().is_some_and(|m| linked(&m.art)) - || n.children().is_some_and(|c| c.iter().any(|m| linked(m))) +/// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. +pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { + fn count(n: &Node) -> usize { + let own = usize::from(n.extras().graphic_style.is_some()); + let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); + own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) } - doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles || doc.layers.iter().any(|l| linked(l)) || doc.symbols.iter().any(|s| linked(&s.art)) + doc.layers.iter().map(|l| count(l)).sum::() + doc.symbols.iter().map(|s| count(&s.art)).sum::() } /// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), -/// by id: paths and compound paths with points and groups, with live effects, without Opacity & -/// Mask Define Knockout Shape (the reader takes an object's transparency from its look, which -/// doesn't carry it). A group's members are paths, compound paths or such groups, none with effects -/// or anything baking changes. +/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic +/// style, without Opacity & Mask Define Knockout Shape (the reader takes an object's transparency +/// from its look, which doesn't carry it). A group's members are paths, compound paths or such +/// groups, none with effects or anything baking changes. pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { fn walk(nodes: &[Arc], out: &mut HashMap>) { for n in nodes { if candidate(n) { out.insert(n.id, n.clone()); - continue; } - // Baking a container with effects of its own changes its members, which keep their ids: - // they are written as baking left them. + // A group's members are written with a style of their own only when the group is written + // without one. Baking a container with effects of its own changes its members, which + // keep their ids: they are written as baking left them. if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &n.kind && !bakes_members(n) { @@ -69,7 +72,8 @@ fn bakes_members(n: &Node) -> bool { /// Whether `n` may be written with an art style ([`styled_objects`]). fn candidate(n: &Node) -> bool { - if n.appearance.effects.is_empty() || n.knockout_shape { + let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some(); + if !styled || n.knockout_shape { return false; } match &n.kind { @@ -96,25 +100,39 @@ fn plain_member(n: &Node) -> bool { } } -/// The art styles objects name, each with its text after its name (`/ActiveStyle :` to `/Def ;`). +/// The art styles written: the Graphic Styles panel's (listed, in its order) and those objects +/// name, each with its text after its name (`/ActiveStyle :` to `/Def ;`). #[derive(Default)] pub(in crate::ai_editing) struct Styles { /// Every style, in the order written: its name and text. known: Vec<(String, String)>, - /// The name of each style by its text. + /// The panel's styles written, by the graphic style's id. + panel: HashMap, + /// The panel's styles written, in its order. + listed: Vec, + /// The names styles have, and the name of each object style by its text. + names: HashSet, by_text: HashMap, next_anon: u64, + /// How many objects were written with the graphic style they are linked to. + pub(super) links_written: usize, } impl Styles { /// The name of an object style of `text`: a style with the same text already written, else a - /// new one named `Anon` and a number. + /// new one named `Anon` and a number no other style has. fn object_style(&mut self, text: String) -> String { if let Some(name) = self.by_text.get(&text) { return name.clone(); } - self.next_anon += 1; - let name = format!("Anon {}", self.next_anon); + let name = loop { + self.next_anon += 1; + let name = format!("Anon {}", self.next_anon); + if !self.names.contains(&name) { + break name; + } + }; + self.names.insert(name.clone()); self.by_text.insert(text.clone(), name.clone()); self.known.push((name.clone(), text)); name @@ -286,16 +304,30 @@ impl Writer<'_> { (!line.is_empty()).then_some(line) } - /// An appearance item's basic filter. `None` for an item with transparency or effects of its - /// own, which the reader doesn't read on an object. + /// An appearance item's filter: its basic filter, or a chain of it, its effects and its + /// transparency. fn item_filter(&mut self, s: &mut String, item: &AppearanceItem, on: Option<(&BezPath, FillRule)>) -> Option<()> { - if item.opacity() < 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() { - return None; - } + let plain = item.opacity() >= 1.0 && item.blend() == BlendMode::Normal && item.effects().is_empty(); + let mut basic_text = String::new(); + // In a chain, the chain holds the item's visibility. + let visible = item.visible() || !plain; match item { - AppearanceItem::Fill(f) => self.fill_filter(s, f, item.visible(), on), - AppearanceItem::Stroke(st) => self.stroke_filter(s, st, item.visible()), + AppearanceItem::Fill(f) => self.fill_filter(&mut basic_text, f, visible, on)?, + AppearanceItem::Stroke(st) => self.stroke_filter(&mut basic_text, st, visible)?, } + if plain { + s.push_str(&basic_text); + return Some(()); + } + let _ = write!(s, "/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n{} /Visible ,\n", u8::from(item.visible())); + s.push_str(&basic_text); + for e in item.effects() { + s.push_str(&self.effect_filter(e)?); + } + let opacity = if item.opacity().is_finite() { item.opacity().clamp(0.0, 1.0) } else { 1.0 }; + blend_filter(s, Xy { blend: item.blend(), opacity, ..Xy::OPAQUE }); + s.push_str(";\n /Part ,\n"); + Some(()) } /// The text of a style of appearance `ap` and transparency `xy` (after its name): the chain @@ -337,7 +369,13 @@ impl Writer<'_> { let p = vectorcraft_effects::merged_params(&e.id, &e.params); let mut d = Entries::default(); let id = e.id.as_str(); - let name = if id.starts_with("stylize.") { self.stylize_entries(id, &p, &mut d)? } else { Self::path_entries(id, &p, &mut d)? }; + let name = if id.starts_with("stylize.") { + self.stylize_entries(id, &p, &mut d)? + } else if id.starts_with("warp.") || id.starts_with("distort.") || id.starts_with("blur.") { + Self::distort_entries(id, &p, &mut d)? + } else { + Self::path_entries(id, &p, &mut d)? + }; let mut s = String::new(); basic(&mut s, name, true, e.visible); s.push_str("/Dictionary : /NotRecorded ,\n"); @@ -460,9 +498,140 @@ impl Writer<'_> { }) } - /// The style object `orig` (`n` before baking) is written with; `None` when the reader wouldn't - /// read the object as it is with it. A gradient fill is placed on `n`'s outline, as the object - /// draws it. A style that isn't written leaves no gradient definition or warning behind. + /// A Warp, Distort & Transform or Gaussian Blur effect's entries ([`Self::effect_filter`]) and + /// its filter's name. + fn distort_entries(id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + id if id.starts_with("warp.") => { + let style = crate::import::warp_number(id.strip_prefix("warp.")?)?; + d.int("DeformStyle", style); + d.real("DeformValue", num(p, "bend")? / 100.0); + d.real("DeformHoriz", num(p, "horizontal")? / 100.0); + d.real("DeformVert", num(p, "vertical")? / 100.0); + d.bool("Rotate", word(p, "orientation").as_deref() == Some("vertical")); + "Adobe Deform" // brand-ok: the filter's name in the files + } + "distort.transform" => { + d.real("scaleH_Factor", num(p, "scaleH")? / 100.0); + d.real("scaleV_Factor", num(p, "scaleV")? / 100.0); + d.real("moveH_Pts", num(p, "moveH")?); + // Art space has y up. + d.real("moveV_Pts", 0.0 - num(p, "moveV")?); + d.real("rotate_Radians", num(p, "rotate")?.to_radians()); + d.int("numCopies", num(p, "copies")? as i64); + d.int("pinPoint", num(p, "reference")? as i64); + d.bool("reflectX", flag(p, "reflectX")?); + d.bool("reflectY", flag(p, "reflectY")?); + d.bool("transformObjects", flag(p, "objects")?); + d.bool("scaleLines", flag(p, "strokes")?); + d.bool("transformPatterns", flag(p, "patterns")?); + d.bool("randomize", false); + "Adobe Transform" // brand-ok: the filter's name in the files + } + "distort.freeDistort" => { + let corners = p.get("corners")?.as_array()?; + let corner = |i: usize| -> Option<(f64, f64)> { + let c = corners.get(i)?.as_array()?; + Some((c.first()?.as_f64()?, c.get(1)?.as_f64()?)) + }; + // The source frame is the unit square (y up); destination corners 0, 1, 3, 2 are + // the catalog's top left, top right, bottom right, bottom left (y down). + for (i, (h, v)) in [(0.0, 1.0), (1.0, 1.0), (0.0, 0.0), (1.0, 0.0)].into_iter().enumerate() { + d.real(&format!("src{i}h"), h); + d.real(&format!("src{i}v"), v); + } + for (i, from) in [0, 1, 3, 2].into_iter().enumerate() { + let (x, y) = corner(from)?; + d.real(&format!("dst{i}h"), x); + d.real(&format!("dst{i}v"), 1.0 - y); + } + "Adobe Free Distort" // brand-ok: the filter's name in the files + } + "distort.puckerBloat" => { + d.real("d_factor", num(p, "amount")?); + "Adobe Punk and Bloat" // brand-ok: the filter's name in the files + } + "distort.twist" => { + d.real("angle", num(p, "angle")?); + "Adobe Twirl" // brand-ok: the filter's name in the files + } + "distort.zigZag" => { + let relative = flag(p, "relative")?; + d.real("absoluteness", if relative { 0.0 } else { 1.0 }); + d.real(if relative { "relAmount" } else { "amount" }, num(p, "size")?); + d.real("ridges", num(p, "ridges")?); + d.real("roundness", if word(p, "points").as_deref() == Some("smooth") { 1.0 } else { 0.0 }); + "Adobe Zigzag" // brand-ok: the filter's name in the files + } + "blur.gaussian" => { + d.real("blur", num(p, "radius")?); + "Adobe PSL Gaussian Blur" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// The Graphic Styles panel's styles that the reader reads as they are: each checked, and + /// the others left out with a warning. Their names are taken before the art's styles are. + pub(in crate::ai_editing) fn panel_styles(&mut self) { + let doc = self.doc; + // A new document's styles are what the reader gives a file without a style list; they are + // still written when an object is linked to one of them, so that it keeps its link. + let linked = self.styled.values().any(|n| n.extras().graphic_style.is_some()); + if !linked && doc.graphic_styles == Document::new(1.0, 1.0).graphic_styles { + return; + } + for g in doc.graphic_styles.iter() { + self.styles.names.insert(g.name.clone()); + } + let mut readable: Vec<(&GraphicStyle, String)> = vec![]; + let mut seen: HashSet<&str> = HashSet::new(); + for g in doc.graphic_styles.iter() { + if readable.len() >= crate::import::MAX_LISTED || g.name.len() > crate::import::MAX_LISTED_NAME || seen.contains(g.name.as_str()) { + self.warn(PANEL_STYLES); + continue; + } + let xy = Xy { blend: g.blend, opacity: g.opacity.clamp(0.0, 1.0), isolate: g.isolate, knockout: g.knockout, shape: false }; + let text = self.style_text(&g.appearance, xy, None).filter(|t| panel_reads(g, t)); + let Some(text) = text else { + self.warn(PANEL_STYLES); + continue; + }; + seen.insert(g.name.as_str()); + readable.push((g, text)); + } + // The list as the reader reads it: names too long, and styles past its count, aren't. + let list: String = readable.iter().map(|(g, _)| ps::bytes(g.name.as_bytes()) + "\n").collect(); + let listed: HashSet = crate::import::read_style_list(list.as_bytes()).into_iter().collect(); + for (g, text) in readable { + if !listed.contains(&g.name) { + self.warn(PANEL_STYLES); + continue; + } + if g.id != 0 { + self.styles.panel.insert(g.id, g.name.clone()); + } + self.styles.listed.push(g.name.clone()); + self.styles.known.push((g.name.clone(), text)); + } + // The reader gives a file without a style list a new document's styles. + if self.styles.listed.is_empty() { + self.warn(PANEL_EMPTY); + } + } + + /// Warn when fewer objects were written with their graphic style than `links` are linked to one. + pub(in crate::ai_editing) fn lost_links(&mut self, links: usize) { + if self.styles.links_written < links { + self.warn(STYLE_LINKS); + } + } + + /// The style object `orig` (`n` before baking) is written with: the graphic style it is linked + /// to when it has that style's appearance and transparency and the panel's style is written, + /// else a style of its own; `None` when the reader wouldn't read the object as it is with + /// either. A gradient fill is placed on `n`'s outline, as the object draws it. A style of its own + /// that isn't written leaves no gradient definition or warning behind. pub(super) fn object_style(&mut self, n: &Node, orig: &Node) -> Option { if let NodeKind::Group { children, .. } = &orig.kind && bakes_members(orig) @@ -470,6 +639,21 @@ impl Writer<'_> { { return None; } + let doc = self.doc; + if let Some(id) = orig.extras().graphic_style + && let Some(name) = self.styles.panel.get(&id).cloned() + && let Some(g) = doc.graphic_style_by_id(id) + && g.transparency_matches(orig) + && g.appearance_on(orig).approx_eq(&orig.appearance) + && let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone()) + && object_reads(orig, &text) + { + self.styles.links_written += 1; + return Some(name); + } + if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) { + return None; + } let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); let saved = (graded.then(|| self.gradient_mark()), self.warnings.len()); let path = drawn_outline(n, orig); @@ -597,6 +781,20 @@ impl Writer<'_> { } s.push_str("%AI9_EndArtStyles\n%AI5_End_NonPrinting--\n"); } + + /// The panel's styles written, in its order, in a non-printing block: + /// `%AI9_BeginArtStyleList`, a name on each line, `%AI9_EndArtStyleList`. + pub(in crate::ai_editing) fn art_style_list(&self, s: &mut String) { + if self.styles.listed.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n"); + for name in &self.styles.listed { + s.push_str(&ps::bytes(name.as_bytes())); + s.push('\n'); + } + s.push_str("%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n"); + } } /// The outline a path's or compound path's style places a gradient fill on. @@ -623,15 +821,38 @@ fn object_rule(n: &Node) -> FillRule { } } +/// Can appearance `ap` be written as a style of an object's own: fills and strokes that paint without +/// transparency or effects of their own, and no Warp, Distort & Transform or Blur effect? Those +/// are written only in the Graphic Styles panel's styles. +fn own_style_holds(ap: &Appearance) -> bool { + let plain = |i: &AppearanceItem| i.opacity() >= 1.0 && i.blend() == BlendMode::Normal && i.effects().is_empty(); + let panel_only = |e: &Effect| ["warp.", "distort.", "blur."].iter().any(|p| e.id.starts_with(p)); + ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only) +} + +/// Does the reader read graphic style `g` from style text `text` as it is? +fn panel_reads(g: &GraphicStyle, text: &str) -> bool { + let section = known_style("x", text); + let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false }; + // The opacity within the written number's precision. + read.transparency.is_some_and(|t| (t.0 - g.opacity).abs() <= 1e-4 && (t.1, t.2, t.3) == (g.blend, g.isolate, g.knockout)) + && same_appearance(&read.appearance, &g.appearance) +} + /// Does the reader read object `n` written with style text `text` as it is: a path or compound /// path with a style it reads on any object, filled by the non-zero rule when the style fills it, -/// or a group with no fills or strokes of its own? +/// or a group with no fills or strokes of its own, with effects that move the outline only where the +/// reader reads them ([`crate::import::style_moves_with`])? fn object_reads(n: &Node, text: &str) -> bool { let section = known_style("x", text); let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_))); let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false }; let group = matches!(n.kind, NodeKind::Group { .. }); - if !read.simple || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) { + if !read.simple + || (group && !read.appearance.items.is_empty()) + || (!group && read.pathfinder) + || !crate::import::style_moves_with(section.as_bytes(), "x", n) + { return false; } if !group { diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index 495bc0074..9b9724f84 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -204,6 +204,8 @@ impl Writer<'_> { if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() { self.warn(SYMBOL_OPTIONS); } + // The art's effects are its own, not those of a drawn look that first places it. + let in_look = std::mem::take(&mut self.in_look); self.defining_symbols.push(name.to_string()); // The objects of this definition and of those it defines first. let before = self.charges(); @@ -220,6 +222,7 @@ impl Writer<'_> { self.defining_symbols.pop(); self.keep_since(before); self.warn(SYMBOL_COUNT); + self.in_look = in_look; return false; } // The group that holds the art is an object of the definition ([`Self::take_objects`]); @@ -305,6 +308,7 @@ impl Writer<'_> { (self.wrote_fixed, self.inner_scale), ) = saved; self.doc = page_doc; + self.in_look = in_look; let art = std::mem::replace(&mut self.body, art); self.defining_symbols.pop(); if self.left_out_since(art_start) { diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 3901c96c8..7f1391756 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -75,7 +75,10 @@ use obj::PsError; #[cfg(test)] pub(crate) use ai::objects_read; -pub(crate) use ai::{MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member}; +pub(crate) use ai::{ + MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, + symbol_art_member, warp_number, +}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; @@ -308,7 +311,8 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< /// The document of what `out` drew: one layer, clipped art in clipping groups (grouped art in /// groups), the spot inks as spot swatches, in CMYK when most process colours were. fn finish(mut out: Out) -> Imported { - let drawn = std::mem::take(&mut out.drawn); + let mut drawn = std::mem::take(&mut out.drawn); + graphics::merge_strips(&mut drawn, &mut out.shadings); let mut children = vectorcraft_doc::clipnest::nest(&mut out.doc, drawn); if !out.shadings.is_empty() { graphics::collapse(&mut children, &out.shadings); diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 2a23f66b5..6b5918530 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -45,14 +45,16 @@ pub(crate) use read::objects_read; #[cfg(test)] pub(crate) use read::with_image_limits; pub use read::{ - BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read, - read_beside, variables, + BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, Symbols, active_artboard, artboards, bindings_note, brushes, graphic_styles, + palette, read, read_beside, symbols, variables, }; pub(crate) use read::{ - MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, slot_frame, slot_of, symbol_art_member, + MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, + symbol_art_member, }; pub(crate) use read::is_look_slot; +pub(crate) use style::{MAX_LISTED, MAX_LISTED_NAME, warp_number}; #[cfg(test)] mod tests; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 4a010a4de..b01ae54a1 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -142,10 +142,11 @@ mod types; pub(crate) use art::join_and; use art::preview_png; pub use document::{ - BindingRef, Brushes, PaletteSwatches, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, variables, + BindingRef, Brushes, PaletteSwatches, Symbols, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, + symbols, variables, }; pub(crate) use document::{slot_frame, slot_of}; -pub(crate) use ops::read_art_style; +pub(crate) use ops::{read_art_style, read_style_list, style_moves_with}; #[cfg(test)] pub(crate) use raster::with_image_limits; pub(crate) use types::MAX_NODES; diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 68f981751..ef27e5f74 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -503,7 +503,15 @@ impl<'a> Reader<'a> { let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { return; }; - let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); + let art: Vec> = frame + .children + .iter() + .map(|n| { + let mut kept = self.doc.reid(n); + self.reid_masks(&mut kept, 0); + Arc::new(kept) + }) + .collect(); // The objects of the art linked to graphic styles, by their ids in the document. if !sub.style_links.is_empty() { let mut ids = HashMap::new(); @@ -671,7 +679,7 @@ impl<'a> Reader<'a> { named } - /// Give the art of the opacity masks in `n` (a copy of a symbol's art or of a clipping path with ids of this + /// Give the art of the opacity masks in `n` (a copy of a symbol's, a pattern's or a brush's art, or of a clipping path, with ids of this /// document, [`vectorcraft_doc::Document::reid`], which leaves masks as they are) ids of this /// document too. pub(super) fn reid_masks(&mut self, n: &mut Node, depth: usize) { @@ -783,7 +791,15 @@ impl<'a> Reader<'a> { && sub.doc.images.is_empty(); let Some(frame) = sub.frames.pop().filter(|f| whole && !f.children.is_empty()) else { return }; self.brush_art_looks.insert(name.clone(), sub.drawn_looks); - let mut art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); + let mut art: Vec> = frame + .children + .iter() + .map(|n| { + let mut kept = self.doc.reid(n); + self.reid_masks(&mut kept, 0); + Arc::new(kept) + }) + .collect(); let node = match art.len() { 1 => art.pop().map(Arc::unwrap_or_clone), _ => Some(Node::group(self.doc.alloc_id(), art)), diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 55cca0a86..9277bcc62 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -405,6 +405,73 @@ pub fn graphic_styles(data: &[u8], large_canvas: bool) -> (Vec, f6 (r.graphic_styles().0, r.canvas_scale) } +/// The symbols of a `.ai` file's editing data, as [`symbols`] reads them. +pub struct Symbols { + /// The symbols in the Symbols panel's order, each with its Symbol Options › Registration. + pub symbols: Vec<(Symbol, vectorcraft_doc::SymbolRegistration)>, + /// The image blobs the symbols' art shows, by key. + pub images: std::collections::BTreeMap, + /// The global and spot colors the symbols' art is linked to, as swatches, as [`read`] adds + /// them to a document read from the layers. + pub swatches: Vec, +} + +/// The symbols of the editing data `data`, for a file whose layers aren't read (a symbol library +/// has none), as [`read`] makes them: in the symbol list's order, without those that hold an +/// instance of a symbol the data doesn't define, and with each object of their art that is linked +/// to a listed graphic style linked to it by the id [`graphic_styles`] gives that style. The +/// symbols are those defined before reading stops and before the first layer; `None` when there +/// are none. +pub fn symbols(data: &[u8]) -> Option { + crate::import::ai::find(data, b"BeginSymbol")?; + let mut r = Reader::with_names(data); + // The symbols come before the art, so an error in the art leaves them read. + let _ = r.run(); + r.check_symbols(); + r.order_symbols(); + if r.doc.symbols.is_empty() { + return None; + } + let (styles, ids) = r.graphic_styles(); + let links: HashMap = r.style_links.iter().filter_map(|(node, name)| Some((*node, *ids.get(name)?))).collect(); + if !links.is_empty() { + for symbol in &mut r.doc.symbols { + link_styles(std::slice::from_mut(&mut symbol.art), &links, &styles, 0); + } + } + let mut shown = Document::new(1.0, 1.0); + shown.symbols = std::mem::take(&mut r.doc.symbols); + let mut images = std::collections::BTreeMap::new(); + shown.visit_images(|_, im| { + if let Some(blob) = r.doc.images.get(&im.key) { + images.insert(im.key.clone(), blob.clone()); + } + }); + // The colors the art is linked to that the file defines, once each. + let mut linked = std::collections::HashSet::new(); + for s in &mut shown.symbols { + vectorcraft_doc::swatches::map_node_solid_paints(Arc::make_mut(&mut s.art), &mut |_, link, _| { + linked.extend(link.clone()); + false + }); + } + let mut swatches: Vec = vec![]; + let colors = r.spots.iter().chain(&r.stop_spots).map(|n| (n, n.spot)).chain(r.custom_colors.iter().map(|n| (n, true))); + for (n, spot) in colors { + if linked.remove(&n.name) { + swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot }); + } + } + let symbols = std::mem::take(&mut shown.symbols) + .into_iter() + .map(|s| { + let reg = r.doc.symbol_registration(&s.name); + (s, reg) + }) + .collect(); + Some(Symbols { symbols, images, swatches }) +} + /// [`artboards`] as far as reading `data` from its start gets: those read before an error, and /// those of a `/Document` still open when it stopped (its art can be what stopped it). fn artboards_read(data: &[u8]) -> (Vec<(String, Rect)>, Option) { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 4861e342d..3051b4a28 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1182,6 +1182,13 @@ pub(crate) struct StyleRead { pub simple: bool, /// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with. pub pathfinder: bool, + /// The opacity, blending mode, isolation and knockout its blend filter gives. + pub transparency: Option<(f32, BlendMode, bool, Knockout)>, +} + +/// The names a style list section gives graphic styles, as the reader reads them. +pub(crate) fn read_style_list(section: &[u8]) -> Vec { + crate::import::ai::style::art_style_list(section).iter().map(|raw| obj::text(raw)).filter(|name| !name.is_empty()).collect() } /// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`], @@ -1200,7 +1207,14 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P } } let appearance = appearance_from_art_style(&st)?; - Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder }) + Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency }) +} + +/// Is art style `name` of `section` read on `object` as far as an effect that moves the outline +/// goes: the style has none, or `object` is one [`moves_with`] reads it on, as [`styled`] checks? +/// `false` when the section doesn't define it. +pub(crate) fn style_moves_with(section: &[u8], name: &str, object: &Node) -> bool { + crate::import::ai::style::art_styles(section).remove(name).is_some_and(|st| !st.moves_outline() || moves_with(object, &st)) } /// A style's stroke as an appearance item (`None` for a fill). diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 857837afe..0ee91f862 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -122,6 +122,12 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> { id.strip_prefix("warp.") } +/// The number `DeformStyle` gives warp style `style` (`arc`, `arcLower`, …), from 1. +pub(crate) fn warp_number(style: &str) -> Option { + let at = WARPS.iter().position(|id| id.strip_prefix("warp.") == Some(style))?; + i64::try_from(at + 1).ok() +} + /// The Effect › Pathfinder effects by the `Command` number of their filter. `Command` 0 is Add /// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137); /// the numbers 1 to 11 are VectorCraft's own reader rules, learned from saved files. Trap (12) isn't @@ -326,9 +332,9 @@ impl Default for Filter { const MAX_FILTERS: usize = 4096; /// Most entries read from a style list. -const MAX_LISTED: usize = 4096; +pub(crate) const MAX_LISTED: usize = 4096; /// The longest style name read from a style list, in bytes. -const MAX_LISTED_NAME: usize = 1024; +pub(crate) const MAX_LISTED_NAME: usize = 1024; /// The style list (`%AI9_BeginArtStyleList`, between its markers): one literal string per style, /// the Graphic Styles panel's order. Each name is its string's bytes, escapes undone; other tokens, diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index ae79a345e..929006856 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1366,6 +1366,15 @@ fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() { assert!(!art_styles(data.as_bytes())["Odd"].plain); } +#[test] +fn warp_numbers_are_the_dialogs_order() { + for n in 1..=15 { + let style = warp_style(n).unwrap(); + assert_eq!(warp_number(style), Some(n), "{style}"); + } + assert_eq!((warp_style(0), warp_style(16), warp_number("arcs"), warp_number("")), (None, None, None, None)); +} + /// A style as a writer checks it: the reader's appearance, with a gradient fill as the object's own, /// and nothing for styles it doesn't read. #[test] @@ -1379,6 +1388,7 @@ fn a_style_is_read_as_a_writer_checks_it() { let got = read(&data, Some(&own)).unwrap(); assert_eq!(got.appearance.items.len(), 1); assert_eq!(got.appearance.items[0].paint(), &own); + assert_eq!(got.transparency.map(|t| (t.0, t.1)), Some((0.5, BlendMode::Multiply))); assert!(got.simple && !got.pathfinder); // A gradient that isn't the object's own, a pattern, another name, two gradients: nothing. assert!(read(&data, None).is_none()); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 07236c2c6..ffb950729 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5974,6 +5974,37 @@ fn an_object_in_a_brushs_art_with_a_style_this_reads_comes_in_as_itself() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// The art of an opacity mask in a brush's art takes ids of the document, as the rest of the art +/// does: no object of the brush's art shares an id with an object of the layers. +#[test] +fn a_brushs_opacity_mask_art_takes_ids_of_the_document() { + fn ids(n: &Node, out: &mut Vec) { + out.push(n.id); + if let Some(m) = n.extras().mask.as_ref() { + ids(&m.art, out); + } + for c in n.children().into_iter().flatten() { + ids(c, out); + } + } + let square = rect_path(0.0, 0.0, 10.0, 10.0); + let mask_art: String = format!("1 g\n{square}f\n").lines().map(|l| format!("%_{l}\n")).collect(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let section = format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n{RED}{square}f\n{mask}%AI8_EndBrushPattern\n"); + let shapes: String = (0..12).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 20.0, 0.0, f64::from(i) * 20.0 + 10.0, 10.0))).collect(); + let r = read_ok(&format!("{section}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(&shapes))); + let lib = vectorcraft_brush::library(&r.doc); + let spray = lib.iter().find(|b| b.name == "Spray").unwrap_or_else(|| panic!("{:?}", r.warnings)); + let art = spray.art(); + assert!(art.iter().any(|n| n.extras().mask.is_some()), "the mask is read: {art:#?}"); + let mut all = Vec::new(); + for n in r.doc.layers.iter().map(|l| &**l).chain(art) { + ids(n, &mut all); + } + let unique: std::collections::HashSet<_> = all.iter().copied().collect(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + /// An art styles section inside a symbol's, a pattern's or a brush's art is skipped: the file's own /// art styles still style the layers' objects read after the definition. #[test] @@ -6184,6 +6215,50 @@ fn an_object_in_a_symbols_art_with_a_listed_style_is_linked() { assert_eq!(art.extras().graphic_style, Some(1), "{art:#?}"); } +/// Editing data with symbols and no layers (a symbol library) gives its symbols: in the symbol +/// list's order with the unlisted one after them, with their registration points and the images +/// their art shows, without the one holding an instance of a symbol the data doesn't define, and +/// with the object linked to a listed graphic style linked to it by its id. +#[test] +fn a_symbol_library_without_layers_gives_its_symbols() { + let styles = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]); + // The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). + let nested = instance("Dot", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]); + let lost = instance("Ghost", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]); + let m = "[ 5 0 0 5 -8096 8244.5 ]"; + let raster = format!( + "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n{m} 2 1 0 Xh\n{m} 0 0 2 1 2 1 8 3 0 0 1 0 4 4 0 0\n\ + %%BeginData: 6\rXI\nABCDEF%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\n" + ); + let defs = symbols( + &(symbol("Dot", &dot(), 1) + + &symbol("Pic", &raster, 5) + + &symbol("Pair", &nested, 5) + + &symbol("Tile", &styled_square("Blue"), 5) + + &symbol("Lost", &lost, 5)), + ); + let list = "%AI24_BeginSymbolList\n(Tile)\n(Pair)\n(Lost)\n(Dot)\n%AI24_EndSymbolList\n"; + let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{styles}{defs}{list}"); + let read = super::symbols(stream(&body).as_bytes()).expect("the library's symbols"); + let names: Vec<&str> = read.symbols.iter().map(|(s, _)| s.name.as_str()).collect(); + assert_eq!(names, ["Tile", "Pair", "Dot", "Pic"]); + let registrations: Vec = read.symbols.iter().map(|(_, r)| r.0).collect(); + let center = vectorcraft_doc::SymbolRegistration::CENTER.0; + assert_eq!(registrations, [center, center, 0, center]); + let Some(((tile, _), (pair, _))) = read.symbols.first().zip(read.symbols.get(1)) else { panic!("two symbols") }; + assert_eq!(tile.art.extras().graphic_style, Some(1), "{:#?}", tile.art); + // Dot sits at (10, 5) in Pair's art. + let mut doc = Document::new(1.0, 1.0); + doc.symbols = read.symbols.iter().map(|(s, _)| s.clone()).collect(); + assert_eq!(pair.art.visual_bounds_with(&doc.symbol_boxes()), Some(Rect::new(8.0, 3.0, 12.0, 7.0))); + let keys: Vec = + doc.symbols.iter().filter_map(|s| if let NodeKind::Image(im) = &s.art.kind { Some(im.key.clone()) } else { None }).collect(); + assert_eq!(keys.len(), 1); + assert_eq!(read.images.keys().cloned().collect::>(), keys); + // Without symbols there are none to give. + assert!(super::symbols(stream(&styles).as_bytes()).is_none()); +} + #[test] fn an_object_in_a_patterns_art_with_a_listed_style_is_linked() { let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]); @@ -6196,6 +6271,35 @@ fn an_object_in_a_patterns_art_with_a_listed_style_is_linked() { assert_eq!(square.extras().graphic_style, Some(1), "{square:#?}"); } +/// The art of an opacity mask in a pattern's art takes ids of the document, as the rest of the art +/// does: no object of the pattern's art shares an id with an object of the layers. +#[test] +fn a_patterns_opacity_mask_art_takes_ids_of_the_document() { + fn ids(n: &Node, out: &mut Vec) { + out.push(n.id); + if let Some(m) = n.extras().mask.as_ref() { + ids(&m.art, out); + } + for c in n.children().into_iter().flatten() { + ids(c, out); + } + } + let square = rect_path(0.0, 0.0, 10.0, 10.0); + let mask_art: String = format!("1 g\n{square}f\n").lines().map(|l| format!("%_{l}\n")).collect(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let pattern = format!("%AI3_BeginPattern: (P)\n(P) 0 0 10 10\n0 A\n0 Xw\n{RED}{square}f\n{mask}E\n%AI3_EndPattern\n"); + let shapes: String = (0..12).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 20.0, 0.0, f64::from(i) * 20.0 + 10.0, 10.0))).collect(); + let r = read_ok(&format!("{pattern}{}", layer(&shapes))); + let def = r.doc.pattern("P").unwrap_or_else(|| panic!("{:?}", r.warnings)); + assert!(def.art.iter().any(|n| n.extras().mask.is_some()), "the mask is read: {:#?}", def.art); + let mut all = Vec::new(); + for n in r.doc.layers.iter().chain(&def.art) { + ids(n, &mut all); + } + let unique: std::collections::HashSet<_> = all.iter().copied().collect(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + #[test] fn an_object_in_a_version_7_patterns_tile_with_a_listed_style_is_linked() { let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]); diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 243187d6b..46fd148db 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -21,9 +21,11 @@ mod ops; mod paint; mod path; mod state; +mod strips; pub(crate) use paint::collapse; pub(crate) use state::{GState, Out, Space, process}; +pub(crate) use strips::merge_strips; /// Most objects one file makes. const MAX_NODES: usize = 1 << 20; diff --git a/crates/eps/src/import/graphics/strips.rs b/crates/eps/src/import/graphics/strips.rs new file mode 100644 index 000000000..9f0b39a24 --- /dev/null +++ b/crates/eps/src/import/graphics/strips.rs @@ -0,0 +1,323 @@ +//! Gradients a program paints as a row of abutting strips, each in one colour (the way many +//! PostScript writers draw a smooth shading at Level 2, which has no `shfill`), read as one +//! linear gradient. +//! +//! Drawn one by one, abutting strips leave seams: a renderer that anti-aliases each fill lets the +//! art below show through along every shared edge (PDF 32000-1:2008 §10.6.4 notes the effect, +//! "conflation", for adjacent areas painted separately), which reads as thin stripes across the +//! gradient, and as a tint of the colour below over the whole of it where the strips are only a +//! pixel or two wide. A run of them under the same clips becomes one object filled with a linear +//! gradient through the strips' colours, which has no seams and stays editable. +//! +//! Only strips that clearly are a gradient merge: at least [`MIN_STRIPS`] parallelograms of one +//! length, side by side in one direction with no gap or overlap, each a small step in colour from +//! the one before ([`MAX_STEP`]). Abutting blocks of distinct colours (a bar chart, a row of +//! swatches) stay as they are. + +use super::{ + Affine, Appearance, AppearanceItem, BezPath, Color, Drawn, FillLayer, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, HashSet, + MAX_STITCH_STOPS, Node, NodeId, NodeKind, Paint, PathData, PathEl, Point, Vec2, +}; + +/// Fewest strips (that paint something) a gradient is read from. +const MIN_STRIPS: usize = 6; +/// Largest change of any colour component (0 to 1) from one strip to the next. +const MAX_STEP: f32 = 0.08; +/// How far apart corners may be and still meet (points). +const EPS: f64 = 0.01; +/// Largest change of any colour component a stop left out may make to the gradient. +const TOLERANCE: f32 = 0.5 / 255.0; + +/// A filled parallelogram in one process colour. +struct Strip { + corners: [Point; 4], + model: u8, + comps: [f32; 4], + color: Color, + overprint: bool, +} + +/// The parallelogram `n` fills, in one process colour: `None` for anything else. +fn strip_of(n: &Node) -> Option { + let NodeKind::Path { path, live: None, clipping: false, guide: false, .. } = &n.kind else { return None }; + if n.appearance.items.len() != 1 { + return None; + } + let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() else { return None }; + if f.opacity != 1.0 || !f.visible || !f.effects.is_empty() || f.blend != vectorcraft_color::BlendMode::Normal { + return None; + } + let Paint::Solid { color, swatch: None, .. } = &f.paint else { return None }; + let (model, comps) = match *color { + Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]), + Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]), + Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]), + Color::Lab { .. } => return None, + }; + let [sp] = path.subpaths.as_slice() else { return None }; + let mut pts: Vec = Vec::with_capacity(5); + for a in &sp.anchors { + if a.h_in != a.p || a.h_out != a.p { + return None; + } + pts.push(a.p); + } + if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) <= EPS) { + pts.pop(); + } + let [a, b, c, d] = pts.as_slice() else { return None }; + // Opposite corners share a midpoint. + if (a.to_vec2() + c.to_vec2() - b.to_vec2() - d.to_vec2()).hypot() > EPS { + return None; + } + Some(Strip { corners: [*a, *b, *c, *d], model, comps, color: *color, overprint: f.overprint }) +} + +/// The edge two strips share, as it runs along the first: `(k, from, to)`, from its corner `k`. +fn shared_edge(a: &Strip, b: &Strip) -> Option<(usize, Point, Point)> { + let near = |p: Point, q: Point| p.distance(q) <= EPS; + for k in 0..4 { + let (p, q) = (a.corners[k], a.corners[(k + 1) % 4]); + if p.distance(q) <= EPS { + continue; + } + for j in 0..4 { + let (r, s) = (b.corners[j], b.corners[(j + 1) % 4]); + if (near(p, r) && near(q, s)) || (near(p, s) && near(q, r)) { + return Some((k, p, q)); + } + } + } + None +} + +/// Coordinates along a run of strips: a point is `o + s·w + t·v`, `v` along the strips (their +/// shared edges run from `t` = 0 to 1), `w` across them. +struct Axes { + o: Point, + w: Vec2, + v: Vec2, + inv: Affine, +} + +impl Axes { + fn of(a: &Strip, b: &Strip) -> Option { + let (k, from, to) = shared_edge(a, b)?; + let w = a.corners[(k + 2) % 4] - a.corners[(k + 1) % 4]; + let v = to - from; + let m = Affine::new([w.x, w.y, v.x, v.y, from.x, from.y]); + if w.hypot() <= EPS || m.determinant().abs() < 1e-9 * w.hypot() * v.hypot() { + return None; + } + Some(Self { o: from, w, v, inv: m.inverse() }) + } + + fn at(&self, s: f64, t: f64) -> Point { + self.o + self.w * s + self.v * t + } + + /// Does `p` lie within the run's length, and across it from `lo` to `hi`? + fn holds(&self, p: Point, lo: f64, hi: f64) -> bool { + let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot()); + let l = self.inv * p; + l.x >= lo - ss && l.x <= hi + ss && l.y >= -ts && l.y <= 1.0 + ts + } + + /// The strip's extent across the run (`s` from, to), when it spans the run's length exactly. + fn band(&self, st: &Strip) -> Option<(f64, f64)> { + let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot()); + let local: Vec = st.corners.iter().map(|p| self.inv * *p).collect(); + let lo = local.iter().map(|p| p.x).fold(f64::INFINITY, f64::min); + let hi = local.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max); + let fits = local + .iter() + .all(|p| ((p.y).abs() <= ts || (p.y - 1.0).abs() <= ts) && ((p.x - lo).abs() <= ss || (p.x - hi).abs() <= ss) && p.x.is_finite()); + // Each end of the strip touches both edges of the run. + let ends = [0.0, 1.0].iter().all(|t| local.iter().filter(|p| (p.y - t).abs() <= ts).count() == 2); + (fits && ends).then_some((lo, hi)) + } +} + +/// A run of strips found at one place in what was drawn: where it ends (exclusive), the object +/// it becomes, and whether that object covers the clip around it (so the clip path filled with +/// the gradient paints no more than the strips did). +struct Run { + start: usize, + end: usize, + node: Node, + covers_clip: bool, +} + +/// Is `s` (from, to) too thin to paint anything? +fn thin(b: (f64, f64), axes: &Axes) -> bool { + (b.1 - b.0) * axes.w.hypot() <= EPS +} + +/// The run of strips starting at `i`, if one does. +fn run_at(drawn: &Drawn, strips: &[Option], i: usize) -> Option { + let chain = |k: usize| drawn.get(k).map(|(c, _)| c.iter().map(|c| c.id).collect::>()); + let here = chain(i)?; + let strip = |k: usize| strips.get(k).and_then(Option::as_ref).filter(|_| chain(k).as_ref() == Some(&here)); + let first = strip(i)?; + // The run's axes come from its first two strips that paint something; a line before them + // (a strip of no width, as some writers draw first) joins the run where it lies on its edge. + let (lead, a, b) = match (strip(i + 1), strip(i + 2)) { + (Some(b), _) if Axes::of(first, b).is_some_and(|ax| ax.band(first).is_some_and(|f| !thin(f, &ax))) => (None, first, b), + (Some(a), Some(b)) => (Some(first), a, b), + _ => return None, + }; + let axes = Axes::of(a, b)?; + let (ba, bb) = (axes.band(a)?, axes.band(b)?); + if thin(ba, &axes) || thin(bb, &axes) { + return None; + } + let ss = EPS / axes.w.hypot(); + let forward = if (bb.0 - ba.1).abs() <= ss { + true + } else if (bb.1 - ba.0).abs() <= ss { + false + } else { + return None; + }; + let mut edge = if forward { ba.0 } else { ba.1 }; + if let Some(l) = lead { + let lb = axes.band(l)?; + if !thin(lb, &axes) || (lb.0 - edge).abs() > ss { + return None; + } + } + let at = i + usize::from(lead.is_some()); + let mut bands: Vec<(f64, f64, &Strip)> = vec![]; + let mut end = at; + while bands.len() < MAX_STITCH_STOPS { + let Some(st) = strip(end) else { break }; + let Some(band) = axes.band(st) else { break }; + let meets = if forward { (band.0 - edge).abs() <= ss } else { (band.1 - edge).abs() <= ss }; + if !meets || st.overprint != a.overprint { + break; + } + if !thin(band, &axes) { + if let Some((_, _, prev)) = bands.last() + && (prev.model != st.model || prev.comps.iter().zip(&st.comps).any(|(x, y)| (x - y).abs() > MAX_STEP)) + { + break; + } + bands.push((band.0, band.1, st)); + } + edge = if forward { band.1 } else { band.0 }; + end += 1; + } + if bands.len() < MIN_STRIPS { + return None; + } + let lo = bands.iter().map(|b| b.0).fold(f64::INFINITY, f64::min); + let hi = bands.iter().map(|b| b.1).fold(f64::NEG_INFINITY, f64::max); + if !(hi - lo).is_finite() || hi - lo <= 0.0 { + return None; + } + bands.sort_by(|x, y| x.0.total_cmp(&y.0)); + let stops: Vec<(f32, &Strip)> = bands.iter().map(|(s0, s1, st)| ((((s0 + s1) / 2.0 - lo) / (hi - lo)) as f32, *st)).collect(); + let stops: Vec = simplify(&stops).into_iter().map(|(t, st)| GradientStop::new(t, st.color)).collect(); + // The gradient runs across the strips, square to them, from the run's edge at `lo`. + let len = axes.v.hypot(); + let mut n = Vec2::new(-axes.v.y / len, axes.v.x / len); + let across = axes.w.dot(n) * (hi - lo); + if across < 0.0 { + n = -n; + } + let start = axes.at(lo, 0.0); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + g.geom = Some(GradientGeom { start, end: start + n * across.abs(), aspect: 1.0, focal: None }); + let mut bp = BezPath::new(); + bp.move_to(axes.at(lo, 0.0)); + bp.line_to(axes.at(hi, 0.0)); + bp.line_to(axes.at(hi, 1.0)); + bp.line_to(axes.at(lo, 1.0)); + bp.close_path(); + let item = AppearanceItem::Fill(FillLayer { overprint: a.overprint, ..FillLayer::new(Paint::Gradient(Box::new(g))) }); + let (clips, first_node) = drawn.get(i)?; + // Every point of a clip's path, control points included, inside the run: its curves are too + // (each lies in the hull of its points), and the run's area is convex. + let covers_clip = clips.last().and_then(|c| c.region.as_ref()).is_some_and(|(clip, _)| { + clip.elements().iter().all(|el| match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => axes.holds(p, lo, hi), + PathEl::QuadTo(a, p) => axes.holds(a, lo, hi) && axes.holds(p, lo, hi), + PathEl::CurveTo(a, b, p) => axes.holds(a, lo, hi) && axes.holds(b, lo, hi) && axes.holds(p, lo, hi), + PathEl::ClosePath => true, + }) + }); + let node = Node::path(first_node.id, PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); + Some(Run { start: i, end, node, covers_clip }) +} + +/// The stops `stops` (offset, strip) needs: those the gradient between the stops kept on either +/// side wouldn't come within [`TOLERANCE`] of are kept. +fn simplify<'a>(stops: &[(f32, &'a Strip)]) -> Vec<(f32, &'a Strip)> { + let mut kept: Vec<(f32, &Strip)> = vec![]; + let Some(&first) = stops.first() else { return kept }; + kept.push(first); + let mut from = 0; + while from + 1 < stops.len() { + // The furthest stop reachable from `from` with every stop between on the line. + let mut to = from + 1; + while to + 1 < stops.len() && on_line(stops, from, to + 1) { + to += 1; + } + if let Some(&s) = stops.get(to) { + kept.push(s); + } + from = to; + } + kept +} + +/// Do the stops between `a` and `b` lie on the blend between those two? +fn on_line(stops: &[(f32, &Strip)], a: usize, b: usize) -> bool { + let (Some((ta, sa)), Some((tb, sb))) = (stops.get(a), stops.get(b)) else { return false }; + let span = tb - ta; + stops.get(a + 1..b).unwrap_or_default().iter().all(|(t, s)| { + let f = if span > 0.0 { (t - ta) / span } else { 0.0 }; + sa.comps.iter().zip(&sb.comps).zip(&s.comps).all(|((x, y), z)| (x + (y - x) * f - z).abs() <= TOLERANCE) + }) +} + +/// Runs of strips in `drawn` become one gradient-filled object each; one that covers its clip is +/// marked in `shadings`, so a clipping group of nothing else is its clip path filled with the +/// gradient (a clip larger than the strips keeps clipping the gradient to them). +pub(crate) fn merge_strips(drawn: &mut Drawn, shadings: &mut HashSet) { + let strips: Vec> = drawn.iter().map(|(_, n)| strip_of(n)).collect(); + if strips.iter().filter(|s| s.is_some()).count() < MIN_STRIPS { + return; + } + let mut runs: Vec = vec![]; + let mut i = 0; + while i < drawn.len() { + match run_at(drawn, &strips, i) { + Some(r) => { + i = r.end.max(i + 1); + runs.push(r); + } + None => i += 1, + } + } + if runs.is_empty() { + return; + } + let old = std::mem::take(drawn); + let mut runs = runs.into_iter().peekable(); + let mut skip_to = 0; + for (k, item) in old.into_iter().enumerate() { + if k < skip_to { + continue; + } + if let Some(r) = runs.next_if(|r| r.start == k) { + skip_to = r.end; + if r.covers_clip { + shadings.insert(r.node.id); + } + drawn.push((item.0, r.node)); + continue; + } + drawn.push(item); + } +} diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 57d961181..46daf84cc 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1192,7 +1192,7 @@ fn keep_differing( } /// Copy page drawing IDs, including mask art outside the ordinary child tree. -fn reid_page_art(doc: &mut Document, source: &Node) -> Node { +pub(super) fn reid_page_art(doc: &mut Document, source: &Node) -> Node { let mut copy = doc.reid(source); if let Some(mask) = &mut copy.extras_mut().mask { mask.art = Arc::new(reid_page_art(doc, &mask.art)); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 597979855..9fd54cc25 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,4 @@ -use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page}; +use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page, reid_page_art}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -7,11 +7,11 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why, with the swatches of the -/// editing copy's palette ([`use_palette`]), its graphic styles ([`use_styles`]) and its variable -/// library ([`use_variables`]) when it could be read, and with its layer tree, empty, -/// below the page's layer when that could be read (#2033). A program in the legacy format without -/// layers (`illustrator`, see [`program_alone`]) is read with its art on one layer; when that -/// can't be used, the file is `visible` as it was. +/// editing copy's palette ([`use_palette`]), its graphic styles ([`use_styles`]), its symbols +/// ([`use_symbols`]) and its variable library ([`use_variables`]) when it could be read, and with +/// its layer tree, empty, below the page's layer when that could be read (#2033). A program in the +/// legacy format without layers (`illustrator`, see [`program_alone`]) is read with its art on one +/// layer; when that can't be used, the file is `visible` as it was. pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool) -> Imported { let Some(data) = editing_copy(ps) else { let read = illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0).ok()); @@ -38,9 +38,10 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus let mut v = visible; if let Ok(data) = &data { let have = v.document.patterns.len(); - use_palette(data, &mut v.document); + let own_palette = use_palette(data, &mut v.document); use_brushes(data, &mut v.document, have, &mut v.warnings); use_styles(data, &mut v.document, true, 1.0, None); + use_symbols(data, &mut v.document, own_palette, &mut v.warnings); use_variables(data, &mut v.document, &mut v.warnings); } if tree.is_empty() { @@ -100,10 +101,11 @@ fn layer_tree(nodes: &[Arc]) -> Vec> { /// notes, or a note saying why the layers weren't read when the editing data can't be decompressed /// or its layers can't be read. The flag returned is `true` when the document is the one read from /// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing -/// data was decompressed but its layers can't be read, and its swatches, brushes, graphic styles and -/// variables those of the editing data's palette ([`use_palette`]), brushes ([`use_brushes`]), styles -/// ([`use_styles`]) and variable library ([`use_variables`]) when it was decompressed: a graphic -/// style library, which has no layers, opens with its styles, and editing data with no layers opens +/// data was decompressed but its layers can't be read, and its swatches, brushes, graphic styles, +/// symbols and variables those of the editing data's palette ([`use_palette`]), brushes +/// ([`use_brushes`]), styles ([`use_styles`]), symbols ([`use_symbols`]) and variable library +/// ([`use_variables`]) when it was decompressed: a graphic style library or a symbol library, which +/// has no layers, opens with its styles or its symbols, and editing data with no layers opens /// on the page of its active artboard under the checks of `place_pages`. `outlined`: the PDF part's type was /// read as outlines (a file whose type shows then comes in as its PDF part). /// `trust`: layers that draw differently from the PDF part are used all the same, with a warning. @@ -140,9 +142,10 @@ pub fn layered_ai_from( visible.opened_artboard = active_page(active, &visible); } let have = visible.patterns.len(); - use_palette(&data, &mut visible); + let own_palette = use_palette(&data, &mut visible); use_brushes(&data, &mut visible, have, &mut warnings); use_styles(&data, &mut visible, false, unit, Some(&boards)); + use_symbols(&data, &mut visible, own_palette, &mut warnings); use_variables(&data, &mut visible, &mut warnings); return (visible, warnings, false); } @@ -152,10 +155,11 @@ pub fn layered_ai_from( let (boards, active) = ai::artboards(&data); place_pages(&boards, active, &mut visible, unit); let have = visible.patterns.len(); - use_palette(&data, &mut visible); + let own_palette = use_palette(&data, &mut visible); use_brushes(&data, &mut visible, have, &mut warnings); use_styles(&data, &mut visible, false, unit, Some(&boards)); let mut all = unread(warnings, why); + use_symbols(&data, &mut visible, own_palette, &mut all); use_variables(&data, &mut visible, &mut all); (visible, all, false) }; @@ -246,14 +250,14 @@ fn use_variables(data: &[u8], doc: &mut Document, warnings: &mut Vec) { /// palette doesn't have follow the palette's swatches. The patterns that the editing data defines come /// in for the palette's pattern swatches, with the images they show; one whose name a pattern of /// the PDF part already has takes a free variant of it ([`ai::palette`]). Left as -/// it is when the palette is empty. -fn use_palette(data: &[u8], doc: &mut Document) { - let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return }; +/// it is when the palette is empty. Whether the palette's swatches came in. +fn use_palette(data: &[u8], doc: &mut Document) -> bool { + let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return false }; for (key, blob) in images { doc.images.entry(key).or_insert(blob); } for mut p in patterns { - p.art = p.art.iter().map(|n| Arc::new(doc.reid(n))).collect(); + p.art = p.art.iter().map(|n| Arc::new(reid_page_art(doc, n))).collect(); doc.patterns.push(p); } let named = |name: &str| swatches.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|s| s.name == name); @@ -262,6 +266,7 @@ fn use_palette(data: &[u8], doc: &mut Document) { let mut swatches = swatches; swatches.extend(art); (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + true } /// `doc` (a file read as its page or PDF part, in place of its layers) with the brushes of its @@ -280,7 +285,7 @@ fn use_brushes(data: &[u8], doc: &mut Document, have: usize, warnings: &mut Vec< if doc.pattern(&p.name).is_some() { continue; } - p.art = p.art.iter().map(|n| Arc::new(doc.reid(n))).collect(); + p.art = p.art.iter().map(|n| Arc::new(reid_page_art(doc, n))).collect(); doc.patterns.push(p); } vectorcraft_brush::store(doc, &brushes); @@ -320,6 +325,66 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo doc.graphic_styles = styles.into(); } +/// `doc` (a file read as its page or PDF part, in place of its layers) with the symbols of its +/// editing data `data` ([`ai::symbols`]), their registration points, the images their art shows and +/// the global and spot colors it is linked to that `doc` has no such swatch for (a color whose name +/// another swatch has takes that swatch's place when it is a default swatch and not `own_palette`, +/// the swatches being those of the editing data's palette, and else comes in under a free variant +/// of the name, which the art then names, as does one whose name a color group has), as [`build`] gives a document read from the layers, and +/// the note about instances that can't be drawn added to `warnings`. An object of their art linked +/// to a graphic style keeps the link when [`use_styles`] gave `doc` the editing data's styles. The +/// symbols' art keeps the editing data's units, as in a document read from the layers: when +/// [`use_styles`] draws the styles' strokes and effects larger (a file in larger units), linked art +/// isn't scaled with them. Left as it is when `doc` has symbols already or the editing data has +/// none this reads. +fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec) { + if !doc.symbols.is_empty() { + return; + } + let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data) else { return }; + for (key, blob) in images { + doc.images.entry(key).or_insert(blob); + } + // A color whose name a swatch of another color has takes that swatch's place when it is a + // default swatch, and else a free variant of the name, as does one whose name a color group has. + let (defaults, groups) = vectorcraft_color::default_swatches(doc.color_mode.model()); + let is_default = + |have: &vectorcraft_color::Swatch| !own_palette && defaults.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|d| d == have); + let mut renamed = std::collections::HashMap::new(); + for s in swatches { + match doc.swatch(&s.name) { + None if !doc.swatch_name_taken(&s.name) => doc.swatches.push(s), + Some(have) if have == &s || (s.name.starts_with('[') && s.name.ends_with(']')) => {} + Some(have) if is_default(have) => { + if let Some(default) = doc.swatch_mut(&s.name) { + *default = s; + } + } + _ => { + let name = doc.free_swatch_name(&s.name); + renamed.insert(s.name.clone(), name.clone()); + doc.swatches.push(vectorcraft_color::Swatch { name, ..s }); + } + } + } + let mut follow = |_: &mut vectorcraft_color::Color, link: &mut Option, _: &mut f32| { + let Some(new) = link.as_ref().and_then(|l| renamed.get(l)) else { return false }; + *link = Some(new.clone()); + true + }; + for (mut symbol, registration) in symbols { + let mut art = reid_page_art(doc, &symbol.art); + if !renamed.is_empty() { + vectorcraft_doc::swatches::map_node_solid_paints(&mut art, &mut follow); + } + symbol.art = Arc::new(art); + doc.set_symbol_registration(&symbol.name, registration); + doc.symbols.push(symbol); + } + // Symbols that hold instances of themselves can't be drawn: those instances go. + doc.limit_depth_noting(warnings); +} + /// How many points a unit of `boards` (the artboards of a file's editing data, in art space) is in /// `doc` (the file read as its PDF part: a page per artboard): 1 when there are as many artboards as /// pages, each the size of its page in points, or `unit` when `unit` is above 1 and each is the size diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 601c64cb5..70e69dae1 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1462,3 +1462,89 @@ fn bitshift_by_the_most_negative_integer_does_not_panic() { let r = read("0 0 10 10 rectfill 1 -9223372036854775808 bitshift pop -1 -9223372036854775808 bitshift pop"); assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings); } + +/// The stops (offset, grey's black ink or red) and the start and end of the linear gradient `n` is +/// filled with. +fn linear_stops(n: &Node) -> (Vec<(f32, f32)>, Point, Point) { + let Some(Paint::Gradient(g)) = fill(n) else { panic!("not a gradient: {:?}", fill(n)) }; + assert_eq!(g.gradient.kind, GradientKind::Linear); + let level = |c: &Color| match *c { + Color::Gray { k } => k, + Color::Rgb { r, .. } => r, + other => panic!("{other:?}"), + }; + let geom = g.geom.unwrap(); + (g.gradient.stops.iter().map(|s| (s.offset, level(&s.color))).collect(), geom.start, geom.end) +} + +#[test] +fn abutting_strips_of_a_gradient_become_one_gradient() { + // A grey ramp drawn right to left as 40 strips 2 pt wide under a clip, after a strip of no + // width on its right edge: the clip path filled with one linear gradient, without seams. + let r = read( + "gsave 10 10 80 80 rectclip 90 10 0 80 rectfill + 0 1 39 { /i exch def i 39 div setgray 88 i 2 mul sub 10 2 80 rectfill } for grestore", + ); + let o = objects(&r.document); + assert_eq!(o.len(), 1, "{o:#?}"); + let n = &o[0]; + assert!(matches!(n.kind, NodeKind::Path { .. })); + assert!(near(bounds(n), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(n)); + let (stops, start, end) = linear_stops(n); + // A straight ramp needs only its two ends, at the middles of the end strips: black on the + // right, white on the left. + assert_eq!(stops.len(), 2, "{stops:?}"); + let x = |t: f32| start.x + (end.x - start.x) * f64::from(t); + assert!((start.y - end.y).abs() < 1e-6); + let ((t0, k0), (t1, k1)) = (stops[0], stops[1]); + // A grey's level is its black ink: 1 for black. + let (dark, light) = if k0 > k1 { ((x(t0), k0), (x(t1), k1)) } else { ((x(t1), k1), (x(t0), k0)) }; + assert!((dark.0 - 89.0).abs() < 0.01 && (dark.1 - 1.0).abs() < 1e-6, "{dark:?}"); + assert!((light.0 - 11.0).abs() < 0.01 && light.1.abs() < 1e-6, "{light:?}"); +} + +#[test] +fn strips_under_a_larger_clip_keep_the_clip() { + // The same ramp under a clip larger than the strips: the gradient fills only where the + // strips were, still clipped, not the whole clip. + let r = read( + "gsave 5 5 90 90 rectclip + 0 1 39 { /i exch def i 39 div setgray 88 i 2 mul sub 10 2 80 rectfill } for grestore", + ); + let o = objects(&r.document); + assert_eq!(o.len(), 1, "{o:#?}"); + let NodeKind::Group { children, clip: true } = &o[0].kind else { panic!("not a clipping group: {:#?}", o[0]) }; + let [_, fill] = children.as_slice() else { panic!("{children:#?}") }; + assert!(near(bounds(fill), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(fill)); + let (stops, _, _) = linear_stops(fill); + assert_eq!(stops.len(), 2, "{stops:?}"); +} + +#[test] +fn turned_strips_become_a_gradient_across_them() { + // Strips drawn in a turned space: the gradient runs square to them. + let r = read("50 50 translate 30 rotate 0 1 19 { /i exch def i 19 div 0 0 setrgbcolor i 2 mul 20 sub -20 2 40 rectfill } for"); + let o = objects(&r.document); + assert_eq!(o.len(), 1, "{o:#?}"); + let (stops, start, end) = linear_stops(&o[0]); + assert_eq!(stops.len(), 2, "{stops:?}"); + let d = end - start; + // 30° in PostScript's y-up space is −30° in the document's y-down one. + let angle = d.y.atan2(d.x).to_degrees(); + assert!((angle + 30.0).abs() < 0.01 || (angle - 150.0).abs() < 0.01, "{angle}"); + assert!((d.hypot() - 40.0).abs() < 0.01, "{}", d.hypot()); +} + +#[test] +fn abutting_blocks_of_distinct_colours_stay_apart() { + // Side by side but each a different colour (a row of swatches): not a gradient. + let blocks: String = + (0..8).map(|i| format!("{} {} {} setrgbcolor {} 10 10 80 rectfill ", i % 2, (i / 2) % 2, (i / 4) % 2, 10 + i * 10)).collect(); + assert_eq!(objects(&read(&blocks).document).len(), 8); + // Too few strips to be sure of. + let few: String = (0..5).map(|i| format!("{} setgray {} 10 2 80 rectfill ", f64::from(i) / 40.0, 10 + i * 2)).collect(); + assert_eq!(objects(&read(&few).document).len(), 5); + // Strips with gaps between them. + let gaps: String = (0..10).map(|i| format!("{} setgray {} 10 2 80 rectfill ", f64::from(i) / 40.0, 10 + i * 3)).collect(); + assert_eq!(objects(&read(&gaps).document).len(), 10); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 329c5fb86..2b53d0d3f 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1437,6 +1437,37 @@ fn an_ai_file_without_layers_keeps_the_patterns_of_its_palette() { assert!(keys.iter().all(|k| d.images.contains_key(k)), "{keys:?}"); } +/// The art of a pattern with an opacity mask that a palette swatch or a brush's art paints with, +/// in a file without layers, takes ids of the document, its mask's art included. +#[test] +fn a_pattern_of_a_file_without_layers_takes_ids_for_its_mask_art() { + let mask_art: String = symbol_square(0.0, 0.0, 4.0, 4.0).replace("0 1 1 0 1 0 0 Xa", "1 g").lines().map(|l| format!("%_{l}\n")).collect(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let hidden = |art: String| -> String { art.lines().map(|l| format!("%_{l}\n")).collect() }; + let tile = format!("{}{mask}{}", hidden(symbol_square(0.0, 0.0, 4.0, 4.0)), hidden(symbol_square(1.0, 1.0, 3.0, 3.0))); + let dots = format!("%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n{tile}E\n%AI3_EndPattern\n"); + let palette = "%AI5_BeginPalette\n0 0 Pb\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Dots)\nPc\nPB\n%AI5_EndPalette\n"; + let order = "%AI8_BeginPluginObject\n(Adobe Brush Manager Order)\n(Adobe Brush Manager Order)\n( Adobe Scatter Brush Tool/ Spray/) .\n%AI8_EndPluginObject\n"; + let spray = "%AI8_BeginPluginObject\n(Adobe Scatter Brush Tool)\n(Spray)\n(2 /Dot art/ 5 1 0 0 / 0 0.5 1.5 1 ) -\n\ + (1.2 2 0 -0.25 0.25 2 -45 30 1 1 0) .\n%AI8_EndPluginObject\n"; + let dot = "%AI8_BeginBrushPattern\n(Dot art)\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 0 m\n4 0 L\n4 4 L\nf\n%AI8_EndBrushPattern\n"; + for setup in [format!("{dots}{palette}"), format!("{dots}{dot}{order}{spray}")] { + let library = format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{setup}%%EndSetup\n%%PageTrailer\n%%Trailer\n"); + let (d, warnings, read) = crate::layered_ai(&private(&library), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + let art = d.pattern("Dots").map(|p| p.art.clone()).unwrap_or_default(); + let mut masked = art.iter().flat_map(|n| std::iter::once(n).chain(n.children().into_iter().flatten())); + assert!(masked.any(|n| n.extras().mask.is_some()), "the mask is read: {art:#?} {warnings:?}"); + let mut ids = vec![]; + for n in d.layers.iter().chain(d.patterns.iter().flat_map(|p| &p.art)) { + all_ids(n, &mut ids); + } + let unique: std::collections::HashSet<_> = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); + assert!(ids.iter().all(|id| id.0 < d.peek_next_id()), "{ids:?}"); + } +} + /// A brush library (brush objects and their art, no layers) comes in as its PDF part with the /// library's brushes in place of the default ones: Other Library lists them. #[test] @@ -1567,6 +1598,230 @@ fn a_file_whose_layers_cant_be_used_keeps_its_graphic_styles() { assert_eq!(names(&r.document), ["Thin", "Medium", "Thick"], "{:?}", r.warnings); } +/// A symbol named `name` whose art is `art` (in the symbol's space, y down), with the registration +/// point `registration` (1 to 9 from the top left, row by row), as the editing data defines it. +fn symbol_def(name: &str, art: &str, registration: u32) -> String { + format!( + "%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n{art}U\n%_/ArtDictionary :\n%_{registration} /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n" + ) +} + +/// A red square from (`x0`, `y0`) to (`x1`, `y1`), as a symbol's art. +fn symbol_square(x0: f64, y0: f64, x1: f64, y1: f64) -> String { + format!("0 1 1 0 1 0 0 Xa\n{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\nf\n") +} + +/// The symbols Dot (a square of 4 about the origin, registered at its top left), Bar (12 × 2 about +/// the origin) and Masked (a square with an opacity mask), listed as Bar, Masked, Dot. +fn symbol_defs() -> String { + let mask_art: String = symbol_square(-2.0, -2.0, 2.0, 2.0).replace("0 1 1 0 1 0 0 Xa", "1 g").lines().map(|l| format!("%_{l}\n")).collect(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let masked = format!("{}{mask}{}", symbol_square(-2.0, -2.0, 2.0, 2.0), symbol_square(-1.0, -1.0, 1.0, 1.0)); + let defs = symbol_def("Dot", &symbol_square(-2.0, -2.0, 2.0, 2.0), 1) + + &symbol_def("Bar", &symbol_square(-6.0, -1.0, 6.0, 1.0), 5) + + &symbol_def("Masked", &masked, 5); + format!("%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n%AI24_BeginSymbolList\n(Bar)\n(Masked)\n(Dot)\n%AI24_EndSymbolList\n") +} + +/// A symbol library (the symbols of [`symbol_defs`], and no layers). +fn symbol_library() -> String { + format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{}%%EndSetup\n%%PageTrailer\n%%Trailer\n", symbol_defs()) +} + +/// The ids of `n`, what it holds and the art of its opacity masks. +fn all_ids(n: &Node, out: &mut Vec) { + out.push(n.id); + if let Some(m) = n.extras().mask.as_ref() { + all_ids(&m.art, out); + } + for c in n.children().into_iter().flatten() { + all_ids(c, out); + } +} + +/// The names of the symbols of `d`, in the Symbols panel's order. +fn symbol_names(d: &Document) -> Vec<&str> { + d.symbols.iter().map(|s| s.name.as_str()).collect() +} + +/// A symbol library comes in as its PDF part with the library's symbols, in the list's order, with +/// their registration points: Other Library lists them. Their art takes ids of the document, the +/// art of an opacity mask in it included. +#[test] +fn an_ai_symbol_library_without_layers_keeps_its_symbols() { + let (d, warnings, read) = crate::layered_ai(&private(&symbol_library()), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + assert_eq!(symbol_names(&d), ["Bar", "Masked", "Dot"], "{warnings:?}"); + assert_eq!(d.symbol_registration("Dot"), vectorcraft_doc::SymbolRegistration(0)); + assert!(d.symbol_registration("Bar").is_center()); + let boxes = d.symbol_boxes(); + let bar = d.symbols.first().and_then(|s| s.art.visual_bounds_with(&boxes)); + assert_eq!(bar, Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0))); + drop(boxes); + let mut ids = vec![]; + for n in d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)) { + all_ids(n, &mut ids); + } + let masked = d.symbols.get(1).map(|s| &s.art); + assert!(masked.is_some_and(|a| a.children().into_iter().flatten().any(|c| c.extras().mask.is_some())), "the mask is read"); + let unique: std::collections::HashSet<_> = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); + assert!(warnings.is_empty(), "{warnings:?}"); + // Without symbols, the PDF part's (none) stay. + let (d, warnings, _) = crate::layered_ai(&private(&native("")), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(d.symbols.is_empty(), "{warnings:?}"); +} + +/// An EPS whose editing copy has symbols and no layers comes in as its page with those symbols. +#[test] +fn an_eps_without_layers_keeps_its_symbols() { + let r = import(&eps(&page_square(50, 50), &symbol_library())).unwrap(); + assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings); +} + +/// A `.ai` that comes in as its PDF part, and an EPS that comes in as its page, because their +/// layers can't be used (their editing data is for another page) get the symbols defined before +/// their layers. +#[test] +fn a_file_whose_layers_cant_be_used_keeps_its_symbols() { + let art = native(&format!("{}{}", symbol_defs(), layer("Art", true, &square(10, 10)))).replace(" 0 0 100 100", " 0 0 600 400"); + let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); + assert!(!read && warnings.iter().any(|w| w.contains("600 × 400")), "{warnings:?}"); + assert_eq!(symbol_names(&d), ["Bar", "Masked", "Dot"], "{warnings:?}"); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("600 × 400")), "{:?}", r.warnings); + assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings); +} + +/// A symbol library's symbols bring the spot colors their art is linked to. A color whose name a +/// swatch of the library's palette has for another color comes in under a free variant of the +/// name, which the art then names, as it does for an ink of the PDF part; without a palette, it +/// takes the place of the default swatch of that name. +#[test] +fn an_ai_symbol_librarys_symbols_keep_their_spot_colors() { + let library = |setup: &str, ink: &str| { + let art = format!("0 0 1 0 ({ink}) 1 x\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n"); + let defs = format!("{setup}%AI5_Begin_NonPrinting\n{}%AI5_End_NonPrinting--\n", symbol_def("Inked", &art, 5)); + format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{defs}%%EndSetup\n%%PageTrailer\n%%Trailer\n") + }; + let open = |text: &str| crate::layered_ai(&private(text), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + let link = |d: &Document| match d.symbols.first().and_then(|s| s.art.appearance.fill()).map(|f| &f.paint) { + Some(vectorcraft_color::Paint::Solid { swatch, .. }) => swatch.clone(), + _ => None, + }; + let ink = |d: &Document, name: &str| d.swatch(name).map(|s| (s.paint.color(), s.spot)); + let yellow = Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0)); + let (d, warnings, _) = open(&library("", "Brand Ink")); + assert_eq!(link(&d).as_deref(), Some("Brand Ink"), "{warnings:?}"); + assert_eq!(ink(&d, "Brand Ink"), Some((yellow, true))); + let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 0 1 0 Xa\n(Clash)\nPc\nPB\n%AI5_EndPalette\n"; + let (d, warnings, _) = open(&library(palette, "Clash")); + assert_eq!(link(&d).as_deref(), Some("Clash 2"), "{warnings:?}"); + assert_eq!(ink(&d, "Clash 2"), Some((yellow, true))); + assert_eq!(ink(&d, "Clash"), Some((Some(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)), false))); + let (d, warnings, _) = open(&library("", "Red")); + assert_eq!(link(&d).as_deref(), Some("Red"), "{warnings:?}"); + assert_eq!(ink(&d, "Red"), Some((yellow, true))); + assert_eq!(d.swatches.iter().filter(|s| s.name == "Red").count(), 1); + // An ink of the PDF part keeps its swatch: the library's color of that name is renamed. + let mut part = pdf_part(10.0, 86.0); + let green = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 1.0, 0.0)); + part.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: green.clone(), global: true, spot: true }); + let (d, warnings, _) = crate::layered_ai(&private(&library("", "Ink")), part, vec![], false, true, 1.0); + assert_eq!(link(&d).as_deref(), Some("Ink 2"), "{warnings:?}"); + assert_eq!(ink(&d, "Ink 2"), Some((yellow, true))); + assert_eq!(d.swatch("Ink").map(|s| &s.paint), Some(&green)); + // A color group of the PDF part keeps its name: the library's color of that name is renamed. + let mut part = pdf_part(10.0, 86.0); + part.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Kit".into(), swatches: vec![] }); + let (d, warnings, _) = crate::layered_ai(&private(&library("", "Kit")), part, vec![], false, true, 1.0); + assert_eq!(link(&d).as_deref(), Some("Kit 2"), "{warnings:?}"); + assert_eq!(ink(&d, "Kit 2"), Some((yellow, true))); + assert!(d.swatch("Kit").is_none()); +} + +/// A symbol library's symbols bring a global process color their art is filled with as a global +/// swatch that isn't an ink, and a spot color a stop of their gradient names as an ink. +#[test] +fn an_ai_symbol_librarys_symbols_keep_their_global_and_gradient_stop_colors() { + let gradient = "%AI5_BeginGradient: (Fade)\n(Fade) 0 2 Bd\n[\n\ + %_0 1 1 0 (Brand) 0 0 5 1 6 50 0 Bs\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let global = "0 0 1 0 (Process Yellow) 0 0 Xk\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n"; + let graded = "Bb\n1 (Fade) 0 0 0 4 1 0 0 1 0 0 Bg\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n0 BB\n"; + let defs = symbol_def("Global", global, 5) + &symbol_def("Graded", graded, 5); + let text = format!( + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{gradient}%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n\ + %%EndSetup\n%%PageTrailer\n%%Trailer\n" + ); + let (d, warnings, _) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert_eq!(symbol_names(&d), ["Global", "Graded"], "{warnings:?}"); + let swatch = |name: &str| d.swatch(name).map(|s| (s.paint.color(), s.global, s.spot)); + assert_eq!(swatch("Process Yellow"), Some((Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0)), true, false)), "{warnings:?}"); + assert_eq!(swatch("Brand"), Some((Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, true)), "{warnings:?}"); +} + +/// A symbol library's symbol shows its image from the document's images, and an object of a +/// symbol's art linked to a style of the library's style list links to that style as the document +/// has it. +#[test] +fn an_ai_symbol_librarys_symbols_bring_their_images_and_style_links() { + // The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). + let m = "[ 5 0 0 5 -8096 8244.5 ]"; + let raster = format!( + "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n{m} 2 1 0 Xh\n{m} 0 0 2 1 2 1 8 3 0 0 1 0 4 4 0 0\n\ + %%BeginData: 6\rXI\nABCDEF%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\n" + ); + let path = "-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\n"; + let hidden: String = path.lines().map(|l| format!("%_{l}\n")).collect(); + let styled = format!("0 Ae\nu\n0 0 0 0 0 0 1 XA\n0 J 0 j 3 w 10 M []0 d\n{path}S\nU\n{hidden}%_S\n1 (Thick) XW\n"); + let defs = format!("%AI5_Begin_NonPrinting\n{}{}%AI5_End_NonPrinting--\n", symbol_def("Pic", &raster, 5), symbol_def("Line", &styled, 5)); + let text = style_library(&defs).replacen("%%BeginSetup", "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%BeginSetup", 1); + let (d, warnings, read) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + assert_eq!(symbol_names(&d), ["Pic", "Line"], "{warnings:?}"); + let key = d.symbols.first().and_then(|s| if let NodeKind::Image(im) = &s.art.kind { Some(im.key.clone()) } else { None }); + let key = key.unwrap_or_else(|| panic!("an image: {:#?}", d.symbols.first())); + assert!(d.images.contains_key(&key), "{key}"); + let mut linked = vec![]; + let mut stack: Vec<&Node> = d.symbols.get(1).map(|s| &*s.art).into_iter().collect(); + while let Some(n) = stack.pop() { + linked.extend(n.extras().graphic_style); + stack.extend(n.children().into_iter().flatten().map(|c| &**c)); + } + let named: Vec<&str> = linked.iter().filter_map(|id| d.graphic_styles.iter().find(|g| g.id == *id)).map(|g| g.name.as_str()).collect(); + assert_eq!(named, ["Thick"], "{linked:?}"); +} + +/// The instances of a symbol library's symbols that would draw more than +/// [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`] objects deep go, with the note saying so, and a +/// document that has symbols already keeps them. +#[test] +fn an_ai_symbol_librarys_instances_drawn_too_deep_go() { + // C0 is a square, and each later symbol holds an instance of the one before it. The template + // box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). + let instance = |name: &str| format!("/SymbolInstance :\n({name}) /SymbolRef ,\n1 0 0 -1 -8091 8242 /RTransformMatrix\n;\n9 () XW\n"); + let count = vectorcraft_doc::depth::MAX_DRAWN_DEPTH + 10; + let mut defs = symbol_def("C0", &symbol_square(-2.0, -2.0, 2.0, 2.0), 5); + for i in 1..count { + defs.push_str(&symbol_def(&format!("C{i}"), &instance(&format!("C{}", i - 1)), 5)); + } + let text = format!( + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%BeginSetup\n\ + %AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n%%EndSetup\n%%PageTrailer\n%%Trailer\n" + ); + let (d, warnings, _) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert_eq!(d.symbols.len(), count, "{warnings:?}"); + let instances = d.symbols.iter().filter(|s| matches!(s.art.kind, NodeKind::SymbolInstance { .. })).count(); + assert!(instances < count - 1, "{instances}"); + assert!(warnings.contains(&vectorcraft_doc::depth::symbols_note()), "{warnings:?}"); + // A document with symbols keeps its own. + let mut part = pdf_part(10.0, 86.0); + let mine = Node::group(part.alloc_id(), vec![]); + part.symbols.push(vectorcraft_doc::Symbol { name: "Mine".into(), art: std::sync::Arc::new(mine), dynamic: false }); + let (d, warnings, _) = crate::layered_ai(&private(&text), part, vec![], false, true, 1.0); + assert_eq!(symbol_names(&d), ["Mine"], "{warnings:?}"); +} + /// A PDF part whose tiling has the name of a pattern its editing data defines (the PDF part names /// its tilings `Pattern 1`, `Pattern 2` and so on): the editing data's pattern comes in under a free /// variant of the name, and the palette's swatch and the brush's art paint with it, not with the diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index d66ab3860..3baf55f14 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -409,17 +409,18 @@ pub fn arc(a: Point, b: Point, slope: f64, closed: bool, _base_along_x: bool) -> PathData::single(SubPath::new(anchors, closed)) } -/// Logarithmic spiral approximating the reference app's Spiral tool (decay-based, `segments` quarter turns). +/// Logarithmic spiral with decay per full wind (`segments` quarter turns). pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clockwise: bool) -> PathData { let decay = (decay_percent / 100.0).clamp(0.05, 0.9999); + let step_decay = decay.powf(0.25); let dir = if clockwise { 1.0 } else { -1.0 }; - // The radius shrinks by `decay` each quarter turn: r(φ) = radius · e^(kφ) after turning φ. - let k = decay.ln() / (PI / 2.0); + // The radius shrinks by `decay` each full wind: r(φ) = radius · e^(kφ). + let k = decay.ln() / TAU; // Handle lengths per unit of the derivative below, at a segment's outer and inner end: √r // changes evenly with the curve's parameter along each quarter turn. Both are KAPPA when the // radius does not shrink, and the curve stays within 0.8% of the spiral's radius at 20% decay. - let outer = 2.0 * KAPPA * (decay.sqrt() - 1.0) / decay.ln(); - let inner = outer / decay.sqrt(); + let outer = 2.0 * KAPPA * (step_decay.sqrt() - 1.0) / step_decay.ln(); + let inner = outer / step_decay.sqrt(); let mut anchors = Vec::new(); let mut r = radius; for i in 0..=segments { @@ -431,7 +432,7 @@ pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clo let h_in = p - d * inner; let h_out = p + d * outer; anchors.push(Anchor { p, h_in: if i == 0 { p } else { h_in }, h_out: if i == segments { p } else { h_out }, kind: AnchorKind::Smooth }); - r *= decay; + r *= step_decay; } // Illustrator spirals wind inward from the outer point; reverse so the path starts at the centre end. let mut sp = SubPath::new(anchors, false); @@ -796,8 +797,17 @@ mod tests { assert!(!p.subpaths[0].closed); } + #[test] + fn spiral_decay_is_per_full_wind() { + let p = spiral(Point::ZERO, 100.0, 80.0, 8, true); + let radii: Vec<_> = p.subpaths[0].anchors.iter().map(|a| (a.p - Point::ZERO).hypot()).collect(); + assert!((radii[8] - 100.0).abs() < 1e-9); + assert!((radii[4] - 80.0).abs() < 1e-9); + assert!((radii[0] - 64.0).abs() < 1e-9); + } + /// #3437: every segment stays on the spiral through its anchors, whose radius shrinks by - /// `decay` each quarter turn, for strong and weak decay and both directions. + /// `decay` each full wind, for strong and weak decay and both directions. #[test] fn spiral_segments_follow_the_spiral() { let center = Point::new(40.0, -25.0); @@ -805,7 +815,7 @@ mod tests { for clockwise in [true, false] { let p = spiral(center, 100.0, decay, 8, clockwise); let anchors = &p.subpaths[0].anchors; - let k = (decay / 100.0_f64).ln() / (PI / 2.0); + let k = (decay / 100.0_f64).ln() / TAU; let mut worst = 0.0_f64; for pair in anchors.windows(2) { let c = kurbo::CubicBez::new(pair[0].p, pair[0].h_out, pair[1].h_in, pair[1].p); diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index 4cb1e62c2..5848c1a91 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +chrono = { workspace = true } unicode-general-category = "1" [dev-dependencies] diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 3eda7b6a6..90dc0d060 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -1,7 +1,8 @@ //! The built-in objects the interpreter itself needs (ECMA-262 5.1 section 15): `Object` and the //! basic methods of `Object.prototype`, `Function` with `Function.prototype.call`, `apply` and //! `toString`, `eval`, the error constructors and `Error.prototype.toString`, and the prototypes -//! primitive values look properties up on. The rest of the standard library is not here yet. +//! primitive values look properties up on. `String`, `Number`, `RegExp` and `Date` are in their +//! own modules; the rest of the standard library is not here yet. use std::rc::Rc; @@ -16,31 +17,32 @@ fn alloc(interp: &mut Interp, class: Class, proto: Option) -> ObjId { } /// A built-in function object. -fn native(interp: &mut Interp, name: &'static str, f: NativeFn, length: u32, kind: NativeKind, constructor: bool) -> JsResult { +pub(crate) fn native(interp: &mut Interp, name: &'static str, f: NativeFn, length: u32, kind: NativeKind, constructor: bool) -> JsResult { let proto = interp.intrinsics.function_proto; let o = alloc(interp, Class::Function(Func::Native { f, kind, constructor, name }), Some(proto)); interp.define(o, key("length"), Prop::Data { value: Value::Number(f64::from(length)), flags: 0 })?; Ok(o) } -fn method(interp: &mut Interp, on: ObjId, name: &'static str, f: NativeFn, length: u32) -> JsResult<()> { +pub(crate) fn method(interp: &mut Interp, on: ObjId, name: &'static str, f: NativeFn, length: u32) -> JsResult<()> { let m = native(interp, name, f, length, NativeKind::Plain, false)?; interp.define(on, key(name), Prop::Data { value: Value::Object(m), flags: HIDDEN }) } -fn data(interp: &mut Interp, on: ObjId, name: &str, value: Value, flags: u8) -> JsResult<()> { +pub(crate) fn data(interp: &mut Interp, on: ObjId, name: &str, value: Value, flags: u8) -> JsResult<()> { interp.define(on, key(name), Prop::Data { value, flags }) } /// Links a constructor and its prototype and makes the constructor a global. -fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, proto: ObjId) -> JsResult<()> { +pub(crate) fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, proto: ObjId) -> JsResult<()> { data(interp, ctor, "prototype", Value::Object(proto), 0)?; data(interp, proto, "constructor", Value::Object(ctor), HIDDEN)?; let global = interp.global; data(interp, global, name, Value::Object(ctor), HIDDEN) } -/// Creates the built-ins and the global object's properties. +/// Creates the built-ins and the global object's properties. The caller installs them before it +/// sets the realm's limits, so no limit stops the install. pub(crate) fn install(interp: &mut Interp) { if let Err(e) = try_install(interp) { // Installing touches only objects it has just made, so this does not happen; a realm @@ -65,7 +67,7 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { } interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto)); interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto)); - interp.intrinsics.number_proto = alloc(interp, Class::Number, Some(object_proto)); + interp.intrinsics.number_proto = alloc(interp, Class::Number(0.0), Some(object_proto)); interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean, Some(object_proto)); interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto)); let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?; @@ -98,7 +100,11 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { data(interp, function_proto, "apply", Value::Object(apply), HIDDEN)?; method(interp, function_proto, "toString", function_to_string, 0)?; - install_errors(interp, object_proto) + install_errors(interp, object_proto)?; + crate::string_builtins::install(interp)?; + crate::number_builtins::install(interp)?; + crate::regexp_builtins::install(interp)?; + crate::date_builtins::install(interp) } fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { @@ -131,7 +137,7 @@ fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { Ok(()) } -fn arg(args: &[Value], i: usize) -> Value { +pub(crate) fn arg(args: &[Value], i: usize) -> Value { args.get(i).cloned().unwrap_or(Value::Undefined) } diff --git a/crates/js/src/date_builtins.rs b/crates/js/src/date_builtins.rs new file mode 100644 index 000000000..55f9e1e81 --- /dev/null +++ b/crates/js/src/date_builtins.rs @@ -0,0 +1,845 @@ +//! `Date` (ECMA-262 5.1 section 15.9), with `getYear`, `setYear` and `toGMTString` from Annex B. +//! +//! Time values are milliseconds since 1970-01-01 UTC (section 15.9.1). `chrono` turns days into +//! calendar dates and back; past the years it represents, dates are shifted by whole 400-year +//! cycles. The current time comes from the host's clock and local time from the host's +//! time zone ([`DateHost`]): by default the system clock (none in a wasm build, where `Date.now()` +//! is NaN until the host sets one) and UTC. `Date.parse` reads the format of section 15.9.1.15 +//! and the formats `toString` and `toUTCString` write. + +use std::rc::Rc; + +use chrono::{Datelike, NaiveDate}; + +use crate::builtins::{arg, constructor, data, method, native}; +use crate::heap::{Class, NativeFn, NativeKind, Object, key}; +use crate::interp::{ErrorType, HeldBytes, Hint, Interp, JsResult}; +use crate::string_builtins::{charge_units, flat_charged, reserve_units, to_integer}; +use crate::value::{Str, Value}; + +/// The clock and time zone a realm's `Date` reads. +#[derive(Clone)] +pub(crate) struct DateHost { + /// The current time value. + pub(crate) clock: Rc f64>, + /// The offset of local time from UTC, in milliseconds, at a UTC time value. + pub(crate) offset: Rc f64>, +} + +impl Default for DateHost { + fn default() -> Self { + Self { clock: Rc::new(system_clock), offset: Rc::new(|_| 0.0) } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn system_clock() -> f64 { + match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(d) => d.as_millis() as f64, + Err(e) => -(e.duration().as_millis() as f64), + } +} + +#[cfg(target_arch = "wasm32")] +fn system_clock() -> f64 { + f64::NAN +} + +const MS_PER_DAY: f64 = 86_400_000.0; +const MS_PER_HOUR: f64 = 3_600_000.0; +const MS_PER_MINUTE: f64 = 60_000.0; +const MS_PER_SECOND: f64 = 1000.0; + +const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +// ---- section 15.9.1 ---- + +fn day(t: f64) -> f64 { + (t / MS_PER_DAY).floor() +} + +fn time_within_day(t: f64) -> f64 { + t.rem_euclid(MS_PER_DAY) +} + +/// `chrono`'s day number (0001-01-01 is day 1) of 1970-01-01. +const EPOCH_CE_DAYS: i64 = 719_163; +/// The days in 400 Gregorian years, after which the calendar repeats. +const CYCLE_DAYS: i64 = 146_097; +/// The days from 1970-01-01 to 2000-01-01, where the 400-year cycle `chrono` is asked about starts. +const DAYS_TO_2000: i64 = 10_957; + +/// The year, the month (0 to 11) and the day of the month of a day (days since 1970-01-01). +/// `chrono` computes them for the matching day of the 400-year cycle starting in 2000, and the +/// whole cycles are added back to the year, so days past `chrono`'s range have dates too. +fn civil(day: f64) -> Option<(f64, f64, f64)> { + if !day.is_finite() || day.abs() > 1e9 { + return None; + } + let d = day as i64; + let cycles = (d - DAYS_TO_2000).div_euclid(CYCLE_DAYS); + let date = NaiveDate::from_num_days_from_ce_opt(i32::try_from(d - cycles * CYCLE_DAYS + EPOCH_CE_DAYS).ok()?)?; + Some((f64::from(date.year()) + 400.0 * cycles as f64, f64::from(date.month0()), f64::from(date.day()))) +} + +/// The year of the 400-year cycle starting in 2000 that has `year`'s calendar, and the number of +/// whole cycles between the two. +fn cycle_year(year: f64) -> Option<(i32, i64)> { + if !year.is_finite() || year.abs() > 1e7 { + return None; + } + let y = year as i64; + let cycles = (y - 2000).div_euclid(400); + Some((i32::try_from(y - 400 * cycles).ok()?, cycles)) +} + +/// The day (days since 1970-01-01) on which month `month0` (0 to 11) of `year` starts. +fn first_day_of_month(year: f64, month0: u32) -> Option { + let (y, cycles) = cycle_year(year)?; + let date = NaiveDate::from_ymd_opt(y, month0 + 1, 1)?; + Some((i64::from(date.num_days_from_ce()) - EPOCH_CE_DAYS + cycles * CYCLE_DAYS) as f64) +} + +/// Month `month` (1 to 12) of `year` has a day `date`. +fn is_date(year: f64, month: f64, date: f64) -> bool { + (1.0..=12.0).contains(&month) + && (1.0..=31.0).contains(&date) + && cycle_year(year).is_some_and(|(y, _)| NaiveDate::from_ymd_opt(y, month as u32, date as u32).is_some()) +} + +fn year_from_time(t: f64) -> f64 { + civil(day(t)).map_or(f64::NAN, |(y, _, _)| y) +} + +fn month_from_time(t: f64) -> f64 { + civil(day(t)).map_or(f64::NAN, |(_, m, _)| m) +} + +fn date_from_time(t: f64) -> f64 { + civil(day(t)).map_or(f64::NAN, |(_, _, d)| d) +} + +fn week_day(t: f64) -> f64 { + (day(t) + 4.0).rem_euclid(7.0) +} + +fn hour_from_time(t: f64) -> f64 { + (t / MS_PER_HOUR).floor().rem_euclid(24.0) +} + +fn min_from_time(t: f64) -> f64 { + (t / MS_PER_MINUTE).floor().rem_euclid(60.0) +} + +fn sec_from_time(t: f64) -> f64 { + (t / MS_PER_SECOND).floor().rem_euclid(60.0) +} + +fn ms_from_time(t: f64) -> f64 { + t.rem_euclid(MS_PER_SECOND) +} + +fn make_time(h: f64, m: f64, s: f64, ms: f64) -> f64 { + if !(h.is_finite() && m.is_finite() && s.is_finite() && ms.is_finite()) { + return f64::NAN; + } + to_integer(h) * MS_PER_HOUR + to_integer(m) * MS_PER_MINUTE + to_integer(s) * MS_PER_SECOND + to_integer(ms) +} + +fn make_day(year: f64, month: f64, date: f64) -> f64 { + if !(year.is_finite() && month.is_finite() && date.is_finite()) { + return f64::NAN; + } + let (y, m, dt) = (to_integer(year), to_integer(month), to_integer(date)); + let ym = y + (m / 12.0).floor(); + // Years this far out are past every time value TimeClip keeps. + if ym.abs() > 400_000.0 { + return f64::NAN; + } + first_day_of_month(ym, m.rem_euclid(12.0) as u32).map_or(f64::NAN, |first| first + dt - 1.0) +} + +fn make_date(day: f64, time: f64) -> f64 { + if !(day.is_finite() && time.is_finite()) { + return f64::NAN; + } + day * MS_PER_DAY + time +} + +fn time_clip(t: f64) -> f64 { + if !t.is_finite() || t.abs() > 8.64e15 { f64::NAN } else { to_integer(t) } +} + +impl Interp { + /// The host's offset of local time from UTC at `utc`; an offset that is not finite or is a + /// day or more counts as 0. + fn local_offset(&self, utc: f64) -> f64 { + let o = (self.date_host.offset)(utc); + if o.is_finite() && o.abs() < MS_PER_DAY { o } else { 0.0 } + } + + /// LocalTime (section 15.9.1.9). + fn local_time(&self, t: f64) -> f64 { + t + self.local_offset(t) + } + + /// UTC (section 15.9.1.9): the time value of a local time. + fn utc(&self, t: f64) -> f64 { + if !t.is_finite() { + return f64::NAN; + } + let guess = t - self.local_offset(t); + t - self.local_offset(guess) + } + + fn now(&self) -> f64 { + time_clip((self.date_host.clock)()) + } +} + +// ---- formatting ---- + +fn year_text(y: f64) -> String { + if y < 0.0 { format!("-{:06}", -y as i64) } else { format!("{:04}", y as i64) } +} + +fn offset_text(offset_ms: f64) -> String { + let minutes = (offset_ms / MS_PER_MINUTE).round() as i64; + let sign = if minutes < 0 { '-' } else { '+' }; + format!("GMT{sign}{:02}{:02}", minutes.abs() / 60, minutes.abs() % 60) +} + +fn date_text(t: f64) -> String { + let wd = DAYS.get(week_day(t) as usize).copied().unwrap_or(""); + let mon = MONTHS.get(month_from_time(t) as usize).copied().unwrap_or(""); + format!("{wd} {mon} {:02} {}", date_from_time(t) as i64, year_text(year_from_time(t))) +} + +fn time_text(t: f64) -> String { + format!("{:02}:{:02}:{:02}", hour_from_time(t) as i64, min_from_time(t) as i64, sec_from_time(t) as i64) +} + +impl Interp { + /// `toString`'s format: `Tue Nov 07 2026 12:00:00 GMT+0100`. + fn date_to_string(&self, t: f64) -> String { + if t.is_nan() { + return "Invalid Date".into(); + } + let local = self.local_time(t); + format!("{} {} {}", date_text(local), time_text(local), offset_text(local - t)) + } +} + +fn utc_text(t: f64) -> String { + if t.is_nan() { + return "Invalid Date".into(); + } + let wd = DAYS.get(week_day(t) as usize).copied().unwrap_or(""); + let mon = MONTHS.get(month_from_time(t) as usize).copied().unwrap_or(""); + format!("{wd}, {:02} {mon} {} {} GMT", date_from_time(t) as i64, year_text(year_from_time(t)), time_text(t)) +} + +fn iso_text(t: f64) -> String { + let y = year_from_time(t); + let year = if (0.0..=9999.0).contains(&y) { + format!("{:04}", y as i64) + } else if y < 0.0 { + format!("-{:06}", -y as i64) + } else { + format!("+{:06}", y as i64) + }; + format!("{year}-{:02}-{:02}T{}.{:03}Z", month_from_time(t) as i64 + 1, date_from_time(t) as i64, time_text(t), ms_from_time(t) as i64) +} + +// ---- parsing ---- + +/// Reads the format of section 15.9.1.15: `YYYY[-MM[-DD]][THH:mm[:ss[.sss]][Z|±HH:mm]]`, with +/// six-digit signed years. A missing offset means UTC. +fn parse_iso(s: &[u8]) -> Option { + let mut i = 0; + let num = |i: &mut usize, n: usize| -> Option { + let part = s.get(*i..*i + n)?; + if !part.iter().all(u8::is_ascii_digit) { + return None; + } + *i += n; + Some(part.iter().fold(0.0, |v, d| v * 10.0 + f64::from(d - b'0'))) + }; + let year = match s.first() { + Some(b'+') | Some(b'-') => { + let neg = s.first() == Some(&b'-'); + i = 1; + let y = num(&mut i, 6)?; + if neg && y == 0.0 { + return None; + } + if neg { -y } else { y } + } + _ => num(&mut i, 4)?, + }; + let (mut month, mut date) = (1.0, 1.0); + if s.get(i) == Some(&b'-') { + i += 1; + month = num(&mut i, 2)?; + if s.get(i) == Some(&b'-') { + i += 1; + date = num(&mut i, 2)?; + } + } + let (mut h, mut m, mut sec, mut ms) = (0.0, 0.0, 0.0, 0.0); + let mut offset = 0.0; + if s.get(i) == Some(&b'T') { + i += 1; + h = num(&mut i, 2)?; + if s.get(i) != Some(&b':') { + return None; + } + i += 1; + m = num(&mut i, 2)?; + if s.get(i) == Some(&b':') { + i += 1; + sec = num(&mut i, 2)?; + if s.get(i) == Some(&b'.') { + i += 1; + let start = i; + while s.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + let digits = s.get(start..i)?; + if digits.is_empty() { + return None; + } + ms = digits.iter().take(3).fold(0.0, |v, d| v * 10.0 + f64::from(d - b'0')) * 10f64.powi(3 - digits.len().min(3) as i32); + } + } + match s.get(i) { + Some(b'Z') => i += 1, + Some(&c @ (b'+' | b'-')) => { + i += 1; + let oh = num(&mut i, 2)?; + if s.get(i) != Some(&b':') { + return None; + } + i += 1; + let om = num(&mut i, 2)?; + if oh > 23.0 || om > 59.0 { + return None; + } + offset = (oh * 60.0 + om) * MS_PER_MINUTE * if c == b'-' { -1.0 } else { 1.0 }; + } + _ => {} + } + } + if i != s.len() { + return None; + } + let valid = is_date(year, month, date) && (h < 24.0 || (h == 24.0 && m == 0.0 && sec == 0.0 && ms == 0.0)) && m < 60.0 && sec < 60.0; + if !valid { + return None; + } + let t = make_date(make_day(year, month - 1.0, date), make_time(h, m, sec, ms)) - offset; + Some(time_clip(t)) +} + +/// Reads the formats `toString` and `toUTCString` write: a weekday name, a month name, a day, a +/// year, an optional `HH:mm[:ss]` time and an optional `GMT[±hhmm]` offset, in either order of +/// day and month. Without an offset the time is local. +fn parse_text(interp: &Interp, s: &str) -> Option { + let starts = |w: &str, prefix: &str| w.get(..prefix.len()).is_some_and(|head| head.eq_ignore_ascii_case(prefix)); + let (mut month, mut date, mut year) = (None, None, None); + let (mut h, mut m, mut sec) = (0.0, 0.0, 0.0); + let mut offset: Option = None; + // The words are read one at a time, and none is copied. + let words = s.split(|c: char| c.is_whitespace() || c == ',').take_while(|w| !w.starts_with('(')).filter(|w| !w.is_empty()); + for w in words { + if DAYS.iter().any(|d| starts(w, d)) && month.is_none() && date.is_none() && w.len() >= 3 && !w.contains(':') { + continue; + } + if let Some(mi) = MONTHS.iter().position(|mo| starts(w, mo)) { + month = Some(mi as f64); + continue; + } + let zone = if starts(w, "gmt") || starts(w, "utc") { w.get(3..) } else { w.eq_ignore_ascii_case("z").then_some("") }; + if let Some(rest) = zone { + if rest.is_empty() { + offset = Some(0.0); + continue; + } + let (sign, digits) = match rest.as_bytes().first() { + Some(b'+') => (1.0, rest.get(1..)?), + Some(b'-') => (-1.0, rest.get(1..)?), + _ => return None, + }; + // Five units are enough to tell whether four digits remain without the colons. + let digits: String = digits.chars().filter(|&c| c != ':').take(5).collect(); + if digits.len() != 4 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let v: f64 = digits.parse().ok()?; + offset = Some(sign * ((v / 100.0).floor() * 60.0 + v % 100.0) * MS_PER_MINUTE); + continue; + } + if w.contains(':') { + if w.split(':').count() > 3 { + return None; + } + let parts: Vec<&str> = w.split(':').collect(); + let field = |i: usize| -> Option { parts.get(i).map(|p| p.parse::().ok()).unwrap_or(Some(0.0)) }; + if parts.len() < 2 || parts.len() > 3 || !parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) { + return None; + } + (h, m, sec) = (field(0)?, field(1)?, field(2)?); + if h > 24.0 || m > 59.0 || sec > 59.0 { + return None; + } + continue; + } + let (neg, digits) = match w.strip_prefix('-') { + Some(d) => (true, d), + None => (false, w), + }; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let v: f64 = digits.parse().ok()?; + if date.is_none() && !neg && digits.len() <= 2 { + date = Some(v); + } else if year.is_none() { + year = Some(if neg { -v } else { v }); + } else { + return None; + } + } + let (month, date, year) = (month?, date?, year?); + if !(1.0..=31.0).contains(&date) { + return None; + } + let local = make_date(make_day(year, month, date), make_time(h, m, sec, 0.0)); + let t = match offset { + Some(o) => local - o, + None => interp.utc(local), + }; + Some(time_clip(t)) +} + +/// Takes fuel for the units of `s` the parsers read. The text the parsers read is held against +/// the heap limit while they read it. +fn parse_date(interp: &mut Interp, s: &Str) -> JsResult { + let units = flat_charged(interp, s)?; + charge_units(interp, units.len())?; + let mut held = HeldBytes::new(interp); + // Each code unit becomes at most three UTF-8 bytes. + held.add(interp, units.len().saturating_mul(3))?; + let text = String::from_utf16_lossy(&units); + let trimmed = text.trim(); + Ok(parse_iso(trimmed.as_bytes()).or_else(|| parse_text(interp, trimmed)).unwrap_or(f64::NAN)) +} + +// ---- the constructor ---- + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let object_proto = interp.intrinsics.object_proto; + let proto = interp.heap.alloc_object(Object::new(Class::Date(f64::NAN), Some(object_proto))); + interp.intrinsics.date_proto = proto; + let ctor = native(interp, "Date", date_ctor, 7, NativeKind::Plain, true)?; + constructor(interp, "Date", ctor, proto)?; + method(interp, ctor, "parse", date_parse, 1)?; + method(interp, ctor, "UTC", date_utc, 7)?; + method(interp, ctor, "now", date_now, 0)?; + let methods: [(&'static str, NativeFn, u32); 44] = [ + ("toString", to_string, 0), + ("toDateString", to_date_string, 0), + ("toTimeString", to_time_string, 0), + ("toLocaleString", to_string, 0), + ("toLocaleDateString", to_date_string, 0), + ("toLocaleTimeString", to_time_string, 0), + ("valueOf", get_time, 0), + ("getTime", get_time, 0), + ("getFullYear", get_full_year, 0), + ("getUTCFullYear", get_utc_full_year, 0), + ("getMonth", get_month, 0), + ("getUTCMonth", get_utc_month, 0), + ("getDate", get_date, 0), + ("getUTCDate", get_utc_date, 0), + ("getDay", get_day, 0), + ("getUTCDay", get_utc_day, 0), + ("getHours", get_hours, 0), + ("getUTCHours", get_utc_hours, 0), + ("getMinutes", get_minutes, 0), + ("getUTCMinutes", get_utc_minutes, 0), + ("getSeconds", get_seconds, 0), + ("getUTCSeconds", get_utc_seconds, 0), + ("getMilliseconds", get_milliseconds, 0), + ("getUTCMilliseconds", get_utc_milliseconds, 0), + ("getTimezoneOffset", get_timezone_offset, 0), + ("setTime", set_time, 1), + ("setMilliseconds", set_milliseconds, 1), + ("setUTCMilliseconds", set_utc_milliseconds, 1), + ("setSeconds", set_seconds, 2), + ("setUTCSeconds", set_utc_seconds, 2), + ("setMinutes", set_minutes, 3), + ("setUTCMinutes", set_utc_minutes, 3), + ("setHours", set_hours, 4), + ("setUTCHours", set_utc_hours, 4), + ("setDate", set_date, 1), + ("setUTCDate", set_utc_date, 1), + ("setMonth", set_month, 2), + ("setUTCMonth", set_utc_month, 2), + ("setFullYear", set_full_year, 3), + ("setUTCFullYear", set_utc_full_year, 3), + ("toISOString", to_iso_string, 0), + ("toJSON", to_json, 1), + ("getYear", get_year, 0), + ("setYear", set_year, 1), + ]; + for (name, f, len) in methods { + method(interp, proto, name, f, len)?; + } + // Annex B.2.6: `toGMTString` is the same function object as `toUTCString`. + method(interp, proto, "toUTCString", to_utc_string, 0)?; + let utc = interp.get_value(&Value::Object(proto), &key("toUTCString"))?; + data(interp, proto, "toGMTString", utc, crate::heap::HIDDEN) +} + +fn text(interp: &mut Interp, s: &str) -> JsResult { + reserve_units(interp, s.len())?; + Ok(Value::String(Str::from(s))) +} + +/// The numbers of the arguments from `year` on, as `new Date(y, m, …)` and `Date.UTC` read them +/// (sections 15.9.3.1 and 15.9.4.3): the local or UTC date they describe, before TimeClip. An +/// omitted month is January, which `Date.UTC(year)` uses (section 15.9.4.3 leaves a call with +/// fewer than two arguments to the implementation). +fn date_from_parts(interp: &mut Interp, args: &[Value]) -> JsResult { + let mut n = [f64::NAN, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]; + for (i, slot) in n.iter_mut().enumerate() { + if let Some(v) = args.get(i) { + *slot = interp.to_number(v)?; + } + } + let [y, m, dt, h, min, s, ms] = n; + let yi = to_integer(y); + let yr = if !y.is_nan() && (0.0..=99.0).contains(&yi) { 1900.0 + yi } else { y }; + Ok(make_date(make_day(yr, m, dt), make_time(h, min, s, ms))) +} + +/// `Date(…)`, `new Date()`, `new Date(value)` and `new Date(y, m, …)` (sections 15.9.2 and +/// 15.9.3). +fn date_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + if !construct { + let s = interp.date_to_string(interp.now()); + return text(interp, &s); + } + let tv = match args.len() { + 0 => interp.now(), + 1 => { + let v = interp.to_primitive(&args[0], Hint::Default)?; + match &v { + Value::String(s) => parse_date(interp, s)?, + _ => time_clip(interp.to_number(&v)?), + } + } + _ => { + let local = date_from_parts(interp, args)?; + time_clip(interp.utc(local)) + } + }; + let proto = interp.intrinsics.date_proto; + Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::Date(tv), Some(proto))))) +} + +fn date_parse(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = interp.to_str(&arg(args, 0))?; + Ok(Value::Number(parse_date(interp, &s)?)) +} + +fn date_utc(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let t = date_from_parts(interp, args)?; + Ok(Value::Number(time_clip(t))) +} + +fn date_now(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Number(interp.now())) +} + +// ---- Date.prototype ---- + +/// The time value of a Date object `this`. +fn this_time(interp: &mut Interp, this: &Value) -> JsResult { + if let Some(Class::Date(t)) = this.as_object().and_then(|o| interp.heap.object(o)).map(|o| &o.class) { + return Ok(*t); + } + Err(interp.type_error("Date.prototype method called on a value that is not a Date object")) +} + +fn set_this_time(interp: &mut Interp, this: &Value, t: f64) -> JsResult { + if let Some(o) = this.as_object().and_then(|o| interp.heap.object_mut(o)) + && let Class::Date(slot) = &mut o.class + { + *slot = t; + } + Ok(Value::Number(t)) +} + +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + let s = interp.date_to_string(t); + text(interp, &s) +} + +fn to_date_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + let s = if t.is_nan() { "Invalid Date".into() } else { date_text(interp.local_time(t)) }; + text(interp, &s) +} + +fn to_time_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + let s = if t.is_nan() { + "Invalid Date".into() + } else { + let local = interp.local_time(t); + format!("{} {}", time_text(local), offset_text(local - t)) + }; + text(interp, &s) +} + +fn to_utc_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + text(interp, &utc_text(t)) +} + +/// `toISOString` (section 15.9.5.43). +fn to_iso_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + if !t.is_finite() { + return Err(interp.throw(ErrorType::Range, "invalid time value")); + } + text(interp, &iso_text(t)) +} + +/// `toJSON` (section 15.9.5.44). +fn to_json(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&this)?; + let ov = Value::Object(o); + interp.temp_roots.push(ov.clone()); + let r = (|| { + let tv = interp.to_primitive(&ov, Hint::Number)?; + if let Value::Number(n) = tv + && !n.is_finite() + { + return Ok(Value::Null); + } + let f = interp.get_value(&ov, &key("toISOString"))?; + if !interp.is_callable(&f) { + return Err(interp.type_error("toISOString is not a function")); + } + interp.call(&f, &ov, &[]) + })(); + interp.temp_roots.pop(); + r +} + +fn get_time(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + this_time(interp, &this).map(Value::Number) +} + +/// A getter: `f` of the time value, in local time when `local`, NaN for an invalid date. +fn getter(interp: &mut Interp, this: &Value, local: bool, f: fn(f64) -> f64) -> JsResult { + let t = this_time(interp, this)?; + if t.is_nan() { + return Ok(Value::Number(f64::NAN)); + } + let t = if local { interp.local_time(t) } else { t }; + Ok(Value::Number(f(t))) +} + +macro_rules! getters { + ($($name:ident, $local:expr, $f:expr;)*) => { + $(fn $name(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + getter(interp, &this, $local, $f) + })* + }; +} + +getters! { + get_full_year, true, year_from_time; + get_utc_full_year, false, year_from_time; + get_month, true, month_from_time; + get_utc_month, false, month_from_time; + get_date, true, date_from_time; + get_utc_date, false, date_from_time; + get_day, true, week_day; + get_utc_day, false, week_day; + get_hours, true, hour_from_time; + get_utc_hours, false, hour_from_time; + get_minutes, true, min_from_time; + get_utc_minutes, false, min_from_time; + get_seconds, true, sec_from_time; + get_utc_seconds, false, sec_from_time; + get_milliseconds, true, ms_from_time; + get_utc_milliseconds, false, ms_from_time; +} + +/// `getYear` (Annex B.2.4). +fn get_year(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + getter(interp, &this, true, |t| year_from_time(t) - 1900.0) +} + +fn get_timezone_offset(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + if t.is_nan() { + return Ok(Value::Number(f64::NAN)); + } + Ok(Value::Number((t - interp.local_time(t)) / MS_PER_MINUTE)) +} + +fn set_time(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + this_time(interp, &this)?; + let t = interp.to_number(&arg(args, 0))?; + set_this_time(interp, &this, time_clip(t)) +} + +/// The time fields a setter starts at: the setter sets that field and the smaller ones after it +/// from its arguments. +#[derive(Clone, Copy)] +enum TimeField { + Hour = 0, + Minute = 1, + Second = 2, + Ms = 3, +} + +/// `setHours`, `setMinutes`, `setSeconds` and `setMilliseconds`, local or UTC (sections +/// 15.9.5.28 to 15.9.5.35). +fn set_time_fields(interp: &mut Interp, this: &Value, args: &[Value], local: bool, first: TimeField, count: usize) -> JsResult { + let tv = this_time(interp, this)?; + let t = if local { interp.local_time(tv) } else { tv }; + let mut fields = [hour_from_time(t), min_from_time(t), sec_from_time(t), ms_from_time(t)]; + let first = first as usize; + for k in 0..count { + let given = k == 0 || args.get(k).is_some(); + if given { + let v = interp.to_number(&arg(args, k))?; + if let Some(slot) = fields.get_mut(first + k) { + *slot = v; + } + } + } + let [h, m, s, ms] = fields; + let date = make_date(day(t), make_time(h, m, s, ms)); + let u = time_clip(if local { interp.utc(date) } else { date }); + set_this_time(interp, this, u) +} + +/// The date fields a setter starts at. +#[derive(Clone, Copy)] +enum DateField { + Year = 0, + Month = 1, + Date = 2, +} + +/// `setFullYear`, `setMonth` and `setDate`, local or UTC (sections 15.9.5.36 to 15.9.5.41). +fn set_date_fields(interp: &mut Interp, this: &Value, args: &[Value], local: bool, first: DateField, count: usize) -> JsResult { + let tv = this_time(interp, this)?; + let mut t = if local { interp.local_time(tv) } else { tv }; + // setFullYear starts from +0 on an invalid date. + if matches!(first, DateField::Year) && t.is_nan() { + t = 0.0; + } + let mut fields = [year_from_time(t), month_from_time(t), date_from_time(t)]; + let first = first as usize; + for k in 0..count { + let given = k == 0 || args.get(k).is_some(); + if given { + let v = interp.to_number(&arg(args, k))?; + if let Some(slot) = fields.get_mut(first + k) { + *slot = v; + } + } + } + let [y, m, d] = fields; + let date = make_date(make_day(y, m, d), time_within_day(t)); + let u = time_clip(if local { interp.utc(date) } else { date }); + set_this_time(interp, this, u) +} + +macro_rules! setters { + ($($name:ident, $f:ident, $local:expr, $field:expr, $count:expr;)*) => { + $(fn $name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + $f(interp, &this, args, $local, $field, $count) + })* + }; +} + +setters! { + set_milliseconds, set_time_fields, true, TimeField::Ms, 1; + set_utc_milliseconds, set_time_fields, false, TimeField::Ms, 1; + set_seconds, set_time_fields, true, TimeField::Second, 2; + set_utc_seconds, set_time_fields, false, TimeField::Second, 2; + set_minutes, set_time_fields, true, TimeField::Minute, 3; + set_utc_minutes, set_time_fields, false, TimeField::Minute, 3; + set_hours, set_time_fields, true, TimeField::Hour, 4; + set_utc_hours, set_time_fields, false, TimeField::Hour, 4; + set_date, set_date_fields, true, DateField::Date, 1; + set_utc_date, set_date_fields, false, DateField::Date, 1; + set_month, set_date_fields, true, DateField::Month, 2; + set_utc_month, set_date_fields, false, DateField::Month, 2; + set_full_year, set_date_fields, true, DateField::Year, 3; + set_utc_full_year, set_date_fields, false, DateField::Year, 3; +} + +/// `setYear` (Annex B.2.5). +fn set_year(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let tv = this_time(interp, &this)?; + let t = if tv.is_nan() { 0.0 } else { interp.local_time(tv) }; + let y = interp.to_number(&arg(args, 0))?; + if y.is_nan() { + return set_this_time(interp, &this, f64::NAN); + } + let yi = to_integer(y); + let yr = if (0.0..=99.0).contains(&yi) { 1900.0 + yi } else { y }; + let d = make_day(yr, month_from_time(t), date_from_time(t)); + let u = time_clip(interp.utc(make_date(d, time_within_day(t)))); + set_this_time(interp, &this, u) +} + +#[cfg(test)] +mod tests { + use super::{date_from_time, first_day_of_month, iso_text, make_date, make_day, make_time, month_from_time, parse_iso, week_day, year_from_time}; + + #[test] + fn time_values_split_into_fields_and_back() { + // 2000-02-29 is a leap day; 1969-12-31 is before the epoch. + let cases: &[(f64, f64, f64, f64)] = &[(2000.0, 1.0, 29.0, 2.0), (1969.0, 11.0, 31.0, 3.0), (1970.0, 0.0, 1.0, 4.0), (-1.0, 0.0, 1.0, 5.0)]; + for &(y, m, d, wd) in cases { + let t = make_date(make_day(y, m, d), make_time(13.0, 5.0, 7.0, 9.0)); + assert_eq!((year_from_time(t), month_from_time(t), date_from_time(t), week_day(t)), (y, m, d, wd), "{y}-{m}-{d}"); + } + assert_eq!(first_day_of_month(1970.0, 0), Some(0.0)); + assert_eq!(make_day(2001.0, -1.0, 1.0), make_day(2000.0, 11.0, 1.0)); + // The ends of the time value range lie past chrono's years; whole 400-year cycles reach them. + assert_eq!(make_day(275_760.0, 8.0, 13.0), 1e8); + assert_eq!(make_day(-271_821.0, 3.0, 20.0), -1e8); + assert_eq!((year_from_time(8.64e15), month_from_time(8.64e15), date_from_time(8.64e15)), (275_760.0, 8.0, 13.0)); + assert_eq!((year_from_time(-8.64e15), month_from_time(-8.64e15), date_from_time(-8.64e15)), (-271_821.0, 3.0, 20.0)); + assert_eq!(make_day(1600.0, 1.0, 29.0) + 1.0, make_day(1600.0, 2.0, 1.0)); + assert_eq!(make_day(1900.0, 1.0, 29.0), make_day(1900.0, 2.0, 1.0)); + } + + #[test] + fn the_iso_format_reads_back_what_to_iso_string_writes() { + for t in [0.0, 951_782_400_123.0, -62_198_755_200_000.0, 8.64e15, -8.64e15] { + let text = iso_text(t); + assert_eq!(parse_iso(text.as_bytes()), Some(t), "{text}"); + } + assert_eq!(parse_iso(b"1970"), Some(0.0)); + assert_eq!(parse_iso(b"1970-01-01T00:00+01:00"), Some(-3_600_000.0)); + assert_eq!(parse_iso(b"1970-01-01T24:00:00Z"), Some(86_400_000.0)); + for bad in ["1970-13-01", "1970-02-30", "1970-01-01T24:00:01", "-000000-01-01", "1970-1-1", "1970-01-01T10", "1970-01-01 10:00"] { + assert_eq!(parse_iso(bad.as_bytes()), None, "{bad}"); + } + } +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 82268f489..69b0b4f40 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -206,10 +206,12 @@ pub(crate) enum Class { Arguments(Box), /// A `Boolean` object made by ToObject. Boolean, - /// A `Number` object made by ToObject. - Number, + /// A `Number` object and its number. + Number(f64), String(Str), - RegExp, + RegExp(Rc), + /// A `Date` object and its time value. + Date(f64), ForIn(Box), } @@ -223,9 +225,10 @@ impl Class { Class::Error => "Error", Class::Arguments(_) => "Arguments", Class::Boolean => "Boolean", - Class::Number => "Number", + Class::Number(_) => "Number", Class::String(_) => "String", - Class::RegExp => "RegExp", + Class::RegExp(_) => "RegExp", + Class::Date(_) => "Date", } } } @@ -528,6 +531,11 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u } Class::Arguments(map) => work.push(map.env.0), Class::String(s) => strings.add(s), + Class::RegExp(re) => { + if strings.seen.insert(Rc::as_ptr(re) as *const () as usize) { + strings.bytes += re.heap_bytes(); + } + } Class::ForIn(state) => { work.extend(state.object.iter().map(|o| o.0)); strings.bytes += state.keys.iter().map(|k| string_bytes(k.len())).sum::(); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 9d16046d0..9e72285f1 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -68,6 +68,7 @@ pub(crate) struct Intrinsics { pub(crate) number_proto: ObjId, pub(crate) boolean_proto: ObjId, pub(crate) regexp_proto: ObjId, + pub(crate) date_proto: ObjId, pub(crate) error_protos: [ObjId; 7], pub(crate) eval: ObjId, pub(crate) throw_type_error: ObjId, @@ -84,6 +85,7 @@ impl Intrinsics { number_proto: o, boolean_proto: o, regexp_proto: o, + date_proto: o, error_protos: [o; 7], eval: o, throw_type_error: o, @@ -112,6 +114,7 @@ impl Intrinsics { self.number_proto, self.boolean_proto, self.regexp_proto, + self.date_proto, self.eval, self.throw_type_error, ]; @@ -161,6 +164,56 @@ pub(crate) struct Interp { pub(crate) throw_site: Option, /// The last exception a run did not catch. pub(crate) last_thrown: Value, + /// The clock and time zone `Date` reads. + pub(crate) date_host: crate::date_builtins::DateHost, + /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. + pub(crate) held: Rc, +} + +/// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's +/// total still counts. +#[derive(Default)] +pub(crate) struct HeldCount { + now: std::cell::Cell, + released: std::cell::Cell, +} + +/// Bytes that native code holds in Rust memory while it works (a matcher's state and stack, a +/// replacement being built, a pattern being compiled), counted against the heap limit. They are +/// in the heap's total, and in the live total of each collection, until the holder is dropped; +/// the heap's total gives them back before the next limit check. +pub(crate) struct HeldBytes { + count: Rc, + bytes: usize, +} + +impl HeldBytes { + pub(crate) fn new(interp: &Interp) -> Self { + Self { count: interp.held.clone(), bytes: 0 } + } + + /// The bytes this holder holds. + #[cfg(test)] + pub(crate) fn bytes(&self) -> usize { + self.bytes + } + + /// Holds `n` more bytes, ending the run with the heap limit when they would not fit after a + /// collection. Everything the caller still needs must be reachable from the roots. + pub(crate) fn add(&mut self, interp: &mut Interp, n: usize) -> JsResult<()> { + interp.reserve_heap(n)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(n); + self.bytes = self.bytes.saturating_add(n); + self.count.now.set(self.count.now.get().saturating_add(n)); + Ok(()) + } +} + +impl Drop for HeldBytes { + fn drop(&mut self) { + self.count.now.set(self.count.now.get().saturating_sub(self.bytes)); + self.count.released.set(self.count.released.get().saturating_add(self.bytes)); + } } /// The least heap size a collection waits for. @@ -277,8 +330,10 @@ impl Interp { global, global_env, intrinsics: Intrinsics::placeholder(), - fuel: limits.fuel, - limits, + fuel: u64::MAX, + // The built-ins are installed without limits; the realm's limits apply from the + // first run. + limits: Limits { fuel: u64::MAX, heap_bytes: usize::MAX, frames: usize::MAX, reentry: usize::MAX }, options, interrupt: Arc::new(AtomicBool::new(false)), reentry: 0, @@ -286,8 +341,12 @@ impl Interp { source_units: 0, throw_site: None, last_thrown: Value::Undefined, + date_host: crate::date_builtins::DateHost::default(), + held: Rc::default(), }; crate::builtins::install(&mut interp); + interp.fuel = limits.fuel; + interp.limits = limits; interp.gc_threshold = interp.next_threshold(); interp } @@ -388,6 +447,7 @@ impl Interp { fn tick(&mut self) -> JsResult<()> { self.use_fuel()?; + self.settle_held(); if self.heap.bytes > self.gc_threshold { self.collect_garbage(); if self.heap.bytes > self.limits.heap_bytes { @@ -401,6 +461,7 @@ impl Interp { /// Ends the run with the heap limit when `bytes` more would not fit in it after a /// collection. Everything the caller still needs must be reachable from the roots. pub(crate) fn reserve_heap(&mut self, bytes: usize) -> JsResult<()> { + self.settle_held(); if self.heap.bytes.saturating_add(bytes) <= self.limits.heap_bytes { return Ok(()); } @@ -412,6 +473,19 @@ impl Interp { Ok(()) } + /// Takes the bytes that dropped [`HeldBytes`] released off the heap's total. + fn settle_held(&mut self) { + let released = self.held.released.replace(0); + self.heap.bytes = self.heap.bytes.saturating_sub(released); + } + + /// Collects garbage now and sets when the next collection runs. + #[cfg(test)] + pub(crate) fn collect_for_room(&mut self) { + self.collect_garbage(); + self.gc_threshold = self.next_threshold(); + } + /// Charges code compiled at run time to the heap, reserving its estimated size and `extra` /// more first. The code is not reachable from the roots yet, so a collection here leaves it /// out of the live total and the charge adds it. @@ -439,6 +513,9 @@ impl Interp { roots.objects.extend(self.intrinsics.all()); roots.envs.push(self.global_env); self.heap.collect(&roots); + // The collection counts the live total again, so bytes already released are not in it. + self.held.released.set(0); + self.heap.bytes = self.heap.bytes.saturating_add(self.held.now.get()); } /// Runs frames until the boundary frame on top returns, and returns its value. @@ -1334,16 +1411,7 @@ impl Interp { fn op_regexp(&mut self, i: u32) -> JsResult<()> { let Some((source, flags)) = self.frame()?.code.regexps.get(i as usize).cloned() else { return internal("regular expression out of range") }; - let proto = self.intrinsics.regexp_proto; - let r = self.heap.alloc_object(Object::new(Class::RegExp, Some(proto))); - let flag_units = flags.flat(); - let has = |c: u8| flag_units.contains(&u16::from(c)); - let (g, i_, m) = (has(b'g'), has(b'i'), has(b'm')); - self.define(r, key("source"), Prop::Data { value: Value::String(source), flags: 0 })?; - self.define(r, key("global"), Prop::Data { value: Value::Bool(g), flags: 0 })?; - self.define(r, key("ignoreCase"), Prop::Data { value: Value::Bool(i_), flags: 0 })?; - self.define(r, key("multiline"), Prop::Data { value: Value::Bool(m), flags: 0 })?; - self.define(r, key("lastIndex"), Prop::Data { value: Value::Number(0.0), flags: WRITABLE })?; + let r = crate::regexp_builtins::new_regexp(self, &source, &flags)?; self.push(Value::Object(r)) } @@ -1938,7 +2006,9 @@ impl Interp { #[allow(clippy::wrong_self_convention)] pub(crate) fn to_primitive(&mut self, v: &Value, hint: Hint) -> JsResult { let Value::Object(o) = v else { return Ok(v.clone()) }; - let order = if hint == Hint::String { ["toString", "valueOf"] } else { ["valueOf", "toString"] }; + // Without a hint, a Date object converts as with the String hint. + let date = hint == Hint::Default && matches!(self.heap.object(*o).map(|ob| &ob.class), Some(Class::Date(_))); + let order = if hint == Hint::String || date { ["toString", "valueOf"] } else { ["valueOf", "toString"] }; for name in order { let f = self.get_from(*o, &key(name), v)?; if self.is_callable(&f) { @@ -2012,7 +2082,7 @@ impl Interp { Value::Object(o) => return Ok(*o), Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), Value::Bool(_) => (Class::Boolean, self.intrinsics.boolean_proto), - Value::Number(_) => (Class::Number, self.intrinsics.number_proto), + Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), }; Ok(self.heap.alloc_object(Object::new(class, Some(proto)))) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 9c0c192aa..3c36efb13 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -23,9 +23,13 @@ //! verifier, and runs it on an interpreter that keeps JavaScript calls on its own frame stack, so //! deep recursion in a script does not use the native stack. Objects and environments live in an //! arena with a mark-and-sweep collector; strings built by repeated `+` are flattened and dropped -//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and a minimal -//! set of built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with -//! `Function.prototype.call` and `apply`, `eval`, and the error constructors. Every run is bounded +//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and these +//! built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with +//! `Function.prototype.call` and `apply`, `eval`, the error constructors, and `String`, `Number`, +//! `RegExp` and `Date` (sections 15.5, 15.7, 15.9 and 15.10). Regular expressions run on a +//! backtracking matcher that keeps its choice points on its own stack and takes fuel for every +//! step; `Date` reads the clock and time zone the host sets with [`Realm::set_clock`] and +//! [`Realm::set_time_zone`]. Every run is bounded //! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an //! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with //! [`RunErrorKind::Limit`], which a script's `try` cannot catch. @@ -36,14 +40,19 @@ mod builtins; mod bytecode; mod chars; mod compiler; +mod date_builtins; mod error; mod heap; mod interp; mod lexer; mod number; +mod number_builtins; mod parser; mod realm; mod regexp; +mod regexp_builtins; +mod regexp_vm; +mod string_builtins; mod token; mod value; @@ -173,3 +182,5 @@ impl LineTable { mod interp_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod text_tests; diff --git a/crates/js/src/number.rs b/crates/js/src/number.rs index 4afd48076..e7782ac8e 100644 --- a/crates/js/src/number.rs +++ b/crates/js/src/number.rs @@ -1,4 +1,7 @@ -//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names. +//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names, and the +//! formats of `Number.prototype`'s `toString(radix)`, `toFixed`, `toExponential` and +//! `toPrecision` (section 15.7.4). The fixed, exponential and precision formats round the +//! number's exact decimal value, ties away from zero, as the specification's "larger n" says. /// The string ToString gives for the number `x`: the shortest decimal digits that read back as /// `x`, laid out as section 9.8.1 says. @@ -38,9 +41,310 @@ pub fn to_string(x: f64) -> String { } } +/// The exact decimal digits of a positive finite `x`, without trailing zeros, and the position of +/// the decimal point: `x = 0.d1d2… × 10^point`. +fn exact_digits(x: f64) -> (Vec, i64) { + // A double's exact decimal expansion has at most 767 significant digits. + let sci = format!("{x:.800e}"); + let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0")); + let mut digits: Vec = mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(); + while digits.len() > 1 && digits.last() == Some(&0) { + digits.pop(); + } + (digits, exp.parse::().unwrap_or(0) + 1) +} + +/// The shortest digits that read back as a positive finite `x`, and the decimal point's position. +fn shortest_digits(x: f64) -> (Vec, i64) { + let sci = format!("{x:e}"); + let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0")); + (mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(), exp.parse::().unwrap_or(0) + 1) +} + +/// Rounds `digits` (a number `0.d1d2… × 10^point`) to its first `keep` digits, a half rounding +/// up. Returns exactly `keep` digits and the new point; `keep` may be 0 or negative, which leaves +/// either nothing or a single `1` one place up. +fn round_digits(digits: &[u8], point: i64, keep: i64) -> (Vec, i64) { + if keep < 0 { + return (Vec::new(), point); + } + let k = usize::try_from(keep).unwrap_or(usize::MAX); + if digits.len() <= k { + let mut out = digits.to_vec(); + out.resize(k, 0); + return (out, point); + } + let mut out: Vec = digits.get(..k).unwrap_or_default().to_vec(); + if digits.get(k).is_some_and(|&d| d >= 5) { + let mut i = out.len(); + loop { + if i == 0 { + out.insert(0, 1); + out.truncate(k.max(1)); + return (out, point + 1); + } + i -= 1; + match out.get_mut(i) { + Some(d) if *d == 9 => *d = 0, + Some(d) => { + *d += 1; + break; + } + None => break, + } + } + } + (out, point) +} + +fn digits_text(digits: &[u8]) -> String { + digits.iter().map(|d| char::from(b'0' + d)).collect() +} + +/// `Number.prototype.toFixed` (section 15.7.4.5) for `f` digits after the point, 0 to 20. +pub(crate) fn to_fixed(x: f64, f: usize) -> String { + if x.is_nan() { + return "NaN".into(); + } + if x.abs() >= 1e21 { + return to_string(x); + } + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let fi = i64::try_from(f).unwrap_or(0); + let n = if x == 0.0 { + vec![0] + } else { + let (digits, point) = exact_digits(x); + let (mut n, new_point) = round_digits(&digits, point, point + fi); + if n.is_empty() { + n.push(0); + } else if new_point > point { + // A carry past the first digit leaves one digit more than kept. + n.push(0); + n.truncate(usize::try_from(new_point + fi).unwrap_or(n.len())); + } + n + }; + let mut m = digits_text(&n); + let m_trim = m.trim_start_matches('0'); + m = if m_trim.is_empty() { "0".into() } else { m_trim.into() }; + if f == 0 { + return format!("{sign}{m}"); + } + if m.len() <= f { + m = format!("{}{m}", "0".repeat(f + 1 - m.len())); + } + let (int, frac) = m.split_at(m.len() - f); + format!("{sign}{int}.{frac}") +} + +/// `Number.prototype.toExponential` (section 15.7.4.6) for `f` digits after the point (0 to 20), +/// or as many as the number needs when `f` is `None`. +pub(crate) fn to_exponential(x: f64, f: Option) -> String { + if !x.is_finite() { + return to_string(x); + } + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let (digits, e) = if x == 0.0 { + (vec![0; f.unwrap_or(0) + 1], 0) + } else { + match f { + None => { + let (d, point) = shortest_digits(x); + (d, point - 1) + } + Some(f) => { + let (d, point) = exact_digits(x); + let (d, point) = round_digits(&d, point, i64::try_from(f).unwrap_or(0) + 1); + (d, point - 1) + } + } + }; + let text = digits_text(&digits); + let (first, rest) = text.split_at(1.min(text.len())); + let mantissa = if rest.is_empty() { first.to_string() } else { format!("{first}.{rest}") }; + let esign = if e < 0 { '-' } else { '+' }; + format!("{sign}{mantissa}e{esign}{}", e.abs()) +} + +/// `Number.prototype.toPrecision` (section 15.7.4.7) for `p` significant digits, 1 to 21. +pub(crate) fn to_precision(x: f64, p: usize) -> String { + if !x.is_finite() { + return to_string(x); + } + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let pi = i64::try_from(p).unwrap_or(1); + let (digits, e) = if x == 0.0 { + (vec![0; p], 0) + } else { + let (d, point) = exact_digits(x); + let (d, point) = round_digits(&d, point, pi); + (d, point - 1) + }; + let m = digits_text(&digits); + if e < -6 || e >= pi { + let (first, rest) = m.split_at(1.min(m.len())); + let mantissa = if rest.is_empty() { first.to_string() } else { format!("{first}.{rest}") }; + let esign = if e < 0 { '-' } else { '+' }; + return format!("{sign}{mantissa}e{esign}{}", e.abs()); + } + if e == pi - 1 { + return format!("{sign}{m}"); + } + if e >= 0 { + let (int, frac) = m.split_at(usize::try_from(e + 1).unwrap_or(0).min(m.len())); + return format!("{sign}{int}.{frac}"); + } + format!("{sign}0.{}{m}", "0".repeat(usize::try_from(-(e + 1)).unwrap_or(0))) +} + +/// `Number.prototype.toString(radix)` (section 15.7.4.2) for a radix of 2 to 36 other than 10: +/// the integer part exactly, and fraction digits until the number would read back unchanged. +pub(crate) fn to_radix_string(x: f64, radix: u32) -> String { + if !x.is_finite() || x == 0.0 { + return to_string(x); + } + const CHARS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let r = f64::from(radix); + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let mut integer = x.floor(); + let mut fraction = x - integer; + // Half the distance to the next double: digits past this resolution are not needed. + let next = f64::from_bits(x.to_bits() + 1); + let mut delta = (0.5 * (next - x)).max(f64::from_bits(1)); + let mut frac_digits: Vec = Vec::new(); + if fraction >= delta { + loop { + fraction *= r; + delta *= r; + let digit = fraction.floor(); + frac_digits.push(digit as u8); + fraction -= digit; + if (fraction > 0.5 || (fraction == 0.5 && (digit as u8) & 1 == 1)) && fraction + delta > 1.0 { + // Round up, carrying into the integer part when every digit is the largest. + loop { + match frac_digits.last_mut() { + None => { + integer += 1.0; + break; + } + Some(d) if u32::from(*d) + 1 == radix => { + frac_digits.pop(); + } + Some(d) => { + *d += 1; + break; + } + } + } + break; + } + if fraction < delta { + break; + } + } + } + let mut int_digits: Vec = Vec::new(); + // Digits below the double's precision are zeros. + while (integer / r) >= 9_007_199_254_740_992.0 { + integer /= r; + int_digits.push(0); + } + loop { + let rem = integer % r; + int_digits.push(rem as u8); + integer = (integer - rem) / r; + if integer <= 0.0 { + break; + } + } + let mut out = String::from(sign); + out.extend(int_digits.iter().rev().map(|&d| char::from(CHARS.get(usize::from(d)).copied().unwrap_or(b'0')))); + if !frac_digits.is_empty() { + out.push('.'); + out.extend(frac_digits.iter().map(|&d| char::from(CHARS.get(usize::from(d)).copied().unwrap_or(b'0')))); + } + out +} + #[cfg(test)] mod tests { - use super::to_string; + use super::{to_exponential, to_fixed, to_precision, to_radix_string, to_string}; + + #[test] + fn fixed_exponential_and_precision_round_the_exact_value_with_ties_away_from_zero() { + let fixed: &[(f64, usize, &str)] = &[ + (0.5, 0, "1"), + (2.5, 0, "3"), + (1.45, 1, "1.4"), + (1.255, 2, "1.25"), + (1.005, 2, "1.00"), + (0.1, 20, "0.10000000000000000555"), + (123.456, 2, "123.46"), + (-1.5, 0, "-2"), + (-0.0000001, 2, "-0.00"), + (-0.0, 2, "0.00"), + (0.0, 0, "0"), + (99.995, 2, "100.00"), + (9.5, 0, "10"), + (1e21, 2, "1e+21"), + (1000000000000000128.0, 0, "1000000000000000128"), + (f64::NAN, 2, "NaN"), + ]; + for (x, f, want) in fixed { + assert_eq!(to_fixed(*x, *f), *want, "({x}).toFixed({f})"); + } + let exp: &[(f64, Option, &str)] = &[ + (123456.0, Some(2), "1.23e+5"), + (123456.0, None, "1.23456e+5"), + (0.00015, Some(1), "1.5e-4"), + (1.25, Some(1), "1.3e+0"), + (0.0, Some(2), "0.00e+0"), + (0.0, None, "0e+0"), + (-6.9e-11, Some(1), "-6.9e-11"), + (9.99, Some(1), "1.0e+1"), + (f64::INFINITY, Some(2), "Infinity"), + ]; + for (x, f, want) in exp { + assert_eq!(to_exponential(*x, *f), *want, "({x}).toExponential({f:?})"); + } + let prec: &[(f64, usize, &str)] = &[ + (123.456, 4, "123.5"), + (0.000123, 2, "0.00012"), + (0.0000001234, 2, "1.2e-7"), + (123456.0, 2, "1.2e+5"), + (123456.0, 6, "123456"), + (0.0, 3, "0.00"), + (1.0, 1, "1"), + (99.5, 2, "1.0e+2"), + (1e21, 21, "1.00000000000000000000e+21"), + ]; + for (x, p, want) in prec { + assert_eq!(to_precision(*x, *p), *want, "({x}).toPrecision({p})"); + } + } + + #[test] + fn radix_strings_give_exact_integers_and_enough_fraction_digits() { + let cases: &[(f64, u32, &str)] = &[ + (255.0, 16, "ff"), + (255.0, 2, "11111111"), + (-255.0, 36, "-73"), + (0.5, 2, "0.1"), + (0.5, 16, "0.8"), + (3.75, 2, "11.11"), + (2f64.powi(60), 16, "1000000000000000"), + (1e21, 36, "5v1j4f4ds7c000"), + (0.1, 3, "0.0022002200220022002200220022002201"), + ]; + for (x, r, want) in cases { + assert_eq!(to_radix_string(*x, *r), *want, "({x}).toString({r})"); + } + } #[test] fn numbers_print_as_section_9_8_1_lays_them_out() { diff --git a/crates/js/src/number_builtins.rs b/crates/js/src/number_builtins.rs new file mode 100644 index 000000000..59ea12813 --- /dev/null +++ b/crates/js/src/number_builtins.rs @@ -0,0 +1,127 @@ +//! `Number` (ECMA-262 5.1 section 15.7): the constructor, its constants and the methods of +//! `Number.prototype`. The formats are in [`crate::number`]. + +use crate::builtins::{arg, constructor, data, method, native}; +use crate::heap::{Class, NativeKind, Object}; +use crate::interp::{ErrorType, Interp, JsResult}; +use crate::number; +use crate::string_builtins::{reserve_units, to_integer}; +use crate::value::{Str, Value}; + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.number_proto; + let ctor = native(interp, "Number", number_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "Number", ctor, proto)?; + let constants = [ + ("MAX_VALUE", f64::MAX), + ("MIN_VALUE", f64::from_bits(1)), + ("NaN", f64::NAN), + ("NEGATIVE_INFINITY", f64::NEG_INFINITY), + ("POSITIVE_INFINITY", f64::INFINITY), + ]; + for (name, v) in constants { + data(interp, ctor, name, Value::Number(v), 0)?; + } + method(interp, proto, "toString", to_string, 1)?; + method(interp, proto, "toLocaleString", to_locale_string, 0)?; + method(interp, proto, "valueOf", value_of, 0)?; + method(interp, proto, "toFixed", to_fixed, 1)?; + method(interp, proto, "toExponential", to_exponential, 1)?; + method(interp, proto, "toPrecision", to_precision, 1) +} + +/// `Number(value)` and `new Number(value)` (sections 15.7.1 and 15.7.2). +fn number_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + let n = match args.first() { + Some(v) => interp.to_number(v)?, + None => 0.0, + }; + if !construct { + return Ok(Value::Number(n)); + } + let proto = interp.intrinsics.number_proto; + Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::Number(n), Some(proto))))) +} + +/// The number a `Number.prototype` method works on: a number, or a Number object's number. +fn this_number(interp: &mut Interp, this: &Value, name: &str) -> JsResult { + match this { + Value::Number(n) => Ok(*n), + Value::Object(o) => match interp.heap.object(*o).map(|o| &o.class) { + Some(Class::Number(n)) => Ok(*n), + _ => Err(interp.type_error(&format!("Number.prototype.{name} was called on a value that is not a number"))), + }, + _ => Err(interp.type_error(&format!("Number.prototype.{name} was called on a value that is not a number"))), + } +} + +fn text(interp: &mut Interp, s: &str) -> JsResult { + reserve_units(interp, s.len())?; + Ok(Value::String(Str::from(s))) +} + +/// `toString(radix)` (section 15.7.4.2). +fn to_string(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let x = this_number(interp, &this, "toString")?; + let radix = match arg(args, 0) { + Value::Undefined => 10.0, + r => to_integer(interp.to_number(&r)?), + }; + if !(2.0..=36.0).contains(&radix) { + return Err(interp.throw(ErrorType::Range, "the radix must be between 2 and 36")); + } + let s = if radix == 10.0 { number::to_string(x) } else { number::to_radix_string(x, radix as u32) }; + text(interp, &s) +} + +fn to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let x = this_number(interp, &this, "toLocaleString")?; + text(interp, &number::to_string(x)) +} + +fn value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + this_number(interp, &this, "valueOf").map(Value::Number) +} + +/// `toFixed(fractionDigits)` (section 15.7.4.5). +fn to_fixed(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let x = this_number(interp, &this, "toFixed")?; + let f = to_integer(interp.to_number(&arg(args, 0))?); + if !(0.0..=20.0).contains(&f) { + return Err(interp.throw(ErrorType::Range, "toFixed() digits must be between 0 and 20")); + } + text(interp, &number::to_fixed(x, f as usize)) +} + +/// `toExponential(fractionDigits)` (section 15.7.4.6). +fn to_exponential(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let x = this_number(interp, &this, "toExponential")?; + let digits = arg(args, 0); + let f = to_integer(interp.to_number(&digits)?); + if !x.is_finite() { + return text(interp, &number::to_string(x)); + } + let f = match digits { + Value::Undefined => None, + _ if !(0.0..=20.0).contains(&f) => return Err(interp.throw(ErrorType::Range, "toExponential() digits must be between 0 and 20")), + _ => Some(f as usize), + }; + text(interp, &number::to_exponential(x, f)) +} + +/// `toPrecision(precision)` (section 15.7.4.7). +fn to_precision(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let x = this_number(interp, &this, "toPrecision")?; + let precision = arg(args, 0); + if matches!(precision, Value::Undefined) { + return text(interp, &number::to_string(x)); + } + let p = to_integer(interp.to_number(&precision)?); + if !x.is_finite() { + return text(interp, &number::to_string(x)); + } + if !(1.0..=21.0).contains(&p) { + return Err(interp.throw(ErrorType::Range, "toPrecision() argument must be between 1 and 21")); + } + text(interp, &number::to_precision(x, p as usize)) +} diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 29d3aacb2..eee516f5b 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -209,6 +209,21 @@ impl Realm { let _ = interp.define(global, key(name), Prop::Data { value: v, flags: HIDDEN }); } + /// Sets the clock `Date.now()` and `new Date()` read: the current time in milliseconds since + /// 1970-01-01 UTC. By default it is the system clock; a wasm build has none, and its default + /// gives NaN. + pub fn set_clock(&mut self, clock: impl Fn() -> f64 + 'static) { + self.interp.date_host.clock = std::rc::Rc::new(clock); + } + + /// Sets the time zone `Date` uses for local time: `offset` gives local time's offset from UTC + /// in milliseconds at a time value (milliseconds since 1970-01-01 UTC), so it can follow + /// daylight saving time. An offset that is not finite or is a day or more counts as 0. By + /// default local time is UTC. + pub fn set_time_zone(&mut self, offset: impl Fn(f64) -> f64 + 'static) { + self.interp.date_host.offset = std::rc::Rc::new(offset); + } + /// Collects garbage now and returns the heap's estimated size in bytes afterwards. pub fn collect_garbage(&mut self) -> usize { self.interp.collect_garbage(); diff --git a/crates/js/src/regexp.rs b/crates/js/src/regexp.rs index cda4f5307..ccb86b998 100644 --- a/crates/js/src/regexp.rs +++ b/crates/js/src/regexp.rs @@ -176,15 +176,8 @@ fn class_atom(pattern: &[u16], i: usize) -> Result<(Option, usize), String> _ => (Some(u32::from(b'\\')), i + 1), }, '0'..='7' => { - let mut value = 0; - let mut j = i + 1; - while j < i + 4 - && let Some(d) = pattern.get(j).and_then(|&u| char::from_u32(u32::from(u))).and_then(|ch| ch.to_digit(8)) - { - value = value * 8 + d; - j += 1; - } - (Some(value), j) + let (value, end) = crate::regexp_vm::octal(pattern, i + 1); + (Some(u32::from(value)), end) } _ => (Some(next), i + 2), }) diff --git a/crates/js/src/regexp_builtins.rs b/crates/js/src/regexp_builtins.rs new file mode 100644 index 000000000..fcafd82c5 --- /dev/null +++ b/crates/js/src/regexp_builtins.rs @@ -0,0 +1,275 @@ +//! `RegExp` (ECMA-262 5.1 section 15.10.3 to 15.10.7): the constructor, regular expression +//! literals' objects, and `exec`, `test` and `toString`. Matching is in [`crate::regexp_vm`]. + +use std::rc::Rc; + +use crate::builtins::{arg, constructor, method, native}; +use crate::heap::{Class, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, WRITABLE, key, string_bytes}; +use crate::interp::{ErrorType, HeldBytes, Interp, JsResult}; +use crate::regexp_vm::{CompileFail, Cost, Flags, Matcher, Regex, compile, parse_flags, use_fuel_n}; +use crate::string_builtins::{flat_charged, new_array, reserve_units, rooted, set_element, substring_value, to_integer}; +use crate::value::{Str, Value}; + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.regexp_proto; + // The prototype is itself a regular expression object, matching the empty string. + let empty = compile_regex(interp, &[], &[], 0)?; + if let Some(p) = interp.heap.object_mut(proto) { + p.class = Class::RegExp(empty); + } + define_flags(interp, proto, Str::from("(?:)"), false, false, false)?; + let ctor = native(interp, "RegExp", regexp_ctor, 2, NativeKind::Plain, true)?; + constructor(interp, "RegExp", ctor, proto)?; + method(interp, proto, "exec", exec, 1)?; + method(interp, proto, "test", test, 1)?; + method(interp, proto, "toString", to_string, 0) +} + +/// The object and the compiled form of a RegExp object. +pub(crate) fn regexp_of(interp: &Interp, v: &Value) -> Option<(ObjId, Rc)> { + let o = v.as_object()?; + match &interp.heap.object(o)?.class { + Class::RegExp(re) => Some((o, re.clone())), + _ => None, + } +} + +/// Checks and compiles a pattern, throwing a `SyntaxError` for a pattern or flags that are not +/// valid. The check takes one unit of fuel for each unit of the pattern. What compiling holds is held against the heap limit as it grows. The compiled form's +/// bytes are then reserved together with `extra` more and added to the heap, so a caller that +/// stores the result in an object it allocates next reserves that object here. +fn compile_regex(interp: &mut Interp, pattern: &[u16], flags: &[u16], extra: usize) -> JsResult> { + let flags: Flags = match parse_flags(flags) { + Ok(f) => f, + Err(m) => return Err(interp.throw(ErrorType::Syntax, &m)), + }; + let depth = interp.options.max_depth; + use_fuel_n(interp, pattern.len() as u64)?; + if let Err(m) = crate::regexp::check(pattern, depth) { + return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))); + } + let mut held = HeldBytes::new(interp); + let compiled = compile(pattern, flags, depth, &mut |cost| match cost { + Cost::Fuel(n) => use_fuel_n(interp, n), + Cost::Heap(n) => held.add(interp, n), + }); + drop(held); + let re = match compiled { + Ok(re) => re, + Err(CompileFail::Syntax(m)) => return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))), + Err(CompileFail::Abrupt(a)) => return Err(a), + }; + let bytes = re.heap_bytes(); + interp.reserve_heap(bytes.saturating_add(extra))?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + Ok(Rc::new(re)) +} + +/// A new RegExp object whose `source` property is `source` (section 15.10.4.1, steps 7 to 12). +fn make(interp: &mut Interp, source: Str, pattern: &[u16], flags: &[u16]) -> JsResult { + let re = compile_regex(interp, pattern, flags, OBJECT_BYTES + 5 * (PROP_BYTES + string_bytes(10)))?; + let (g, i, m) = (parse_flags(flags).unwrap_or_default().global, re.ignore_case, re.multiline); + let proto = interp.intrinsics.regexp_proto; + let r = interp.heap.alloc_object(Object::new(Class::RegExp(re), Some(proto))); + define_flags(interp, r, source, g, i, m)?; + Ok(r) +} + +/// Defines a RegExp object's `source`, `global`, `ignoreCase`, `multiline` and `lastIndex` +/// (section 15.10.7). +fn define_flags(interp: &mut Interp, r: ObjId, source: Str, g: bool, i: bool, m: bool) -> JsResult<()> { + interp.define(r, key("source"), Prop::Data { value: Value::String(source), flags: 0 })?; + interp.define(r, key("global"), Prop::Data { value: Value::Bool(g), flags: 0 })?; + interp.define(r, key("ignoreCase"), Prop::Data { value: Value::Bool(i), flags: 0 })?; + interp.define(r, key("multiline"), Prop::Data { value: Value::Bool(m), flags: 0 })?; + interp.define(r, key("lastIndex"), Prop::Data { value: Value::Number(0.0), flags: WRITABLE })?; + Ok(()) +} + +/// The object a regular expression literal evaluates to (section 7.8.5): a new RegExp object +/// each time. +pub(crate) fn new_regexp(interp: &mut Interp, source: &Str, flags: &Str) -> JsResult { + let pattern = flat_charged(interp, source)?; + let flags = flat_charged(interp, flags)?; + make(interp, source.clone(), &pattern, &flags) +} + +/// The `source` text for a pattern given to the constructor: `(?:)` for the empty pattern, and +/// `/` and line terminators escaped, so that `/source/` reads back as the same pattern. Its +/// length is reserved on the heap before it is built. +pub(crate) fn source_text(interp: &mut Interp, pattern: &[u16]) -> JsResult { + if pattern.is_empty() { + return Ok(Str::from("(?:)")); + } + let mut len = 0usize; + source_units(pattern, |u| len = len.saturating_add(u.len())); + reserve_units(interp, len)?; + let mut out = Vec::with_capacity(len); + source_units(pattern, |u| out.extend_from_slice(u)); + Ok(Str::from_units(out)) +} + +/// Passes the units of [`source_text`] for a non-empty pattern to `emit`, a piece at a time. +fn source_units(pattern: &[u16], mut emit: impl FnMut(&[u16])) { + const BACKSLASH: u16 = 0x5C; + let mut in_class = false; + let mut i = 0; + while let Some(&u) = pattern.get(i) { + match u { + 0x5C => { + // A backslash before a line terminator escapes the terminator as itself (section + // 15.10.2.10); the terminator's escape written below matches the same unit. + if pattern.get(i + 1).is_some_and(|&n| crate::chars::is_line_terminator(u32::from(n))) { + i += 1; + continue; + } + emit(pattern.get(i..i + 2).unwrap_or(&[BACKSLASH])); + i += 2; + continue; + } + 0x5B => in_class = true, + 0x5D => in_class = false, + 0x2F if !in_class => emit(&[BACKSLASH]), + _ => {} + } + match u { + 0x0A => emit(&[BACKSLASH, 0x6E]), + 0x0D => emit(&[BACKSLASH, 0x72]), + 0x2028 => emit(&[BACKSLASH, 0x75, 0x32, 0x30, 0x32, 0x38]), + 0x2029 => emit(&[BACKSLASH, 0x75, 0x32, 0x30, 0x32, 0x39]), + _ => emit(&[u]), + } + i += 1; + } +} + +/// `new RegExp(pattern, flags)` (section 15.10.4.1). +pub(crate) fn new_regexp_from(interp: &mut Interp, pattern: &Value, flags: &Value) -> JsResult { + if let Some((o, _)) = regexp_of(interp, pattern) { + if !matches!(flags, Value::Undefined) { + return Err(interp.type_error("flags cannot be given with a RegExp object")); + } + let pv = Value::Object(o); + let source = interp.get_value(&pv, &key("source"))?; + let source = interp.to_str(&source)?; + let mut f = String::new(); + for (name, c) in [("global", 'g'), ("ignoreCase", 'i'), ("multiline", 'm')] { + if interp.get_value(&pv, &key(name))?.truthy() { + f.push(c); + } + } + let units = flat_charged(interp, &source)?; + let pattern: &[u16] = if is_empty_group(&units) { &[] } else { &units }; + return make(interp, source.clone(), pattern, &f.encode_utf16().collect::>()); + } + let p = if matches!(pattern, Value::Undefined) { Str::from("") } else { interp.to_str(pattern)? }; + let f = if matches!(flags, Value::Undefined) { Str::from("") } else { interp.to_str(flags)? }; + let units = flat_charged(interp, &p)?; + let flags = flat_charged(interp, &f)?; + let source = source_text(interp, &units)?; + make(interp, source, &units, &flags) +} + +fn is_empty_group(units: &[u16]) -> bool { + units.iter().copied().eq("(?:)".encode_utf16()) +} + +/// `RegExp(pattern, flags)` and `new RegExp(pattern, flags)` (sections 15.10.3 and 15.10.4). +fn regexp_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + let (pattern, flags) = (arg(args, 0), arg(args, 1)); + if !construct && regexp_of(interp, &pattern).is_some() && matches!(flags, Value::Undefined) { + return Ok(pattern); + } + new_regexp_from(interp, &pattern, &flags).map(Value::Object) +} + +fn this_regexp(interp: &mut Interp, this: &Value, name: &str) -> JsResult { + match regexp_of(interp, this) { + Some((o, _)) => Ok(o), + None => Err(interp.type_error(&format!("RegExp.prototype.{name} was called on a value that is not a RegExp object"))), + } +} + +/// The matching steps of `exec` (section 15.10.6.2, steps 3 to 11): the positions of the match +/// and of its groups ([`crate::regexp_vm::NONE`] for a group that did not take part), or `None`. +/// Reads `lastIndex` and `global`, and sets `lastIndex` as `exec` does. +pub(crate) fn exec_raw(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult>> { + let Some((_, re)) = regexp_of(interp, &Value::Object(rx)) else { return Err(interp.type_error("not a RegExp object")) }; + let rv = Value::Object(rx); + let last_index = key("lastIndex"); + let li = interp.get_value(&rv, &last_index)?; + let li = to_integer(interp.to_number(&li)?); + let global = interp.get_value(&rv, &key("global"))?.truthy(); + let units = flat_charged(interp, s)?; + let i = if global { li } else { 0.0 }; + let found = if i < 0.0 || i > units.len() as f64 { + None + } else { + let mut m = Matcher::new(&re, interp)?; + if m.search(interp, &units, i as usize)? { Some(m.caps) } else { None } + }; + match found { + None => { + interp.put(rx, last_index, Value::Number(0.0), true)?; + Ok(None) + } + Some(caps) => { + if global { + let end = caps.get(1).copied().unwrap_or(0); + interp.put(rx, last_index, Value::Number(end as f64), true)?; + } + Ok(Some(caps)) + } + } +} + +/// `exec`'s result for `rx` on `s`: the match array, or `null`. +pub(crate) fn exec_value(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult { + rooted(interp, |interp| { + let Some(caps) = exec_raw(interp, rx, s)? else { return Ok(Value::Null) }; + let units = flat_charged(interp, s)?; + let a = new_array(interp); + interp.reserve_heap(2 * (PROP_BYTES + string_bytes(5)))?; + let index = caps.first().copied().unwrap_or(0); + interp.define(a, key("index"), Prop::Data { value: Value::Number(index as f64), flags: OPEN })?; + interp.define(a, key("input"), Prop::Data { value: Value::String(s.clone()), flags: OPEN })?; + for (n, pair) in caps.chunks(2).enumerate() { + let v = match pair { + [x, y] if *x != crate::regexp_vm::NONE && *y != crate::regexp_vm::NONE => substring_value(interp, &units, *x, *y)?, + _ => Value::Undefined, + }; + set_element(interp, a, n, v)?; + } + Ok(Value::Object(a)) + }) +} + +/// `RegExp.prototype.exec` (section 15.10.6.2). +fn exec(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let rx = this_regexp(interp, &this, "exec")?; + let s = interp.to_str(&arg(args, 0))?; + exec_value(interp, rx, &s) +} + +/// `RegExp.prototype.test` (section 15.10.6.3). +fn test(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let rx = this_regexp(interp, &this, "test")?; + let s = interp.to_str(&arg(args, 0))?; + Ok(Value::Bool(exec_raw(interp, rx, &s)?.is_some())) +} + +/// `RegExp.prototype.toString` (section 15.10.6.4). +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let rx = this_regexp(interp, &this, "toString")?; + let rv = Value::Object(rx); + let source = interp.get_value(&rv, &key("source"))?; + let source = interp.to_str(&source)?; + let mut flags = String::from("/"); + for (name, c) in [("global", 'g'), ("ignoreCase", 'i'), ("multiline", 'm')] { + if interp.get_value(&rv, &key(name))?.truthy() { + flags.push(c); + } + } + let head = interp.concat(&Str::from("/"), &source)?; + interp.concat(&head, &Str::from(flags.as_str())).map(Value::String) +} diff --git a/crates/js/src/regexp_vm.rs b/crates/js/src/regexp_vm.rs new file mode 100644 index 000000000..476758bd6 --- /dev/null +++ b/crates/js/src/regexp_vm.rs @@ -0,0 +1,1328 @@ +//! Compiling and matching regular expressions (ECMA-262 5.1 section 15.10.2, with the lenient +//! patterns of Annex B that [`crate::regexp::check`] accepts). +//! +//! A pattern compiles to a list of instructions for a backtracking matcher. The compiler reads the +//! pattern with a loop and a stack of open groups, and the matcher keeps its choice points and the +//! values it must restore on its own stack, so neither recurses however deeply the groups nest. +//! Quantifiers count their iterations in registers instead of repeating their atom's code. Every +//! instruction run, every capture slot cleared, every choice point taken back and every 16 units a +//! backreference compares cost fuel, and +//! the compiler's work, the matcher's state and its stack are held against the heap limit, so a +//! pattern that backtracks without end stops at a limit. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use crate::LimitKind; +use crate::interp::{Abrupt, HeldBytes, Interp, JsResult}; + +/// A capture position that is not set. +pub(crate) const NONE: usize = usize::MAX; + +/// Steps the matcher counts before it takes them from the fuel. +const FUEL_BATCH: u64 = 1024; + +#[derive(Clone, Copy, Debug)] +enum Inst { + /// One code unit, already canonicalized when the pattern ignores case. + Char(u16), + /// `.`: any unit but a line terminator. + Any, + Set(u32), + LineStart, + LineEnd, + /// `\b` (true) or `\B` (false). + WordBoundary(bool), + /// Goes on at the first offset, and at the second when that fails. + Split(i32, i32), + Jump(i32), + GroupStart(u32), + GroupEnd(u32), + /// Sets a quantifier's iteration count to zero. + RepInit(u32), + /// A quantifier's head: its atom follows, then [`Inst::RepEnd`]. `caps` is the range of groups + /// inside the atom, cleared before each iteration; `exit` leads past the atom. + Rep { + reg: u32, + min: u64, + max: u64, + greedy: bool, + caps: (u32, u32), + exit: i32, + }, + RepEnd { + reg: u32, + head: i32, + }, + /// A lookahead: its pattern follows, then [`Inst::LookEnd`]; `end` leads past it. + Look { + negative: bool, + end: i32, + }, + LookEnd, + BackRef(u32), + Match, +} + +/// A character class: sorted, merged ranges of code units. +#[derive(Clone, Debug, Default)] +struct CharSet { + ranges: Vec<(u16, u16)>, + negated: bool, +} + +impl CharSet { + fn contains_raw(&self, u: u16) -> bool { + let i = self.ranges.partition_point(|&(_, hi)| hi < u); + self.ranges.get(i).is_some_and(|&(lo, _)| lo <= u) + } +} + +/// The flags of a regular expression (section 15.10.4.1). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Flags { + pub(crate) global: bool, + pub(crate) ignore_case: bool, + pub(crate) multiline: bool, +} + +/// Reads flags: each of `g`, `i` and `m` at most once. +pub(crate) fn parse_flags(units: &[u16]) -> Result { + let mut f = Flags::default(); + for &u in units { + let slot = match u { + 0x67 => &mut f.global, + 0x69 => &mut f.ignore_case, + 0x6D => &mut f.multiline, + _ => return Err(format!("invalid regular expression flag '{}'", String::from_utf16_lossy(&[u]))), + }; + if *slot { + return Err(format!("regular expression flag '{}' given twice", String::from_utf16_lossy(&[u]))); + } + *slot = true; + } + Ok(f) +} + +/// A compiled regular expression. +pub(crate) struct Regex { + insts: Vec, + sets: Vec, + /// Capturing groups, not counting the whole match. + pub(crate) groups: usize, + loops: usize, + pub(crate) ignore_case: bool, + pub(crate) multiline: bool, +} + +/// Why a pattern did not compile. +pub(crate) enum CompileFail { + Syntax(String), + Abrupt(Abrupt), +} + +impl From for CompileFail { + fn from(s: String) -> Self { + CompileFail::Syntax(s) + } +} + +impl From<&str> for CompileFail { + fn from(s: &str) -> Self { + CompileFail::Syntax(s.into()) + } +} + +/// What compiling takes as it goes: fuel for the work done, or heap bytes for what it holds. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Cost { + Fuel(u64), + Heap(usize), +} + +impl Regex { + /// The bytes the compiled form holds, counting the capacity of each list. + pub(crate) fn heap_bytes(&self) -> usize { + let sets = self.sets.iter().map(set_bytes).fold(0usize, usize::saturating_add); + size_of::() + .saturating_add(size_of::().saturating_mul(self.insts.capacity())) + .saturating_add(size_of::().saturating_mul(self.sets.capacity())) + .saturating_add(sets) + } + + fn canon(&self, u: u16) -> u16 { + if self.ignore_case { canonicalize(u) } else { u } + } + + fn set_matches(&self, set: &CharSet, u: u16) -> bool { + let found = if self.ignore_case { + let k = canonicalize(u); + match case_groups().get(&k) { + Some(group) => group.iter().any(|&c| set.contains_raw(c)), + None => set.contains_raw(u), + } + } else { + set.contains_raw(u) + }; + found != set.negated + } +} + +// ---- characters ---- + +fn is_line_terminator(u: u16) -> bool { + crate::chars::is_line_terminator(u32::from(u)) +} + +fn is_word_char(u: u16) -> bool { + matches!(u, 0x30..=0x39 | 0x41..=0x5A | 0x5F | 0x61..=0x7A) +} + +/// Canonicalize (section 15.10.2.8): the unit's upper case when that is one unit, unless it +/// would turn a unit from 128 up into one below 128. +fn canonicalize(u: u16) -> u16 { + static TABLE: OnceLock> = OnceLock::new(); + let table = TABLE.get_or_init(|| (0..=u16::MAX).map(upper_unit).collect()); + table.get(usize::from(u)).copied().unwrap_or(u) +} + +fn upper_unit(u: u16) -> u16 { + let Some(c) = char::from_u32(u32::from(u)) else { return u }; + let mut upper = c.to_uppercase(); + match (upper.next(), upper.next()) { + (Some(up), None) => match u16::try_from(u32::from(up)) { + Ok(cu) if !(u >= 128 && cu < 128) => cu, + _ => u, + }, + _ => u, + } +} + +/// For each canonical unit that more than one unit canonicalizes to, those units. +fn case_groups() -> &'static HashMap> { + static GROUPS: OnceLock>> = OnceLock::new(); + GROUPS.get_or_init(|| { + let mut groups: HashMap> = HashMap::new(); + for u in 0..=u16::MAX { + let k = canonicalize(u); + if k != u { + groups.entry(k).or_default().push(u); + } + } + for (k, members) in &mut groups { + if canonicalize(*k) == *k { + members.push(*k); + } + } + groups + }) +} + +fn digit_ranges() -> Vec<(u16, u16)> { + vec![(0x30, 0x39)] +} + +fn word_ranges() -> Vec<(u16, u16)> { + vec![(0x30, 0x39), (0x41, 0x5A), (0x5F, 0x5F), (0x61, 0x7A)] +} + +/// White space and line terminators (`\s`). +fn space_ranges() -> Vec<(u16, u16)> { + let mut out: Vec<(u16, u16)> = Vec::new(); + for u in 0..=u16::MAX { + if crate::value::is_str_space(u) { + match out.last_mut() { + Some((_, hi)) if u32::from(*hi) + 1 == u32::from(u) => *hi = u, + _ => out.push((u, u)), + } + } + } + out +} + +fn complement(ranges: &[(u16, u16)]) -> Vec<(u16, u16)> { + let mut out = Vec::new(); + let mut next: u32 = 0; + for &(lo, hi) in ranges { + if u32::from(lo) > next { + out.push((next as u16, lo - 1)); + } + next = u32::from(hi) + 1; + } + if next <= 0xFFFF { + out.push((next as u16, 0xFFFF)); + } + out.shrink_to_fit(); + out +} + +/// Sorts and merges ranges. +fn normalize(mut ranges: Vec<(u16, u16)>) -> Vec<(u16, u16)> { + ranges.sort_unstable(); + let mut out: Vec<(u16, u16)> = Vec::with_capacity(ranges.len()); + for (lo, hi) in ranges { + match out.last_mut() { + Some((_, last)) if u32::from(lo) <= u32::from(*last) + 1 => *last = (*last).max(hi), + _ => out.push((lo, hi)), + } + } + out.shrink_to_fit(); + out +} + +/// The ranges of a class escape letter (`d`, `D`, `s`, `S`, `w` or `W`). +fn escape_ranges(letter: u16) -> Option> { + static SPACE: OnceLock> = OnceLock::new(); + let space = || SPACE.get_or_init(space_ranges).clone(); + Some(match letter { + 0x64 => digit_ranges(), + 0x44 => complement(&digit_ranges()), + 0x73 => space(), + 0x53 => complement(&space()), + 0x77 => word_ranges(), + 0x57 => complement(&word_ranges()), + _ => return None, + }) +} + +// ---- compiling ---- + +#[derive(Clone, Copy)] +enum Kind { + Top, + Capture(u32), + Group, + Look(bool), +} + +/// A piece of code being compiled: one instruction, or a finished fragment placed whole, so that +/// closing a group or quantifying it moves one item however much code it holds. +#[derive(Clone, Copy)] +enum Item { + Inst(Inst), + Frag(usize), +} + +/// The finished fragments, with the number of instructions each one flattens to. +#[derive(Default)] +struct Frags { + items: Vec>, + lens: Vec, +} + +impl Frags { + fn len_of(&self, items: &[Item]) -> usize { + items + .iter() + .map(|i| match i { + Item::Inst(_) => 1, + Item::Frag(f) => self.lens.get(*f).copied().unwrap_or(0), + }) + .fold(0usize, usize::saturating_add) + } + + fn add(&mut self, items: Vec) -> Item { + let len = self.len_of(&items); + self.items.push(items); + self.lens.push(len); + Item::Frag(self.items.len() - 1) + } + + /// The instructions of `top` with every fragment in place, written with a loop, with room for + /// one more. Its stack holds at most one entry for each fragment and two more, so it never + /// grows past [`flatten_bytes`]. + fn flatten(&self, top: &[Item]) -> Vec { + let mut out = Vec::with_capacity(self.len_of(top).saturating_add(1)); + let mut stack: Vec<(&[Item], usize)> = Vec::with_capacity(self.items.len().saturating_add(2)); + stack.push((top, 0)); + while let Some((items, i)) = stack.pop() { + let Some(item) = items.get(i) else { continue }; + stack.push((items, i + 1)); + match item { + Item::Inst(inst) => out.push(*inst), + Item::Frag(f) => { + if let Some(child) = self.items.get(*f) { + stack.push((child, 0)); + } + } + } + } + out + } +} + +struct Ctx { + kind: Kind, + alts: Vec>, + cur: Vec, + /// The last term, when it may take a quantifier: where it starts in `cur` and how many groups + /// were opened before it. + atom: Option<(usize, u32)>, + /// Where the group starts in its parent's `cur` and how many groups were opened before it. + opened_at: (usize, u32), +} + +impl Ctx { + fn new(kind: Kind, opened_at: (usize, u32)) -> Self { + Self { kind, alts: Vec::new(), cur: Vec::new(), atom: None, opened_at } + } +} + +/// Adds `inst` to `ctx`. +fn emit(ctx: &mut Ctx, inst: Inst) { + ctx.cur.push(Item::Inst(inst)); +} + +/// The bytes of one item of code being compiled. +pub(crate) const ITEM_BYTES: usize = size_of::(); + +/// The bytes of one instruction of compiled code. +pub(crate) const INST_BYTES: usize = size_of::(); + +/// Charges `bytes` that compiling holds. +fn hold(bytes: usize, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(), CompileFail> { + if bytes == 0 { + return Ok(()); + } + charge(Cost::Heap(bytes)).map_err(CompileFail::Abrupt) +} + +/// Makes room in `list` for `extra` more entries. When it must grow, its capacity at least +/// doubles, and the bytes of the added capacity are charged before it is reserved. +fn room(list: &mut Vec, extra: usize, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(), CompileFail> { + let need = list.len().saturating_add(extra); + let cap = list.capacity(); + if need <= cap { + return Ok(()); + } + let new_cap = need.max(cap.saturating_mul(2)); + hold(size_of::().saturating_mul(new_cap - cap), charge)?; + list.reserve_exact(new_cap - list.len()); + Ok(()) +} + +/// The bytes `join` holds for `alts` alternatives besides the fragments it adds: the code that +/// joins them and their lengths. +fn join_bytes(alts: usize) -> usize { + if alts <= 1 { + return 0; + } + alts.saturating_mul(2 * ITEM_BYTES + size_of::()) +} + +/// The bytes flattening holds: `instructions` and one more, and its stack for `fragments`. +fn flatten_bytes(instructions: usize, fragments: usize) -> usize { + INST_BYTES + .saturating_mul(instructions.saturating_add(1)) + .saturating_add(size_of::<(&[Item], usize)>().saturating_mul(fragments.saturating_add(2))) +} + +fn offset(n: usize) -> Result { + i32::try_from(n).map_err(|_| CompileFail::Syntax("regular expression too large".into())) +} + +/// The alternatives of a disjunction as one piece of code. +fn join(mut alts: Vec>, frags: &mut Frags) -> Result, CompileFail> { + if alts.len() == 1 { + return Ok(alts.pop().unwrap_or_default()); + } + let lens: Vec = alts.iter().map(|a| frags.len_of(a)).collect(); + let total = lens.iter().sum::() + 2 * alts.len().saturating_sub(1); + let last = alts.len().saturating_sub(1); + let mut out = Vec::with_capacity(2 * alts.len()); + let mut at = 0usize; + for (i, (a, len)) in alts.into_iter().zip(lens).enumerate() { + if i == last { + out.push(frags.add(a)); + } else { + out.push(Item::Inst(Inst::Split(1, offset(len + 2)?))); + out.push(frags.add(a)); + at += len + 1; + out.push(Item::Inst(Inst::Jump(offset(total - at)?))); + at += 1; + } + } + Ok(out) +} + +/// The number of capturing groups in `pattern`. +fn count_groups(pattern: &[u16]) -> usize { + let mut i = 0; + let mut n = 0; + let mut in_class = false; + while let Some(&u) = pattern.get(i) { + match u { + 0x5C => i += 1, + 0x5B => in_class = true, + 0x5D => in_class = false, + 0x28 if !in_class && pattern.get(i + 1) != Some(&0x3F) => n += 1, + _ => {} + } + i += 1; + } + n +} + +fn hex_digits(pattern: &[u16], at: usize, n: usize) -> Option { + let mut v: u32 = 0; + for k in 0..n { + let d = crate::chars::hex_value(u32::from(*pattern.get(at + k)?))?; + v = v * 16 + d; + } + u16::try_from(v).ok() +} + +/// A legacy octal escape (Annex B) whose first digit is at `at`: its value and the position after +/// it. [`crate::regexp::check`] reads class escapes with it too. +pub(crate) fn octal(pattern: &[u16], at: usize) -> (u16, usize) { + let digit = |i: usize| pattern.get(i).filter(|&&u| (0x30..=0x37).contains(&u)).map(|&u| u - 0x30); + let Some(first) = digit(at) else { return (0, at) }; + let max = if first <= 3 { 3 } else { 2 }; + let mut value = first; + let mut i = at + 1; + while i < at + max + && let Some(d) = digit(i) + { + value = value * 8 + d; + i += 1; + } + (value, i) +} + +/// A `{n}`, `{n,}` or `{n,m}` quantifier whose `{` is at `i`: its bounds and the position after it. +fn braces(pattern: &[u16], i: usize) -> Option<(u64, u64, usize)> { + let digits = |mut j: usize| -> Option<(u64, usize)> { + let start = j; + let mut v: u64 = 0; + while let Some(d) = pattern.get(j).filter(|&&u| (0x30..=0x39).contains(&u)) { + v = v.saturating_mul(10).saturating_add(u64::from(d - 0x30)); + j += 1; + } + (j > start).then_some((v, j)) + }; + let (min, mut j) = digits(i + 1)?; + let max = if pattern.get(j) == Some(&0x2C) { + j += 1; + match digits(j) { + Some((max, end)) => { + j = end; + max + } + None => u64::MAX, + } + } else { + min + }; + (pattern.get(j) == Some(&0x7D)).then_some((min, max, j + 1)) +} + +/// A class atom: one unit, or the ranges of a class escape. +enum ClassAtom { + Unit(u16), + Ranges(Vec<(u16, u16)>), +} + +/// The class atom at `i` and the position after it. +fn class_atom(pattern: &[u16], i: usize) -> Result<(ClassAtom, usize), CompileFail> { + let unit = *pattern.get(i).ok_or("unterminated character class")?; + if unit != 0x5C { + return Ok((ClassAtom::Unit(unit), i + 1)); + } + let next = *pattern.get(i + 1).ok_or("\\ at end of pattern")?; + if let Some(r) = escape_ranges(next) { + return Ok((ClassAtom::Ranges(r), i + 2)); + } + let unit = |u: u16, end: usize| Ok((ClassAtom::Unit(u), end)); + match next { + 0x62 => unit(0x08, i + 2), + 0x66 => unit(0x0C, i + 2), + 0x6E => unit(0x0A, i + 2), + 0x72 => unit(0x0D, i + 2), + 0x74 => unit(0x09, i + 2), + 0x76 => unit(0x0B, i + 2), + 0x78 => hex_digits(pattern, i + 2, 2).map_or(unit(next, i + 2), |v| unit(v, i + 4)), + 0x75 => hex_digits(pattern, i + 2, 4).map_or(unit(next, i + 2), |v| unit(v, i + 6)), + 0x63 => match pattern.get(i + 2) { + Some(&l) if matches!(l, 0x30..=0x39 | 0x41..=0x5A | 0x61..=0x7A | 0x5F) => unit(l % 32, i + 3), + _ => unit(0x5C, i + 1), + }, + 0x30..=0x37 => { + let (v, end) = octal(pattern, i + 1); + unit(v, end) + } + _ => unit(next, i + 2), + } +} + +/// Counts `n` units of work in `work`, taking them from the fuel once a batch is full. +fn add_work(work: &mut u64, n: u64, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(), CompileFail> { + *work = work.saturating_add(n); + if *work >= FUEL_BATCH { + charge(Cost::Fuel(std::mem::take(work))).map_err(CompileFail::Abrupt)?; + } + Ok(()) +} + +/// The ranges of a class being read, with the bytes charged for them so far. +struct ClassRanges { + ranges: Vec<(u16, u16)>, + held: usize, +} + +impl ClassRanges { + /// Adds the ranges of an atom. + fn add_atom(&mut self, atom: &ClassAtom, work: &mut u64, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(), CompileFail> { + match atom { + ClassAtom::Unit(u) => self.add(&[(*u, *u)], work, charge), + ClassAtom::Ranges(r) => self.add(r, work, charge), + } + } + + /// Adds `part`. Each range counts as work, and when the list must grow, the bytes of its new + /// capacity and of the copy `normalize` makes of it are charged first. + fn add(&mut self, part: &[(u16, u16)], work: &mut u64, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(), CompileFail> { + let extra = part.len(); + add_work(work, extra as u64, charge)?; + let len = self.ranges.len().saturating_add(extra); + if len > self.ranges.capacity() { + let cap = len.max(self.ranges.capacity().saturating_mul(2)); + let need = cap.saturating_mul(2 * size_of::<(u16, u16)>()); + if need > self.held { + charge(Cost::Heap(need - self.held)).map_err(CompileFail::Abrupt)?; + self.held = need; + } + self.ranges.reserve_exact(cap - self.ranges.len()); + } + self.ranges.extend_from_slice(part); + Ok(()) + } +} + +/// The character class whose `[` is at `i`, and the position after its `]`. Each atom read and +/// each range added counts as work in `work`, and the class's ranges are charged as they grow. +fn class(pattern: &[u16], mut i: usize, work: &mut u64, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(CharSet, usize), CompileFail> { + i += 1; + let negated = pattern.get(i) == Some(&0x5E); + if negated { + i += 1; + } + let mut list = ClassRanges { ranges: Vec::new(), held: 0 }; + loop { + add_work(work, 1, charge)?; + let u = *pattern.get(i).ok_or("unterminated character class")?; + if u == 0x5D { + return Ok((CharSet { ranges: normalize(list.ranges), negated }, i + 1)); + } + let (atom, next) = class_atom(pattern, i)?; + i = next; + let dash = pattern.get(i) == Some(&0x2D) && pattern.get(i + 1).is_some_and(|&u| u != 0x5D); + if dash { + let (high, after) = class_atom(pattern, i + 1)?; + match (atom, high) { + (ClassAtom::Unit(lo), ClassAtom::Unit(hi)) => { + if lo > hi { + return Err("range out of order in character class".into()); + } + list.add(&[(lo, hi)], work, charge)?; + } + // Annex B: a range with a class escape at one end is its atoms and a `-`. + (lo, hi) => { + for a in [lo, ClassAtom::Unit(0x2D), hi] { + list.add_atom(&a, work, charge)?; + } + } + } + i = after; + } else { + list.add_atom(&atom, work, charge)?; + } + } +} + +/// At least the bytes of a class escape's ranges (`\d`, `\S`, ...). +const ESCAPE_RANGES_BYTES: usize = 16 * size_of::<(u16, u16)>(); + +/// The bytes a character class's ranges hold. +fn set_bytes(set: &CharSet) -> usize { + size_of::<(u16, u16)>().saturating_mul(set.ranges.capacity()) +} + +/// Compiles `pattern`. Groups may nest `max_depth` deep. `charge` takes fuel for the work done, +/// counting each unit of the scan for capturing groups and of the compile itself, and heap bytes +/// for what compiling holds before it is made: the capacity each list reserves as it grows, the +/// ranges of each class, and the code that joins alternatives and the flattened code. An error +/// from `charge` stops compiling. +pub(crate) fn compile(pattern: &[u16], flags: Flags, max_depth: usize, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result { + let mut work: u64 = 0; + add_work(&mut work, pattern.len() as u64, charge)?; + let total_groups = count_groups(pattern); + hold(size_of::(), charge)?; + let mut sets: Vec = Vec::new(); + let mut stack: Vec = vec![Ctx::new(Kind::Top, (0, 0))]; + let mut frags = Frags::default(); + let mut groups: u32 = 0; + let mut loops: u32 = 0; + let mut i = 0; + let canon = |u: u16| if flags.ignore_case { canonicalize(u) } else { u }; + while let Some(&u) = pattern.get(i) { + add_work(&mut work, 1, charge)?; + // One step adds at most one open group, one class, one alternative and four items to the + // current alternative, so those lists have room for them before the step. + room(&mut stack, 1, charge)?; + room(&mut sets, 1, charge)?; + let ctx = stack.last_mut().ok_or("unmatched ')'")?; + room(&mut ctx.cur, 4, charge)?; + room(&mut ctx.alts, 1, charge)?; + let start = ctx.cur.len(); + let before = groups; + match u { + 0x5E | 0x24 => { + emit(ctx, if u == 0x5E { Inst::LineStart } else { Inst::LineEnd }); + ctx.atom = None; + i += 1; + } + 0x7C => { + let cur = std::mem::take(&mut ctx.cur); + ctx.alts.push(cur); + ctx.atom = None; + i += 1; + } + 0x2E => { + emit(ctx, Inst::Any); + ctx.atom = Some((start, before)); + i += 1; + } + 0x5B => { + let (set, next) = class(pattern, i, &mut work, charge)?; + emit(ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); + sets.push(set); + ctx.atom = Some((start, before)); + i = next; + } + 0x28 => { + let kind = match (pattern.get(i + 1), pattern.get(i + 2)) { + (Some(0x3F), Some(0x3A)) => Kind::Group, + (Some(0x3F), Some(0x3D)) => Kind::Look(false), + (Some(0x3F), Some(0x21)) => Kind::Look(true), + (Some(0x3F), _) => return Err("invalid group".into()), + _ => { + groups += 1; + Kind::Capture(groups) + } + }; + i += if matches!(kind, Kind::Capture(_)) { 1 } else { 3 }; + if stack.len() > max_depth { + return Err(crate::error::TOO_DEEP.into()); + } + stack.push(Ctx::new(kind, (start, before))); + } + 0x29 => { + if stack.len() <= 1 { + return Err("unmatched ')'".into()); + } + let Some(mut done) = stack.pop() else { return Err("unmatched ')'".into()) }; + let cur = std::mem::take(&mut done.cur); + done.alts.push(cur); + // Joining adds a fragment for each alternative when there are several, and the + // group's body is one more. + let alts = done.alts.len(); + hold(join_bytes(alts), charge)?; + room(&mut frags.items, alts + 1, charge)?; + room(&mut frags.lens, alts + 1, charge)?; + let body = join(std::mem::take(&mut done.alts), &mut frags)?; + work += body.len() as u64; + let body_len = frags.len_of(&body); + let body = frags.add(body); + let (start, before) = done.opened_at; + let parent = stack.last_mut().ok_or("unmatched ')'")?; + room(&mut parent.cur, 3, charge)?; + match done.kind { + Kind::Capture(n) => { + emit(parent, Inst::GroupStart(n)); + parent.cur.push(body); + emit(parent, Inst::GroupEnd(n)); + } + Kind::Group | Kind::Top => parent.cur.push(body), + Kind::Look(negative) => { + let end = offset(body_len + 2)?; + emit(parent, Inst::Look { negative, end }); + parent.cur.push(body); + emit(parent, Inst::LookEnd); + } + } + parent.atom = Some((start, before)); + i += 1; + } + 0x2A | 0x2B | 0x3F | 0x7B => { + let (min, max, next) = match u { + 0x2A => (0, u64::MAX, i + 1), + 0x2B => (1, u64::MAX, i + 1), + 0x3F => (0, 1, i + 1), + _ => match braces(pattern, i) { + Some(b) => b, + None => { + // Annex B: a `{` that starts no quantifier is itself. + emit(ctx, Inst::Char(canon(u))); + ctx.atom = Some((start, before)); + i += 1; + continue; + } + }, + }; + if max < min { + return Err("numbers out of order in {} quantifier".into()); + } + let Some((atom_start, atom_groups)) = ctx.atom.take() else { return Err("nothing to repeat".into()) }; + let greedy = pattern.get(next) != Some(&0x3F); + i = if greedy { next } else { next + 1 }; + if min == 1 && max == 1 { + continue; + } + hold(ITEM_BYTES.saturating_mul(ctx.cur.len().saturating_sub(atom_start)), charge)?; + room(&mut frags.items, 1, charge)?; + room(&mut frags.lens, 1, charge)?; + let body: Vec = ctx.cur.drain(atom_start..).collect(); + work += body.len() as u64; + let reg = loops; + loops = loops.checked_add(1).ok_or("too many quantifiers")?; + let n = frags.len_of(&body); + let body = frags.add(body); + emit(ctx, Inst::RepInit(reg)); + emit(ctx, Inst::Rep { reg, min, max, greedy, caps: (atom_groups + 1, groups + 1), exit: offset(n + 2)? }); + ctx.cur.push(body); + emit(ctx, Inst::RepEnd { reg, head: -offset(n + 1)? }); + } + 0x5C => { + let next = *pattern.get(i + 1).ok_or("\\ at end of pattern")?; + let mut atom = true; + match next { + 0x62 | 0x42 => { + emit(ctx, Inst::WordBoundary(next == 0x62)); + atom = false; + i += 2; + } + 0x31..=0x39 => { + let mut j = i + 1; + let mut n: u64 = 0; + while let Some(d) = pattern.get(j).filter(|&&u| (0x30..=0x39).contains(&u)) { + n = n.saturating_mul(10).saturating_add(u64::from(d - 0x30)); + j += 1; + } + if n <= total_groups as u64 { + emit(ctx, Inst::BackRef(u32::try_from(n).map_err(|_| "too many groups")?)); + i = j; + } else if next <= 0x37 { + let (v, end) = octal(pattern, i + 1); + emit(ctx, Inst::Char(canon(v))); + i = end; + } else { + emit(ctx, Inst::Char(canon(next))); + i += 2; + } + } + 0x30 => { + if pattern.get(i + 2).is_some_and(|&d| (0x30..=0x37).contains(&d)) { + let (v, end) = octal(pattern, i + 1); + emit(ctx, Inst::Char(canon(v))); + i = end; + } else { + emit(ctx, Inst::Char(0)); + i += 2; + } + } + _ => { + if matches!(next, 0x64 | 0x44 | 0x73 | 0x53 | 0x77 | 0x57) { + hold(ESCAPE_RANGES_BYTES, charge)?; + } + if let Some(r) = escape_ranges(next) { + emit(ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); + sets.push(CharSet { ranges: r, negated: false }); + i += 2; + } else { + let (atom_unit, end) = match class_atom(pattern, i)? { + (ClassAtom::Unit(v), end) => (v, end), + (ClassAtom::Ranges(_), end) => (next, end), + }; + emit(ctx, Inst::Char(canon(atom_unit))); + i = end; + } + } + } + ctx.atom = if atom { Some((start, before)) } else { None }; + } + _ => { + emit(ctx, Inst::Char(canon(u))); + ctx.atom = Some((start, before)); + i += 1; + } + } + } + if stack.len() != 1 { + return Err("unterminated group".into()); + } + let Some(mut top) = stack.pop() else { return Err("unterminated group".into()) }; + let cur = std::mem::take(&mut top.cur); + top.alts.push(cur); + // Joining adds a fragment for each alternative when there are several, and flattening writes + // every instruction. + let alts = top.alts.len(); + hold(join_bytes(alts), charge)?; + room(&mut frags.items, alts, charge)?; + room(&mut frags.lens, alts, charge)?; + let top = join(top.alts, &mut frags)?; + hold(flatten_bytes(frags.len_of(&top), frags.items.len()), charge)?; + let mut insts = frags.flatten(&top); + insts.push(Inst::Match); + sets.shrink_to_fit(); + charge(Cost::Fuel(work + insts.len() as u64 / 16)).map_err(CompileFail::Abrupt)?; + Ok(Regex { insts, sets, groups: groups as usize, loops: loops as usize, ignore_case: flags.ignore_case, multiline: flags.multiline }) +} + +// ---- matching ---- + +/// An entry on the matcher's stack: a choice point to go back to, or a value to restore when +/// going back past it. +#[derive(Clone, Copy, Debug)] +enum Bt { + Retry { + pc: usize, + pos: usize, + }, + /// Runs another iteration of the quantifier at `pc`. + EnterRep { + pc: usize, + pos: usize, + }, + Cap(usize, usize), + Open(usize, usize), + Count(usize, u64), + Mark(usize, usize), + Look { + pos: usize, + negative: bool, + resume: usize, + }, +} + +impl Bt { + fn is_undo(&self) -> bool { + matches!(self, Bt::Cap(..) | Bt::Open(..) | Bt::Count(..) | Bt::Mark(..)) + } +} + +/// Takes `n` units of fuel, checking the interrupt flag. +pub(crate) fn use_fuel_n(interp: &mut Interp, n: u64) -> JsResult<()> { + if n == 0 { + return Ok(()); + } + if interp.fuel < n { + interp.fuel = 0; + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + interp.fuel -= n - 1; + interp.use_fuel() +} + +/// The stack entries a matcher's stack first grows by. +const STACK_CHUNK: usize = 64; + +/// The state of one match attempt, kept between attempts to reuse its buffers. Its capture and +/// quantifier state, and its stack's capacity, are held against the heap limit until it is +/// dropped. +pub(crate) struct Matcher<'r> { + re: &'r Regex, + /// Two positions per group, group 0 first: [`NONE`] where a group did not take part. + pub(crate) caps: Vec, + open: Vec, + counts: Vec, + marks: Vec, + stack: Vec, + /// The stack entries held. + stack_room: usize, + held: HeldBytes, + steps: u64, +} + +impl<'r> Matcher<'r> { + /// A matcher for `re`, ending the run with the heap limit when its state does not fit. Holding + /// the state, and later the stack, can run a collection, so everything the caller still needs + /// must be reachable from the roots while the matcher works. + pub(crate) fn new(re: &'r Regex, interp: &mut Interp) -> JsResult { + let state = 8usize.saturating_mul(re.groups.saturating_add(1).saturating_mul(3).saturating_add(re.loops.saturating_mul(2))); + let mut held = HeldBytes::new(interp); + held.add(interp, state)?; + Ok(Self { + re, + caps: vec![NONE; 2 * (re.groups + 1)], + open: vec![NONE; re.groups + 1], + counts: vec![0; re.loops], + marks: vec![NONE; re.loops], + stack: Vec::new(), + stack_room: 0, + held, + steps: 0, + }) + } + + /// The bytes the matcher holds. + #[cfg(test)] + pub(crate) fn held_bytes(&self) -> usize { + self.held.bytes() + } + + fn push(&mut self, interp: &mut Interp, b: Bt) -> JsResult<()> { + if self.stack.len() >= self.stack_room { + self.grow(interp)?; + } + self.stack.push(b); + Ok(()) + } + + /// Holds more stack entries: as many as the stack has, at least [`STACK_CHUNK`], or when those + /// do not fit, what the heap room left holds. + fn grow(&mut self, interp: &mut Interp) -> JsResult<()> { + let size = size_of::(); + let mut more = self.stack_room.max(STACK_CHUNK); + if self.held.add(interp, more.saturating_mul(size)).is_err() { + more = interp.limits.heap_bytes.saturating_sub(interp.heap.bytes) / size; + if more == 0 { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + self.held.add(interp, more * size)?; + } + self.stack_room = self.stack_room.saturating_add(more); + self.stack.reserve_exact(self.stack_room - self.stack.len()); + Ok(()) + } + + fn step(&mut self, interp: &mut Interp) -> JsResult<()> { + self.steps += 1; + if self.steps >= FUEL_BATCH { + let n = std::mem::take(&mut self.steps); + use_fuel_n(interp, n)?; + } + Ok(()) + } + + /// Takes the fuel for the steps not yet charged. + fn settle(&mut self, interp: &mut Interp) -> JsResult<()> { + let n = std::mem::take(&mut self.steps); + use_fuel_n(interp, n) + } + + fn set_cap(&mut self, interp: &mut Interp, slot: usize, v: usize) -> JsResult<()> { + let Some(old) = self.caps.get(slot).copied() else { return Ok(()) }; + if old != v { + self.push(interp, Bt::Cap(slot, old))?; + if let Some(c) = self.caps.get_mut(slot) { + *c = v; + } + } + Ok(()) + } + + /// Starts an iteration of the quantifier at `pc`, clearing the groups inside its atom. Each + /// group cleared counts as a step. + fn enter_rep(&mut self, interp: &mut Interp, pc: usize, pos: usize) -> JsResult<()> { + let Some(Inst::Rep { reg, caps: (lo, hi), .. }) = self.re.insts.get(pc).copied() else { return Ok(()) }; + let reg = reg as usize; + let old = self.marks.get(reg).copied().unwrap_or(NONE); + self.push(interp, Bt::Mark(reg, old))?; + if let Some(m) = self.marks.get_mut(reg) { + *m = pos; + } + for g in lo as usize..hi as usize { + self.step(interp)?; + self.set_cap(interp, 2 * g, NONE)?; + self.set_cap(interp, 2 * g + 1, NONE)?; + } + Ok(()) + } + + /// Restores the value an undo entry saved; other entries are ignored. + fn undo(&mut self, b: Bt) { + let (slot, old) = match b { + Bt::Cap(s, old) => (self.caps.get_mut(s), old), + Bt::Open(g, old) => (self.open.get_mut(g), old), + Bt::Mark(r, old) => (self.marks.get_mut(r), old), + Bt::Count(r, old) => { + if let Some(c) = self.counts.get_mut(r) { + *c = old; + } + return; + } + _ => return, + }; + if let Some(slot) = slot { + *slot = old; + } + } + + /// Goes back to the last choice point, restoring what was changed since. False when there is + /// none left. + fn backtrack(&mut self, interp: &mut Interp, pc: &mut usize, pos: &mut usize) -> JsResult { + while let Some(b) = self.stack.pop() { + self.step(interp)?; + match b { + Bt::Retry { pc: p, pos: q } => { + (*pc, *pos) = (p, q); + return Ok(true); + } + Bt::EnterRep { pc: p, pos: q } => { + *pos = q; + self.enter_rep(interp, p, q)?; + *pc = p + 1; + return Ok(true); + } + Bt::Cap(..) | Bt::Open(..) | Bt::Count(..) | Bt::Mark(..) => self.undo(b), + // A negative lookahead whose pattern failed succeeds. + Bt::Look { pos: q, negative: true, resume } => { + (*pc, *pos) = (resume, q); + return Ok(true); + } + Bt::Look { negative: false, .. } => {} + } + } + Ok(false) + } + + /// Tries to match at `start` only. On success `caps` holds the match. + pub(crate) fn match_at(&mut self, interp: &mut Interp, input: &[u16], start: usize) -> JsResult { + let r = self.run(interp, input, start); + self.settle(interp)?; + r + } + + /// The first match at `from` or after. + pub(crate) fn search(&mut self, interp: &mut Interp, input: &[u16], from: usize) -> JsResult { + let mut start = from; + while start <= input.len() { + if self.match_at(interp, input, start)? { + return Ok(true); + } + start += 1; + } + Ok(false) + } + + /// Clears the state for a new attempt. Each slot cleared counts as a step. + fn reset(&mut self) { + let slots = self.caps.len() + self.open.len() + self.counts.len() + self.marks.len(); + self.steps = self.steps.saturating_add(slots as u64); + self.caps.fill(NONE); + self.open.fill(NONE); + self.counts.fill(0); + self.marks.fill(NONE); + self.stack.clear(); + } + + fn run(&mut self, interp: &mut Interp, input: &[u16], start: usize) -> JsResult { + self.reset(); + let re = self.re; + let mut pc = 0usize; + let mut pos = start; + let at = |pc: usize, off: i32| -> usize { pc.wrapping_add_signed(off as isize) }; + loop { + self.step(interp)?; + let Some(inst) = re.insts.get(pc).copied() else { return Err(Abrupt::Internal("regular expression jumped out of its code".into())) }; + let ok = match inst { + Inst::Char(c) => { + let hit = input.get(pos).is_some_and(|&u| re.canon(u) == c); + if hit { + pos += 1; + pc += 1; + } + hit + } + Inst::Any => { + let hit = input.get(pos).is_some_and(|&u| !is_line_terminator(u)); + if hit { + pos += 1; + pc += 1; + } + hit + } + Inst::Set(k) => { + let hit = match (input.get(pos), re.sets.get(k as usize)) { + (Some(&u), Some(set)) => re.set_matches(set, u), + _ => false, + }; + if hit { + pos += 1; + pc += 1; + } + hit + } + Inst::LineStart => { + let hit = pos == 0 || (re.multiline && input.get(pos - 1).is_some_and(|&u| is_line_terminator(u))); + pc += 1; + hit + } + Inst::LineEnd => { + let hit = pos == input.len() || (re.multiline && input.get(pos).is_some_and(|&u| is_line_terminator(u))); + pc += 1; + hit + } + Inst::WordBoundary(want) => { + let a = pos > 0 && input.get(pos - 1).is_some_and(|&u| is_word_char(u)); + let b = input.get(pos).is_some_and(|&u| is_word_char(u)); + pc += 1; + (a != b) == want + } + Inst::Split(first, second) => { + self.push(interp, Bt::Retry { pc: at(pc, second), pos })?; + pc = at(pc, first); + true + } + Inst::Jump(off) => { + pc = at(pc, off); + true + } + Inst::GroupStart(g) => { + let g = g as usize; + let old = self.open.get(g).copied().unwrap_or(NONE); + self.push(interp, Bt::Open(g, old))?; + if let Some(o) = self.open.get_mut(g) { + *o = pos; + } + pc += 1; + true + } + Inst::GroupEnd(g) => { + let g = g as usize; + let s = self.open.get(g).copied().unwrap_or(NONE); + self.set_cap(interp, 2 * g, s)?; + self.set_cap(interp, 2 * g + 1, pos)?; + pc += 1; + true + } + Inst::RepInit(r) => { + let r = r as usize; + let old = self.counts.get(r).copied().unwrap_or(0); + self.push(interp, Bt::Count(r, old))?; + if let Some(c) = self.counts.get_mut(r) { + *c = 0; + } + pc += 1; + true + } + Inst::Rep { reg, min, max, greedy, exit, .. } => { + let count = self.counts.get(reg as usize).copied().unwrap_or(0); + let exit_pc = at(pc, exit); + if count >= max { + pc = exit_pc; + } else if count < min { + self.enter_rep(interp, pc, pos)?; + pc += 1; + } else if greedy { + self.push(interp, Bt::Retry { pc: exit_pc, pos })?; + self.enter_rep(interp, pc, pos)?; + pc += 1; + } else { + self.push(interp, Bt::EnterRep { pc, pos })?; + pc = exit_pc; + } + true + } + Inst::RepEnd { reg, head } => { + let head_pc = at(pc, head); + let min = match re.insts.get(head_pc) { + Some(Inst::Rep { min, .. }) => *min, + _ => return Err(Abrupt::Internal("quantifier end without its head".into())), + }; + let r = reg as usize; + let count = self.counts.get(r).copied().unwrap_or(0); + // An iteration past the minimum that matched nothing fails (section + // 15.10.2.5, RepeatMatcher step 2.a). + if count >= min && self.marks.get(r).copied() == Some(pos) { + false + } else { + self.push(interp, Bt::Count(r, count))?; + if let Some(c) = self.counts.get_mut(r) { + *c = count + 1; + } + pc = head_pc; + true + } + } + Inst::Look { negative, end } => { + self.push(interp, Bt::Look { pos, negative, resume: at(pc, end) })?; + pc += 1; + true + } + Inst::LookEnd => { + let Some(b) = self.stack.iter().rposition(|e| matches!(e, Bt::Look { .. })) else { + return Err(Abrupt::Internal("lookahead end without its start".into())); + }; + // Each entry read above, and below, counts as a step. + self.steps = self.steps.saturating_add((self.stack.len() - b) as u64); + let Some(Bt::Look { pos: q, negative, resume }) = self.stack.get(b).copied() else { + return Err(Abrupt::Internal("lookahead end without its start".into())); + }; + if negative { + // The pattern matched, so the negative lookahead fails: restore what it + // changed, drop its choice points and go back past it. + while self.stack.len() > b + 1 { + if let Some(e) = self.stack.pop() { + self.undo(e); + } + } + self.stack.truncate(b); + false + } else { + // A lookahead is atomic: its choice points are dropped, and what it set is + // kept, with the entries that restore it, moved down in place. + let mut kept = b; + for r in b + 1..self.stack.len() { + if let Some(e) = self.stack.get(r).copied().filter(Bt::is_undo) { + if let Some(slot) = self.stack.get_mut(kept) { + *slot = e; + } + kept += 1; + } + } + self.stack.truncate(kept); + pos = q; + pc = resume; + true + } + } + Inst::BackRef(g) => { + let g = g as usize; + let (s, e) = (self.caps.get(2 * g).copied().unwrap_or(NONE), self.caps.get(2 * g + 1).copied().unwrap_or(NONE)); + pc += 1; + if s == NONE || e == NONE || e < s { + true + } else { + let len = e - s; + let mut hit = pos + len <= input.len(); + // The units are compared 16 at a time, and each 16, or fewer at the end, + // count as a step taken before they are compared. + let mut k = 0; + while hit && k < len { + let end = len.min(k + 16); + self.step(interp)?; + hit = (k..end).all(|i| match (input.get(s + i), input.get(pos + i)) { + (Some(&a), Some(&b)) => re.canon(a) == re.canon(b), + _ => false, + }); + k = end; + } + if hit { + pos += len; + } + hit + } + } + Inst::Match => { + self.set_cap(interp, 0, start)?; + self.set_cap(interp, 1, pos)?; + return Ok(true); + } + }; + if !ok && !self.backtrack(interp, &mut pc, &mut pos)? { + return Ok(false); + } + } + } +} diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs new file mode 100644 index 000000000..b3758bdf5 --- /dev/null +++ b/crates/js/src/string_builtins.rs @@ -0,0 +1,788 @@ +//! `String` (ECMA-262 5.1 section 15.5): the constructor, `String.fromCharCode` and the methods of +//! `String.prototype`, with `substr` from Annex B. +//! +//! The methods read a string's code units once (flattening a rope, whose flat copy counts as a new +//! string), take a unit of fuel for every [`UNITS_PER_FUEL`] code units they read or write, and +//! reserve each new string's bytes on the heap before building it, so a method that would make a +//! string past the heap limit ends the run instead of allocating it. + +use std::rc::Rc; + +use crate::LimitKind; +use crate::builtins::{arg, constructor, method, native}; +use crate::heap::{Class, NativeKind, OPEN, ObjId, Object, PROP_BYTES, Prop, string_bytes}; +use crate::interp::{Abrupt, HeldBytes, Interp, JsResult}; +use crate::regexp_builtins::{exec_raw, new_regexp_from, regexp_of}; +use crate::regexp_vm::{Matcher, NONE, use_fuel_n}; +use crate::value::{Str, Value, index_key, is_str_space, to_uint32}; + +/// Code units a unit of fuel pays for when a built-in reads or writes text. +pub(crate) const UNITS_PER_FUEL: usize = 16; + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.string_proto; + let ctor = native(interp, "String", string_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "String", ctor, proto)?; + method(interp, ctor, "fromCharCode", from_char_code, 1)?; + let methods: [(&'static str, crate::heap::NativeFn, u32); 20] = [ + ("toString", to_string, 0), + ("valueOf", to_string, 0), + ("charAt", char_at, 1), + ("charCodeAt", char_code_at, 1), + ("concat", concat, 1), + ("indexOf", index_of, 1), + ("lastIndexOf", last_index_of, 1), + ("localeCompare", locale_compare, 1), + ("match", match_, 1), + ("replace", replace, 2), + ("search", search, 1), + ("slice", slice, 2), + ("split", split, 2), + ("substring", substring, 2), + ("substr", substr, 2), + ("toLowerCase", to_lower_case, 0), + ("toLocaleLowerCase", to_lower_case, 0), + ("toUpperCase", to_upper_case, 0), + ("toLocaleUpperCase", to_upper_case, 0), + ("trim", trim, 0), + ]; + for (name, f, len) in methods { + method(interp, proto, name, f, len)?; + } + Ok(()) +} + +// ---- helpers the other text built-ins share ---- + +/// ToInteger (section 9.4) applied to a number. +pub(crate) fn to_integer(n: f64) -> f64 { + if n.is_nan() { + 0.0 + } else if n.is_infinite() { + n + } else { + n.trunc() + 0.0 + } +} + +/// Takes the fuel for reading or writing `n` code units. +pub(crate) fn charge_units(interp: &mut Interp, n: usize) -> JsResult<()> { + use_fuel_n(interp, (n / UNITS_PER_FUEL) as u64 + 1) +} + +/// Reserves the heap bytes of a new string of `len` code units before it is built, ending the +/// run with the heap limit when it would not fit. Everything the caller still needs must be +/// reachable from the roots. +pub(crate) fn reserve_units(interp: &mut Interp, len: usize) -> JsResult<()> { + reserve_string(interp, len)?; + charge_units(interp, len) +} + +/// [`reserve_units`] without the fuel, for a caller that took it as it read the units. +fn reserve_string(interp: &mut Interp, len: usize) -> JsResult<()> { + check_string_room(interp, len)?; + let bytes = string_bytes(len); + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + Ok(()) +} + +/// Ends the run with the heap limit when a new string of `len` code units would not fit, after +/// a collection if one is needed. Everything the caller still needs must be reachable from the +/// roots. +fn check_string_room(interp: &mut Interp, len: usize) -> JsResult<()> { + if len > interp.limits.heap_bytes / 4 { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + interp.reserve_heap(string_bytes(len)) +} + +/// Pushes `item` onto `list`, first holding the bytes of the list's new capacity when it must +/// grow. +fn push_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, item: T) -> JsResult<()> { + if list.len() == list.capacity() { + let more = list.capacity().max(4); + held.add(interp, more.saturating_mul(size_of::()))?; + list.reserve_exact(more); + } + list.push(item); + Ok(()) +} + +/// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that copy's +/// bytes are reserved and added to the heap, and its units charged as fuel, first. +pub(crate) fn flat_charged(interp: &mut Interp, s: &Str) -> JsResult> { + if s.is_rope() { + reserve_units(interp, s.len())?; + } + Ok(s.flat()) +} + +/// A new string holding `units[start..end]`. +pub(crate) fn substring_value(interp: &mut Interp, units: &[u16], start: usize, end: usize) -> JsResult { + let part = units.get(start..end.max(start)).unwrap_or_default(); + reserve_units(interp, part.len())?; + Ok(Value::String(Str::from_units(part.to_vec()))) +} + +/// Runs `f`, then drops the temporary roots it added. +pub(crate) fn rooted(interp: &mut Interp, f: impl FnOnce(&mut Interp) -> JsResult) -> JsResult { + let mark = interp.temp_roots.len(); + let r = f(interp); + interp.temp_roots.truncate(mark); + r +} + +/// A new, empty array, kept in the temporary roots: call it inside [`rooted`]. +pub(crate) fn new_array(interp: &mut Interp) -> ObjId { + let proto = interp.intrinsics.array_proto; + let a = interp.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: true }, Some(proto))); + interp.temp_roots.push(Value::Object(a)); + a +} + +/// Sets element `i` of an array made by [`new_array`], reserving its bytes first. +pub(crate) fn set_element(interp: &mut Interp, a: ObjId, i: usize, v: Value) -> JsResult<()> { + let i = u32::try_from(i).map_err(|_| Abrupt::Limit(LimitKind::Heap))?; + interp.temp_roots.push(v.clone()); + let reserved = interp.reserve_heap(PROP_BYTES + string_bytes(10)); + interp.temp_roots.pop(); + reserved?; + interp.use_fuel()?; + interp.define(a, index_key(i), Prop::Data { value: v, flags: OPEN }) +} + +/// CheckObjectCoercible on `this`, then ToString. +pub(crate) fn this_str(interp: &mut Interp, this: &Value, method: &str) -> JsResult { + if this.is_nullish() { + let message = format!("String.prototype.{method} was called on null or undefined"); + return Err(interp.type_error(&message)); + } + interp.to_str(this) +} + +/// ToInteger of an argument. +fn int_arg(interp: &mut Interp, args: &[Value], i: usize) -> JsResult { + let n = interp.to_number(&arg(args, i))?; + Ok(to_integer(n)) +} + +/// Clamps an integer to `0..=len`. +fn clamp(n: f64, len: usize) -> usize { + if n <= 0.0 { + 0 + } else if n >= len as f64 { + len + } else { + n as usize + } +} + +/// The first index at `from` or after where `needle` occurs in `hay`. Every position tried takes +/// fuel. +fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult> { + if needle.len() > hay.len() { + return Ok(None); + } + let last = hay.len() - needle.len(); + let per = (needle.len() / UNITS_PER_FUEL) as u64 + 1; + let mut steps = 0u64; + let mut i = from; + let mut found = None; + while i <= last { + steps += per; + if steps >= 1024 { + use_fuel_n(interp, std::mem::take(&mut steps))?; + } + if hay.get(i..i + needle.len()) == Some(needle) { + found = Some(i); + break; + } + i += 1; + } + use_fuel_n(interp, steps)?; + Ok(found) +} + +// ---- the constructor ---- + +/// `String(value)` and `new String(value)` (sections 15.5.1 and 15.5.2). +fn string_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + let s = match args.first() { + Some(v) => interp.to_str(v)?, + None => Str::from(""), + }; + if !construct { + return Ok(Value::String(s)); + } + let proto = interp.intrinsics.string_proto; + Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::String(s), Some(proto))))) +} + +/// `String.fromCharCode(…)` (section 15.5.3.2). +fn from_char_code(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let mut units = Vec::with_capacity(args.len()); + for a in args { + let n = interp.to_number(a)?; + units.push(to_uint32(n) as u16); + } + reserve_units(interp, units.len())?; + Ok(Value::String(Str::from_units(units))) +} + +// ---- String.prototype ---- + +/// `toString` and `valueOf` (sections 15.5.4.2 and 15.5.4.3). +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + match &this { + Value::String(_) => Ok(this), + Value::Object(o) => match interp.heap.object(*o).map(|o| &o.class) { + Some(Class::String(s)) => Ok(Value::String(s.clone())), + _ => Err(interp.type_error("String.prototype.toString was called on a value that is not a string")), + }, + _ => Err(interp.type_error("String.prototype.toString was called on a value that is not a string")), + } +} + +fn char_at(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "charAt")?; + let pos = int_arg(interp, args, 0)?; + if pos < 0.0 || pos >= s.len() as f64 { + return Ok(Value::str("")); + } + let unit = flat_charged(interp, &s)?.get(pos as usize).copied().unwrap_or(0); + Ok(Value::String(Str::from_units(vec![unit]))) +} + +fn char_code_at(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "charCodeAt")?; + let pos = int_arg(interp, args, 0)?; + if pos < 0.0 || pos >= s.len() as f64 { + return Ok(Value::Number(f64::NAN)); + } + Ok(Value::Number(f64::from(flat_charged(interp, &s)?.get(pos as usize).copied().unwrap_or(0)))) +} + +fn concat(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let mut s = this_str(interp, &this, "concat")?; + for a in args { + let r = interp.to_str(a)?; + s = interp.concat(&s, &r)?; + } + Ok(Value::String(s)) +} + +fn index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "indexOf")?; + let search = interp.to_str(&arg(args, 0))?; + let pos = int_arg(interp, args, 1)?; + let (hay, needle) = (flat_charged(interp, &s)?, flat_charged(interp, &search)?); + let start = clamp(pos, hay.len()); + let found = find(interp, &hay, &needle, start)?; + Ok(Value::Number(found.map_or(-1.0, |i| i as f64))) +} + +fn last_index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "lastIndexOf")?; + let search = interp.to_str(&arg(args, 0))?; + let n = interp.to_number(&arg(args, 1))?; + let pos = if n.is_nan() { f64::INFINITY } else { to_integer(n) }; + let (hay, needle) = (flat_charged(interp, &s)?, flat_charged(interp, &search)?); + let start = clamp(pos, hay.len()); + if needle.len() > hay.len() { + return Ok(Value::Number(-1.0)); + } + let mut k = start.min(hay.len() - needle.len()); + let per = (needle.len() / UNITS_PER_FUEL) as u64 + 1; + loop { + use_fuel_n(interp, per)?; + if hay.get(k..k + needle.len()) == Some(&needle[..]) { + return Ok(Value::Number(k as f64)); + } + if k == 0 { + return Ok(Value::Number(-1.0)); + } + k -= 1; + } +} + +/// `localeCompare` (section 15.5.4.9): code unit order. +fn locale_compare(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "localeCompare")?; + let that = interp.to_str(&arg(args, 0))?; + let (a, b) = (flat_charged(interp, &s)?, flat_charged(interp, &that)?); + charge_units(interp, a.len().min(b.len()))?; + Ok(Value::Number(match a.cmp(&b) { + std::cmp::Ordering::Less => -1.0, + std::cmp::Ordering::Equal => 0.0, + std::cmp::Ordering::Greater => 1.0, + })) +} + +fn slice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "slice")?; + let len = s.len() as f64; + let start = int_arg(interp, args, 0)?; + let end = if matches!(arg(args, 1), Value::Undefined) { len } else { int_arg(interp, args, 1)? }; + let from = if start < 0.0 { (len + start).max(0.0) } else { start.min(len) }; + let to = if end < 0.0 { (len + end).max(0.0) } else { end.min(len) }; + if to <= from { + return Ok(Value::str("")); + } + let units = flat_charged(interp, &s)?; + substring_value(interp, &units, from as usize, to as usize) +} + +fn substring(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "substring")?; + let len = s.len(); + let start = int_arg(interp, args, 0)?; + let end = if matches!(arg(args, 1), Value::Undefined) { len as f64 } else { int_arg(interp, args, 1)? }; + let (a, b) = (clamp(start, len), clamp(end, len)); + let units = flat_charged(interp, &s)?; + substring_value(interp, &units, a.min(b), a.max(b)) +} + +/// `substr` (Annex B.2.3). Its first step applies ToString to `this` with no object-coercible +/// check, so `null` and `undefined` give substrings of `"null"` and `"undefined"`. +fn substr(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = interp.to_str(&this)?; + let len = s.len() as f64; + let start = int_arg(interp, args, 0)?; + let length = if matches!(arg(args, 1), Value::Undefined) { f64::INFINITY } else { int_arg(interp, args, 1)? }; + let start = if start >= 0.0 { start } else { (len + start).max(0.0) }; + let count = length.max(0.0).min(len - start); + if count <= 0.0 { + return Ok(Value::str("")); + } + let units = flat_charged(interp, &s)?; + substring_value(interp, &units, start as usize, (start + count) as usize) +} + +/// The UTF-16 length of `c` mapped to lower or upper case. +fn mapped_len(c: char, upper: bool) -> usize { + if upper { c.to_uppercase().map(char::len_utf16).sum() } else { c.to_lowercase().map(char::len_utf16).sum() } +} + +/// Maps the characters of `units` to lower or upper case (Unicode's full mappings, final sigma +/// included), passing lone surrogates through. The result's length is reserved before it is built. +fn map_case(interp: &mut Interp, units: &[u16], upper: bool) -> JsResult> { + let len: usize = char::decode_utf16(units.iter().copied()).map(|r| r.map_or(1, |c| mapped_len(c, upper))).sum(); + reserve_units(interp, len)?; + let mut out = Vec::with_capacity(len); + let mut run = String::new(); + let flush = |run: &mut String, out: &mut Vec| { + let mapped = if upper { run.to_uppercase() } else { run.to_lowercase() }; + out.extend(mapped.encode_utf16()); + run.clear(); + }; + for r in char::decode_utf16(units.iter().copied()) { + match r { + Ok(c) => run.push(c), + Err(e) => { + flush(&mut run, &mut out); + out.push(e.unpaired_surrogate()); + } + } + } + flush(&mut run, &mut out); + Ok(out) +} + +fn to_lower_case(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "toLowerCase")?; + let units = flat_charged(interp, &s)?; + let out = map_case(interp, &units, false)?; + Ok(Value::String(Str::from_units(out))) +} + +fn to_upper_case(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "toUpperCase")?; + let units = flat_charged(interp, &s)?; + let out = map_case(interp, &units, true)?; + Ok(Value::String(Str::from_units(out))) +} + +fn trim(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "trim")?; + let units = flat_charged(interp, &s)?; + // The scan reads every unit once at most. + charge_units(interp, units.len())?; + let start = units.iter().position(|&u| !is_str_space(u)).unwrap_or(units.len()); + let end = units.iter().rposition(|&u| !is_str_space(u)).map_or(start, |i| i + 1); + if start == 0 && end == units.len() { + return Ok(Value::String(s)); + } + substring_value(interp, &units, start, end) +} + +// ---- the methods that take a regular expression ---- + +/// The regular expression argument of `match` and `search`: a RegExp object, or a new one made +/// from the value as `new RegExp(value)` would. +fn regexp_arg(interp: &mut Interp, v: &Value) -> JsResult { + if let Some((o, _)) = regexp_of(interp, v) { + return Ok(o); + } + let o = new_regexp_from(interp, v, &Value::Undefined)?; + interp.temp_roots.push(Value::Object(o)); + Ok(o) +} + +/// `match` (section 15.5.4.10). +fn match_(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + rooted(interp, |interp| { + let s = this_str(interp, &this, "match")?; + let rx = regexp_arg(interp, &arg(args, 0))?; + let global = interp.get_value(&Value::Object(rx), &crate::heap::key("global"))?.truthy(); + if !global { + return crate::regexp_builtins::exec_value(interp, rx, &s); + } + let last_index = crate::heap::key("lastIndex"); + interp.put(rx, last_index.clone(), Value::Number(0.0), true)?; + let a = new_array(interp); + let mut n = 0usize; + let mut previous = 0.0; + let units = flat_charged(interp, &s)?; + while let Some(caps) = exec_raw(interp, rx, &s)? { + let this_index = interp.get_value(&Value::Object(rx), &last_index)?; + let this_index = interp.to_number(&this_index)?; + if this_index == previous { + previous = this_index + 1.0; + interp.put(rx, last_index.clone(), Value::Number(previous), true)?; + } else { + previous = this_index; + } + let m = substring_value(interp, &units, caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0))?; + set_element(interp, a, n, m)?; + n += 1; + } + Ok(if n == 0 { Value::Null } else { Value::Object(a) }) + }) +} + +/// `search` (section 15.5.4.12): the index of the first match from the start, ignoring +/// `lastIndex` and `global`. +fn search(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + rooted(interp, |interp| { + let s = this_str(interp, &this, "search")?; + let rx = regexp_arg(interp, &arg(args, 0))?; + let Some((_, re)) = regexp_of(interp, &Value::Object(rx)) else { return Err(interp.type_error("not a regular expression")) }; + let units = flat_charged(interp, &s)?; + let mut m = Matcher::new(&re, interp)?; + let found = m.search(interp, &units, 0)?; + Ok(Value::Number(if found { m.caps.first().copied().unwrap_or(0) as f64 } else { -1.0 })) + }) +} + +/// A piece of a result string: a range of the input, a range of the replacement pattern, or the +/// units of a string a replacement function returned. +enum Piece { + Range(usize, usize), + Template(usize, usize), + Units(Rc<[u16]>), +} + +impl Piece { + fn len(&self) -> usize { + match self { + Piece::Range(a, b) | Piece::Template(a, b) => b.saturating_sub(*a), + Piece::Units(u) => u.len(), + } + } +} + +/// The pieces of a result string and the length they add up to. +#[derive(Default)] +struct Pieces { + list: Vec, + len: usize, +} + +impl Pieces { + /// Adds `piece`, holding its bytes and taking fuel for its units, and ends the run with the + /// heap limit once the result would not fit. + fn add(&mut self, interp: &mut Interp, held: &mut HeldBytes, piece: Piece) -> JsResult<()> { + let n = piece.len(); + if n == 0 { + return Ok(()); + } + push_held(interp, held, &mut self.list, piece)?; + self.len = self.len.saturating_add(n); + charge_units(interp, n)?; + check_string_room(interp, self.len) + } + + /// Joins the pieces into a new string, reserving its length first. `units` is the input and + /// `template` the replacement pattern. + fn build(&self, interp: &mut Interp, units: &[u16], template: &[u16]) -> JsResult { + reserve_string(interp, self.len)?; + let mut out = Vec::with_capacity(self.len); + for p in &self.list { + match p { + Piece::Range(a, b) => out.extend_from_slice(units.get(*a..*b).unwrap_or_default()), + Piece::Template(a, b) => out.extend_from_slice(template.get(*a..*b).unwrap_or_default()), + Piece::Units(u) => out.extend_from_slice(u), + } + } + Ok(Value::String(Str::from_units(out))) + } +} + +/// A part of a replacement pattern (section 15.5.4.11, Table 22). +#[derive(Clone, Copy)] +enum Part { + /// The pattern's own text in a range. + Text(usize, usize), + /// `$&` + Matched, + /// `` $` `` + Before, + /// `$'` + After, + /// `$n` or `$nn` + Group(usize), +} + +impl Part { + /// The piece this part gives for one match: `caps` holds the match and its groups as pairs of + /// positions in an input of `len` units. + fn piece(self, caps: &[usize], len: usize) -> Option { + let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0)); + match self { + Part::Text(a, b) => Some(Piece::Template(a, b)), + Part::Matched => Some(Piece::Range(start, end)), + Part::Before => Some(Piece::Range(0, start)), + Part::After => Some(Piece::Range(end, len)), + Part::Group(n) => { + let (s, e) = (caps.get(2 * n).copied().unwrap_or(NONE), caps.get(2 * n + 1).copied().unwrap_or(NONE)); + (s != NONE && e != NONE).then_some(Piece::Range(s, e)) + } + } + } +} + +/// Reads a replacement pattern once into its parts, for a regular expression with `groups` +/// groups. The parts are held against the heap limit, and the units read take fuel. +fn parse_template(interp: &mut Interp, held: &mut HeldBytes, template: &[u16], groups: usize) -> JsResult> { + charge_units(interp, template.len())?; + let digit = |i: usize| template.get(i).filter(|&&u| (0x30..=0x39).contains(&u)).map(|&u| usize::from(u - 0x30)); + let mut parts = Vec::new(); + let mut lit = 0; + let mut i = 0; + while let Some(&u) = template.get(i) { + if u != 0x24 { + i += 1; + continue; + } + let (part, taken) = match template.get(i + 1) { + // `$$` gives one `$`: the text up to and including the first. + Some(0x24) => { + push_held(interp, held, &mut parts, Part::Text(lit, i + 1))?; + lit = i + 2; + i += 2; + continue; + } + Some(0x26) => (Part::Matched, 2), + Some(0x60) => (Part::Before, 2), + Some(0x27) => (Part::After, 2), + _ => match (digit(i + 1), digit(i + 2)) { + (Some(a), Some(b)) if (1..=groups).contains(&(a * 10 + b)) => (Part::Group(a * 10 + b), 3), + (Some(a), _) if (1..=groups).contains(&a) => (Part::Group(a), 2), + _ => { + i += 1; + continue; + } + }, + }; + if i > lit { + push_held(interp, held, &mut parts, Part::Text(lit, i))?; + } + push_held(interp, held, &mut parts, part)?; + i += taken; + lit = i; + } + if template.len() > lit { + push_held(interp, held, &mut parts, Part::Text(lit, template.len()))?; + } + Ok(parts) +} + +/// `replace` (section 15.5.4.11). The matches, the parsed replacement pattern and the pieces of +/// the result are held against the heap limit while the result is put together, and the +/// result's length is checked against the heap as each piece is added. +fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + rooted(interp, |interp| { + let s = this_str(interp, &this, "replace")?; + let search_value = arg(args, 0); + let replace_value = arg(args, 1); + let units = flat_charged(interp, &s)?; + let mut held = HeldBytes::new(interp); + // Each match as the positions of the match and its groups. + let mut matches: Vec> = Vec::new(); + if let Some((rx, _)) = regexp_of(interp, &search_value) { + let global = interp.get_value(&search_value, &crate::heap::key("global"))?.truthy(); + if global { + let last_index = crate::heap::key("lastIndex"); + interp.put(rx, last_index.clone(), Value::Number(0.0), true)?; + let mut previous = 0.0; + while let Some(caps) = exec_raw(interp, rx, &s)? { + held.add(interp, size_of::().saturating_mul(caps.capacity()))?; + push_held(interp, &mut held, &mut matches, caps)?; + let this_index = interp.get_value(&search_value, &last_index)?; + let this_index = interp.to_number(&this_index)?; + if this_index == previous { + previous = this_index + 1.0; + interp.put(rx, last_index.clone(), Value::Number(previous), true)?; + } else { + previous = this_index; + } + } + } else if let Some(caps) = exec_raw(interp, rx, &s)? { + matches.push(caps); + } + } else { + let search = interp.to_str(&search_value)?; + let needle = flat_charged(interp, &search)?; + if let Some(i) = find(interp, &units, &needle, 0)? { + matches.push(vec![i, i + needle.len()]); + } + } + let replacer = interp.is_callable(&replace_value).then(|| replace_value.clone()); + let template = match &replacer { + Some(_) => None, + None => { + let t = interp.to_str(&replace_value)?; + Some(flat_charged(interp, &t)?) + } + }; + if matches.is_empty() { + return Ok(Value::String(s)); + } + let template: &[u16] = template.as_deref().unwrap_or_default(); + // Every match has the same number of groups. + let groups = matches.first().map_or(0, |caps| (caps.len() / 2).saturating_sub(1)); + let parts = if replacer.is_none() { parse_template(interp, &mut held, template, groups)? } else { Vec::new() }; + let mut pieces = Pieces::default(); + let mut last = 0usize; + for caps in &matches { + let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0)); + pieces.add(interp, &mut held, Piece::Range(last, start))?; + match &replacer { + Some(f) => { + let mut call_args = Vec::with_capacity(caps.len() / 2 + 2); + for pair in caps.chunks(2) { + let v = match pair { + [a, b] if *a != NONE && *b != NONE => substring_value(interp, &units, *a, *b)?, + _ => Value::Undefined, + }; + call_args.push(v); + } + call_args.push(Value::Number(start as f64)); + call_args.push(Value::String(s.clone())); + let r = interp.call(f, &Value::Undefined, &call_args)?; + let text = interp.to_str(&r)?; + let text = flat_charged(interp, &text)?; + pieces.add(interp, &mut held, Piece::Units(text))?; + } + None => { + // Reading the parts takes fuel even when they give empty pieces. + use_fuel_n(interp, (parts.len() / UNITS_PER_FUEL) as u64)?; + for part in &parts { + if let Some(p) = part.piece(caps, units.len()) { + pieces.add(interp, &mut held, p)?; + } + } + } + } + use_fuel_n(interp, 1)?; + last = end; + } + pieces.add(interp, &mut held, Piece::Range(last, units.len()))?; + pieces.build(interp, &units, template) + }) +} + +/// `split` (section 15.5.4.14). +fn split(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + rooted(interp, |interp| { + let s = this_str(interp, &this, "split")?; + let separator = arg(args, 0); + let limit = arg(args, 1); + let a = new_array(interp); + let lim = if matches!(limit, Value::Undefined) { u32::MAX } else { to_uint32(interp.to_number(&limit)?) }; + let rx = regexp_of(interp, &separator); + let sep = match &rx { + Some(_) => None, + None => { + let sep = interp.to_str(&separator)?; + Some(flat_charged(interp, &sep)?) + } + }; + if lim == 0 { + return Ok(Value::Object(a)); + } + if matches!(separator, Value::Undefined) { + set_element(interp, a, 0, Value::String(s))?; + return Ok(Value::Object(a)); + } + let units = flat_charged(interp, &s)?; + let size = units.len(); + let re: Option> = rx.map(|(_, re)| re); + let mut matcher = re.as_deref().map(|re| Matcher::new(re, interp)).transpose()?; + // SplitMatch (section 15.5.4.14): the end of a separator match at `q` and the groups. + let mut split_match = |interp: &mut Interp, q: usize| -> JsResult)>> { + match (&mut matcher, &sep) { + (Some(m), _) => { + if m.match_at(interp, &units, q)? { + Ok(Some((m.caps.get(1).copied().unwrap_or(q), m.caps.get(2..).map(<[usize]>::to_vec).unwrap_or_default()))) + } else { + Ok(None) + } + } + (None, Some(r)) => { + use_fuel_n(interp, (r.len() / UNITS_PER_FUEL) as u64 + 1)?; + if units.get(q..q + r.len()) == Some(&r[..]) { Ok(Some((q + r.len(), Vec::new()))) } else { Ok(None) } + } + (None, None) => Ok(None), + } + }; + if size == 0 { + if split_match(interp, 0)?.is_none() { + set_element(interp, a, 0, Value::String(s.clone()))?; + } + return Ok(Value::Object(a)); + } + let mut n = 0usize; + let (mut p, mut q) = (0usize, 0usize); + while q != size { + match split_match(interp, q)? { + None => q += 1, + Some((e, _)) if e == p => q += 1, + Some((e, caps)) => { + let t = substring_value(interp, &units, p, q)?; + set_element(interp, a, n, t)?; + n += 1; + if n as u64 == u64::from(lim) { + return Ok(Value::Object(a)); + } + p = e; + for pair in caps.chunks(2) { + let v = match pair { + [x, y] if *x != NONE && *y != NONE => substring_value(interp, &units, *x, *y)?, + _ => Value::Undefined, + }; + set_element(interp, a, n, v)?; + n += 1; + if n as u64 == u64::from(lim) { + return Ok(Value::Object(a)); + } + } + q = p; + } + } + } + let t = substring_value(interp, &units, p, size)?; + set_element(interp, a, n, t)?; + Ok(Value::Object(a)) + }) +} diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs new file mode 100644 index 000000000..c873bdaf5 --- /dev/null +++ b/crates/js/src/text_tests.rs @@ -0,0 +1,633 @@ +//! Tests of `String`, `Number`, `RegExp` and `Date` run through a realm. + +use proptest::prelude::*; + +use crate::regexp_vm::{INST_BYTES, ITEM_BYTES}; +use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind}; + +fn run_in(realm: &mut Realm, src: &str) -> JsValue { + match realm.run(src) { + Ok(v) => v, + Err(e) => panic!("{src}\n{e:?}"), + } +} + +fn run(src: &str) -> JsValue { + run_in(&mut Realm::new(), src) +} + +fn string(src: &str) -> String { + match run(src) { + JsValue::String(s) => s, + other => panic!("{src}: {other:?}"), + } +} + +/// Script helpers that need no `Array.prototype` method: `show(a, sep)` joins an array (`null` +/// shows as `null`, `undefined` elements as nothing), `nan(n)` tells NaN apart. +const HELPERS: &str = "function show(a, sep) { if (a === null) return 'null'; var s = ''; \ + for (var i = 0; i < a.length; i++) s += (i ? (sep === undefined ? ',' : sep) : '') + (a[i] === undefined ? '' : a[i]); return s; } \ + function nan(n) { return n !== n ? 'NaN' : 'num'; }\n"; + +/// Runs `src` after [`HELPERS`] and checks it gives the string `want`. +fn shows(src: &str, want: &str) { + match run(&format!("{HELPERS}{src}")) { + JsValue::String(s) => assert_eq!(s, want, "{src}"), + other => panic!("{src}: {other:?}"), + } +} + +fn truth(src: &str) { + assert_eq!(run(src), JsValue::Bool(true), "{src}"); +} + +fn fail_with(mut realm: Realm, src: &str) -> RunError { + match realm.run(src) { + Ok(v) => panic!("{src} completed with {v:?}"), + Err(e) => e, + } +} + +fn thrown(src: &str) -> String { + let e = fail_with(Realm::new(), src); + assert_eq!(e.kind, RunErrorKind::Thrown, "{src}: {e:?}"); + e.message +} + +// ---- String ---- + +#[test] +fn string_converts_and_wraps() { + assert_eq!(string("String(12.5) + String() + String(null)"), "12.5null"); + truth("var s = new String('ab'); typeof s === 'object' && s.length === 2 && s[1] === 'b' && s.valueOf() === 'ab'"); + assert_eq!(string("String.fromCharCode(104, 105, 65536 + 33)"), "hi!"); + assert!(thrown("String.prototype.toString.call({})").starts_with("TypeError")); + assert!(thrown("String.prototype.trim.call(null)").starts_with("TypeError")); +} + +#[test] +fn string_methods_follow_section_15_5_4() { + let cases: &[(&str, &str)] = &[ + ("'abc'.charAt(1) + 'abc'.charAt(5) + 'abc'.charAt(-1)", "b"), + ("'' + 'abc'.charCodeAt(0) + nan('abc'.charCodeAt(3))", "97NaN"), + ("'a'.concat('b', 1, null)", "ab1null"), + ("'' + 'abcabc'.indexOf('c') + 'abcabc'.indexOf('c', 3) + 'abc'.indexOf('') + 'abc'.indexOf('', 9)", "2503"), + ("'' + 'abcabc'.lastIndexOf('c') + 'abcabc'.lastIndexOf('c', 4) + 'abc'.lastIndexOf('x')", "52-1"), + ("'' + 'a'.localeCompare('b') + 'b'.localeCompare('a') + 'a'.localeCompare('a')", "-110"), + ("'abcdef'.slice(1, -1) + '|' + 'abcdef'.slice(-2) + '|' + 'abc'.slice(2, 1)", "bcde|ef|"), + ("'abcdef'.substring(4, 1) + '|' + 'abcdef'.substring(-3, 2) + '|' + 'abc'.substring(1)", "bcd|ab|bc"), + ("'abcdef'.substr(-3, 2) + '|' + 'abcdef'.substr(1) + '|' + 'abc'.substr(1, -1)", "de|bcdef|"), + ("'\\u00dfa\\u03a3'.toUpperCase() + '|' + 'A\\u03a3'.toLowerCase() + '|' + 'A\\u03a3b'.toLowerCase()", "SSA\u{3a3}|a\u{3c2}|a\u{3c3}b"), + ("'[' + ' \\t\\n\\u00a0\\ufeffx y\\u2028 '.trim() + ']'", "[x y]"), + ("show('a,b,,c'.split(','), '|')", "a|b||c"), + ("show('abc'.split(''), '|') + ';' + 'abc'.split().length + ';' + show('abc'.split('', 2), '|')", "a|b|c;1;a|b"), + ("''.split('').length + ';' + ''.split('x').length", "0;1"), + ("show('a1b22c'.split(/(\\d)+/), '|')", "a|1|b|2|c"), + ("show('ab'.split(/(?:)/), '|') + ';' + show('ab'.split(/a*?/), '|')", "a|b;a|b"), + ("'aaa'.replace('a', '$&$&') + ';' + 'abc'.replace('b', '[$`|$\\'|$$]')", "aaaa;a[a|c|$]c"), + ("'John Smith'.replace(/(\\w+)\\s(\\w+)/, '$2, $1') + ';' + 'x'.replace(/x/, '$1$01$10')", "Smith, John;$1$01$10"), + ("'a-b-c'.replace(/-/g, function (m, i, s) { return '[' + i + s.length + ']'; })", "a[15]b[35]c"), + ("'aaa'.replace(/a*?/g, '-')", "-a-a-a-"), + ("'abc'.match(/b/).index + ';' + show('abab'.match(/a(b)/g), '|') + ';' + ('x'.match(/y/g) === null)", "1;ab|ab;true"), + ("'' + 'abc'.search(/c/) + 'abc'.search('b') + 'abc'.search(/x/)", "21-1"), + ]; + for (src, want) in cases { + shows(src, want); + } +} + +#[test] +fn string_methods_count_against_the_heap_before_allocating() { + // Doubling with `concat` and `replace` stops at the heap limit, not at an allocation failure. + let limits = Limits { heap_bytes: 8 << 20, ..Limits::default() }; + for src in [ + "var s = 'x'; for (;;) s = s.concat(s);", + "var s = 'xx'; for (;;) s = s.replace(/x/g, 'xx');", + "var s = 'x'; for (;;) s = (s + s).toUpperCase();", + "var s = 'x'; for (;;) s = (s + s + s).slice(0) + s.substr(0);", + ] { + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{src}: {e:?}"); + } + // A replacement that would build a string past the heap limit is refused before it is built. + let limits = Limits { heap_bytes: 4 << 20, ..Limits::default() }; + let e = fail_with( + Realm::with_options(Options::default(), limits), + "var big = 'yyyyyyyyyyyyyyyy'; for (var i = 0; i < 15; i++) big = big + big; 'xxxxxxxxxx'.replace(/x/g, big)", + ); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); +} + +// ---- Number ---- + +#[test] +fn numbers_format_as_section_15_7_4_says() { + let cases: &[(&str, &str)] = &[ + ("(255).toString(16) + ' ' + (-255).toString(2) + ' ' + (0.5).toString(2) + ' ' + (1e21).toString(36)", "ff -11111111 0.1 5v1j4f4ds7c000"), + ("(123.456).toString() + ' ' + (1e21).toString(10) + ' ' + (0.000001).toString()", "123.456 1e+21 0.000001"), + ("(0.5).toFixed(0) + ' ' + (1.255).toFixed(2) + ' ' + (0.1).toFixed(20) + ' ' + (1e21).toFixed(2)", "1 1.25 0.10000000000000000555 1e+21"), + ("(123456).toExponential(2) + ' ' + (123456).toExponential() + ' ' + (0).toExponential(2)", "1.23e+5 1.23456e+5 0.00e+0"), + ( + "(123.456).toPrecision(4) + ' ' + (0.0000001234).toPrecision(2) + ' ' + (123456).toPrecision(2) + ' ' + (5).toPrecision()", + "123.5 1.2e-7 1.2e+5 5", + ), + ( + "(-1.5).toFixed(0) + ' ' + (NaN).toFixed(2) + ' ' + (Infinity).toExponential(50) + ' ' + (-Infinity).toPrecision(50)", + "-2 NaN Infinity -Infinity", + ), + ("new Number(5).toString(2) + ' ' + Number('0x10') + ' ' + Number() + ' ' + Number.MIN_VALUE", "101 16 0 5e-324"), + ]; + for (src, want) in cases { + assert_eq!(string(src), *want, "{src}"); + } + truth("Number.MAX_VALUE === 1.7976931348623157e308 && Number.POSITIVE_INFINITY === 1/0 && Number.NaN !== Number.NaN"); + truth( + "typeof new Number(1) === 'object' && new Number(7).valueOf() === 7 && Object.prototype.toString.call(Number.prototype) === '[object Number]'", + ); + for src in [ + "(1).toFixed(21)", + "(1).toFixed(-1)", + "(1).toExponential(21)", + "(1).toPrecision(0)", + "(1).toPrecision(22)", + "(1).toString(1)", + "(1).toString(37)", + ] { + assert!(thrown(src).starts_with("RangeError"), "{src}"); + } + assert!(thrown("Number.prototype.toFixed.call('1', 2)").starts_with("TypeError")); +} + +// ---- RegExp ---- + +#[test] +fn regular_expressions_match_as_section_15_10_2_says() { + let cases: &[(&str, &str)] = &[ + ("show(/a(b)?c/.exec('ac'))", "ac,"), + ("show(/(z)((a+)?(b+)?(c))*/.exec('zaacbbbcac'))", "zaacbbbcac,z,ac,a,,c"), + ("show(/(a*)*/.exec('b'))", ","), + ("show(/(a*)b\\1+/.exec('baaaac'))", "b,"), + ("show(/(?=(a+))/.exec('baaabac'))", ",aaa"), + ("show(/(?=(a+))a*b\\1/.exec('baaabac'))", "aba,a"), + ("show(/(.*?)a(?!(a+)b\\2c)\\2(.*)/.exec('baaabaac'))", "baaabaac,ba,,abaac"), + ("show(/((a)|b)+/.exec('ab'))", "ab,b,"), + ("show(/a[a-z]{2,4}/.exec('abcdefghi'))", "abcde"), + ("show(/a[a-z]{2,4}?/.exec('abcdefghi'))", "abc"), + ("show(/(aa|aabaac|ba|b|c)*/.exec('aabaac'))", "aaba,ba"), + ("show(/[^\\d\\s]+/.exec('12 ab3'))", "ab"), + ("show(/\\bfoo\\B/.exec('a foox'))", "foo"), + ("show(/^b/m.exec('a\\nb')) + /^b/.test('a\\nb')", "bfalse"), + ("/ß/i.test('SS') + ' ' + show(/[a-z]+/i.exec('HeLLo')) + ' ' + /\\u017f/i.test('s')", "false HeLLo false"), + ("/[\\u017f]/i.test('S') + ' ' + /k/i.test('\\u212a')", "false false"), + ("show(/\\1(a)/.exec('aa')) + ';' + show(/(a\\1)/.exec('aa'))", "a,a;a,a"), + ("/\\0\\x41\\u0042\\cJ\\101/.test('\\0AB\\nA') + ' ' + /[\\b]/.test('\\b') + ' ' + /a{,2}/.test('a{,2}')", "true true true"), + ("/]/.test(']') + ' ' + show(/[\\d-z]+/.exec('5-z')) + ' ' + /\\8/.test('8')", "true 5-z true"), + ("/(?:a|b)*c/.exec('ababc').index + ' ' + /x*/.exec('yyy').index", "0 0"), + ("/[]/.test('a') + ' ' + /[^]/.test('\\n')", "false true"), + // An octal escape starting with 4 to 7 takes two digits, so `\477-z` is `'` and the range `7-z`. + ("show(/[\\477-z]+/.exec(\"'8y\")) + ' ' + new RegExp('[\\\\477-z]').test('8') + ' ' + /[\\377]/.test('\\xff')", "'8y true true"), + ]; + for (src, want) in cases { + shows(src, want); + } +} + +#[test] +fn a_realm_made_under_low_limits_has_every_built_in() { + let low = Limits { fuel: 0, heap_bytes: 1, ..Limits::default() }; + let mut realm = Realm::with_options(Options::default(), low.clone()); + assert_eq!(realm.limits(), &low); + realm.set_limits(Limits::default()); + assert_eq!( + run_in(&mut realm, "typeof String + typeof Number + typeof RegExp + typeof Date + typeof /a/.exec + typeof new Date(0).getTime"), + JsValue::String("function".repeat(6)) + ); +} + +#[test] +fn regexp_objects_follow_sections_15_10_4_to_15_10_7() { + truth("var r = /a/g; r.lastIndex === 0 && r.global && !r.ignoreCase && !r.multiline && r.source === 'a'"); + truth( + "var r = /a/g; r.exec('aa'); var i = r.lastIndex; r.exec('aa'); var j = r.lastIndex; r.exec('aa'); i === 1 && j === 2 && r.lastIndex === 0", + ); + truth("var r = /a/; r.lastIndex = 5; r.test('a') && r.lastIndex === 5"); + truth("var r = new RegExp('a/b', 'im'); r.source === 'a\\\\/b' && r.ignoreCase && r.multiline && String(r) === '/a\\\\/b/im'"); + truth("String(new RegExp()) === '/(?:)/' && new RegExp('').test('x')"); + truth("var r = new RegExp('[/]'); String(r) === '/[/]/' && eval(String(r)).test('/') && eval(String(new RegExp('a/'))).source === 'a\\\\/'"); + truth("var r = /x/g; RegExp(r) === r && new RegExp(r) !== r && new RegExp(r).global"); + truth("var m = /(a)|(b)/.exec('b'); m.length === 3 && m[1] === undefined && m[2] === 'b' && m.index === 0 && m.input === 'b'"); + truth("Object.prototype.toString.call(RegExp.prototype) === '[object RegExp]' && Object.prototype.toString.call(/x/) === '[object RegExp]'"); + truth("var p = RegExp.prototype; p.hasOwnProperty('global') && p.hasOwnProperty('lastIndex') && p.source === '(?:)' && p.global === false"); + for src in ["new RegExp('(')", "new RegExp('a', 'gg')", "new RegExp('a', 'x')", "new RegExp('a**')", "new RegExp('[b-a]')"] { + assert!(thrown(src).starts_with("SyntaxError"), "{src}"); + } + assert!(thrown("new RegExp(/a/, 'g')").starts_with("TypeError")); + assert!(thrown("RegExp.prototype.exec.call({}, 'a')").starts_with("TypeError")); +} + +#[test] +fn a_catastrophic_regular_expression_ends_by_fuel() { + let limits = Limits { fuel: 2_000_000, ..Limits::default() }; + let src = "var s = ''; for (var i = 0; i < 40; i++) s += 'a'; /(a+)+$/.test(s + 'b')"; + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{e:?}"); + // The interrupt flag stops it too. + let realm = Realm::with_options(Options::default(), Limits { fuel: u64::MAX, ..Limits::default() }); + realm.interrupt().set(); + let e = fail_with(realm, src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Interrupted), "{e:?}"); +} + +#[test] +fn trim_and_date_parsing_take_fuel_for_the_units_they_scan() { + let limits = Limits { fuel: 40_000, ..Limits::default() }; + let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; s.length"; + assert!(Realm::with_options(Options::default(), limits.clone()).run(build).is_ok()); + for scan in ["s.trim()", "Date.parse(s)", "new Date(s)"] { + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &format!("{build}; {scan}")); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{scan}: {e:?}"); + } +} + +#[test] +fn global_replace_counts_its_matches_against_the_heap() { + let src = "var s = 'ab'; for (var i = 0; i < 17; i++) s = s + s; s.replace(/./g, '').length"; + let e = fail_with(Realm::with_options(Options::default(), Limits { heap_bytes: 4 << 20, ..Limits::default() }), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); + assert_eq!(run(src), JsValue::Number(0.0)); +} + +#[test] +fn replace_holds_its_pieces_and_checks_the_result_as_it_grows() { + // Each `$&` gives a piece for every match: the pieces count against the heap, so the run ends + // with the heap limit long before the 4,194,304 pieces would take the fuel. + let limits = Limits { heap_bytes: 4 << 20, fuel: 500_000, ..Limits::default() }; + let src = "var s = 'a'; for (var i = 0; i < 12; i++) s += s; var t = '$&'; for (var i = 0; i < 10; i++) t += t; s.replace(/a/g, t).length"; + let e = fail_with(Realm::with_options(Options::default(), limits), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); + // A long literal pattern is not copied for each match, and the result's length is checked as + // the pieces are added, so the run ends with the heap limit before the fuel for 16,384 + // copies of the pattern is spent. + let limits = Limits { heap_bytes: 4 << 20, fuel: 1_000_000, ..Limits::default() }; + let src = "var s = 'a'; for (var i = 0; i < 14; i++) s += s; var t = 'x'; for (var i = 0; i < 11; i++) t += t; s.replace(/a/g, t).length"; + let e = fail_with(Realm::with_options(Options::default(), limits), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); + // The parts of the pattern give the same result as before. + shows("'abcabc'.replace(/(b)(c)/g, '[$$|$&|$`|$\\'|$2$1|$3|$0|$10]')", "a[$|bc|a|abc|cb|$3|$0|b0]a[$|bc|abca||cb|$3|$0|b0]"); +} + +#[test] +fn flattening_a_rope_counts_against_the_heap() { + // Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap. + let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';"; + let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; + let built = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} s.length + t.length + u.length")); + assert_eq!(built, Ok(JsValue::Number(1_572_866.0))); + for read in [ + "charAt(0)", + "charCodeAt(0)", + "indexOf('q')", + "lastIndexOf('q')", + "localeCompare('a')", + "slice(0, 1)", + "substring(0, 1)", + "substr(0, 1)", + "trim()", + "match(/q/)", + "search(/q/)", + "replace(/q/, '')", + "replace('q', '')", + ] { + let src = format!("{build} s.{read}; t.{read}; u.{read}; 0"); + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{read}: {e:?}"); + } + for call in ["/q/.exec(X)", "/q/.test(X)", "Date.parse(X)", "new RegExp(X)"] { + let src = format!("{build} {}; {}; {}; 0", call.replace('X', "s"), call.replace('X', "t"), call.replace('X', "u")); + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{call}: {e:?}"); + } +} + +#[test] +fn compiling_a_pattern_counts_against_the_heap() { + let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; + let e = fail_with(Realm::with_options(Options::default(), Limits { heap_bytes: 2 << 20, ..Limits::default() }), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); + assert_eq!(run(src), JsValue::Number(131_072.0)); + // The compiler stops while it reads the pattern, before the code it builds passes the room. + let pattern = "[\\s\\S]a*".repeat(1000); + assert!(stopped_by(&compile_within(&pattern, 400 * (ITEM_BYTES + INST_BYTES), u64::MAX).0, LimitKind::Heap)); + assert!(compile_within(&pattern, usize::MAX, u64::MAX).0.is_ok()); + // A literal pattern takes what its instructions hold, not the most a unit can take. + assert!(compile_within(&"a".repeat(10_000), 20_000 * (ITEM_BYTES + INST_BYTES), u64::MAX).0.is_ok()); + // A class's ranges count while they are read, even when they collapse into one range. + assert!(stopped_by(&compile_within(&format!("[{}]", "a".repeat(10_000)), 1000, u64::MAX).0, LimitKind::Heap)); + // So do the ranges of class escapes, and each atom and range read takes fuel. + let escapes = format!("[{}]", "\\w".repeat(10_000)); + assert!(stopped_by(&compile_within(&escapes, 64 << 10, u64::MAX).0, LimitKind::Heap)); + assert!(stopped_by(&compile_within(&escapes, usize::MAX, 20_000).0, LimitKind::Fuel)); + // The lists count with the room they reserve as they double: 4097 literal units hold room for + // 8192 items, and the flattened code after the last unit adds 4098 instructions. + let literal = "a".repeat(4097); + let (compiled, bytes, _) = compile_within(&literal, usize::MAX, u64::MAX); + assert!(compiled.is_ok()); + assert!(bytes >= 8192 * ITEM_BYTES + 4098 * INST_BYTES, "{bytes}"); + assert!(stopped_by(&compile_within(&literal, 8192 * ITEM_BYTES + 4000 * INST_BYTES, u64::MAX).0, LimitKind::Heap)); +} + +#[test] +fn compiling_a_pattern_takes_fuel_for_each_scan_of_it() { + // The scan for capturing groups takes a unit of fuel for each unit of the pattern, and so does + // the compile itself. + let literal = "a".repeat(100_000); + let (compiled, _, fuel) = compile_within(&literal, usize::MAX, u64::MAX); + assert!(compiled.is_ok()); + assert!(fuel >= 200_000, "{fuel}"); + assert!(stopped_by(&compile_within(&literal, usize::MAX, 150_000).0, LimitKind::Fuel)); + // The syntax check before compiling takes a unit for each unit too. + let mut realm = Realm::new(); + let interp = realm.interp(); + let (source, flags) = (crate::value::Str::from(literal.as_str()), crate::value::Str::from("")); + interp.fuel = 1 << 40; + assert!(crate::regexp_builtins::new_regexp(interp, &source, &flags).is_ok()); + let used = (1u64 << 40) - interp.fuel; + assert!(used >= 300_000, "{used}"); + interp.fuel = 250_000; + assert!(matches!(crate::regexp_builtins::new_regexp(interp, &source, &flags), Err(crate::interp::Abrupt::Limit(LimitKind::Fuel)))); +} + +#[test] +fn held_bytes_leave_the_heap_total_when_their_holder_is_dropped() { + let mut realm = Realm::new(); + let interp = realm.interp(); + interp.collect_for_room(); + let live = interp.heap.bytes; + interp.limits.heap_bytes = live + 10_000; + let mut held = crate::interp::HeldBytes::new(interp); + assert!(held.add(interp, 6000).is_ok()); + assert_eq!(interp.heap.bytes, live + 6000); + assert!(matches!(crate::interp::HeldBytes::new(interp).add(interp, 6000), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + drop(held); + // The next check gives the bytes back without a collection. + assert!(interp.reserve_heap(0).is_ok()); + assert_eq!(interp.heap.bytes, live); + let mut again = crate::interp::HeldBytes::new(interp); + assert!(again.add(interp, 6000).is_ok()); + assert_eq!(interp.heap.bytes, live + 6000); +} + +/// Compiles `pattern` with no flags through a charge that allows `heap` bytes and `fuel` units, +/// and returns the result with the bytes and fuel the compiler asked for. +fn compile_within(pattern: &str, heap: usize, fuel: u64) -> (Result, usize, u64) { + use crate::regexp_vm::Cost; + let units: Vec = pattern.encode_utf16().collect(); + let flags = crate::regexp_vm::parse_flags(&[]).unwrap(); + let (mut bytes, mut used) = (0usize, 0u64); + let result = crate::regexp_vm::compile(&units, flags, 200, &mut |cost| { + match cost { + Cost::Heap(n) => bytes = bytes.saturating_add(n), + Cost::Fuel(n) => used = used.saturating_add(n), + } + if bytes > heap { + return Err(crate::interp::Abrupt::Limit(LimitKind::Heap)); + } + if used > fuel { + return Err(crate::interp::Abrupt::Limit(LimitKind::Fuel)); + } + Ok(()) + }); + (result, bytes, used) +} + +/// Whether compiling stopped with the limit `kind`. +fn stopped_by(result: &Result, kind: LimitKind) -> bool { + matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(k))) if *k == kind) +} + +#[test] +fn compiling_a_pattern_collects_garbage_when_the_heap_is_full() { + let mut realm = Realm::with_options(Options::default(), Limits { heap_bytes: 256 << 10, ..Limits::default() }); + let src = "var n = 0; for (var i = 0; i < 5000; i++) { var junk = 'x' + i + 'y' + i; new RegExp('(a|b)c[d-f]'); n++; } n"; + assert_eq!(run_in(&mut realm, src), JsValue::Number(5000.0)); +} + +#[test] +fn a_constructed_source_counts_against_the_heap_before_it_is_built() { + let mut realm = Realm::new(); + let interp = realm.interp(); + interp.collect_for_room(); + let live = interp.heap.bytes; + let pattern = vec![0x2028u16; 10_000]; + interp.limits.heap_bytes = live + 50_000; + assert!(matches!(crate::regexp_builtins::source_text(interp, &pattern), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + interp.limits.heap_bytes = live + (1 << 20); + let Ok(source) = crate::regexp_builtins::source_text(interp, &pattern) else { panic!("the source fits") }; + assert_eq!(source.flat().len(), 60_000); + assert_eq!(string("String(new RegExp('a/\\\\/[/]\\n\\r\\u2028\\u2029'))"), "/a\\/\\/[/]\\n\\r\\u2028\\u2029/"); + // A backslash before a line terminator reads back as the terminator's escape. + let escaped = "var r = new RegExp(String.fromCharCode(92, 10, 124, 92, 0x2028)); var back = eval(String(r));"; + assert_eq!( + string(&format!("{escaped} String(r) + ' ' + r.test('\\n') + back.test('\\n') + back.test('\\u2028') + back.test('x')")), + "/\\n|\\u2028/ truetruetruefalse" + ); +} + +#[test] +fn the_matcher_state_counts_against_the_heap() { + let mut realm = Realm::with_options(Options::default(), Limits { heap_bytes: 1 << 20, ..Limits::default() }); + let Ok(re) = compile_within(&"(a)".repeat(1000), usize::MAX, u64::MAX).0 else { panic!("the pattern compiles") }; + let interp = realm.interp(); + interp.collect_for_room(); + let live = interp.heap.bytes; + interp.limits.heap_bytes = live + 1000; + assert!(matches!(crate::regexp_vm::Matcher::new(&re, interp), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + interp.limits.heap_bytes = 1 << 20; + assert!(crate::regexp_vm::Matcher::new(&re, interp).is_ok()); + // The stack takes only the room left: with room for the state and no more, backtracking ends + // with the heap limit. + let Ok(re) = compile_within("a*b", usize::MAX, u64::MAX).0 else { panic!("the pattern compiles") }; + let input: Vec = "aaaaaaaaaa".encode_utf16().collect(); + interp.limits.heap_bytes = live + 64; + let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") }; + assert!(matches!(m.search(interp, &input, 0), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + drop(m); + interp.limits.heap_bytes = 1 << 20; + let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") }; + assert!(matches!(m.search(interp, &input, 0), Ok(false))); + // What the matcher holds is on the heap while it lives, so other allocations cannot use the + // same room, and a collection after it is dropped takes it off again. + let held = m.held_bytes(); + assert!(held > 0); + assert!(interp.heap.bytes >= live + held, "{} < {live} + {held}", interp.heap.bytes); + interp.collect_for_room(); + assert!(interp.heap.bytes >= live + held, "{} < {live} + {held}", interp.heap.bytes); + drop(m); + interp.collect_for_room(); + assert_eq!(interp.heap.bytes, live); +} + +#[test] +fn a_backreference_takes_fuel_for_the_units_it_compares() { + let mut realm = Realm::with_options(Options::default(), Limits::default()); + let interp = realm.interp(); + let mut fuel_for = |pattern: &str, input: &str, want: bool| { + let input: Vec = input.encode_utf16().collect(); + let Ok(re) = compile_within(pattern, usize::MAX, u64::MAX).0 else { panic!("the pattern compiles") }; + let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") }; + interp.fuel = 1 << 40; + assert!(matches!(m.match_at(interp, &input, 0), Ok(hit) if hit == want), "{pattern}"); + (1u64 << 40) - interp.fuel + }; + // The same 20,000 iterations, each running a one-unit backreference instead of a character: + // each comparison takes a step besides the instruction's own. + let a = "a".repeat(20_001); + let (by_char, by_backref) = (fuel_for("(a)(?:a)*$", &a, true), fuel_for("(a)(?:\\1)*$", &a, true)); + assert!(by_backref >= by_char + 20_000, "{by_backref} < {by_char} + 20000"); + // A long comparison takes a step for each 16 units it compares. + let long = fuel_for("^(a{10000})\\1", &a, true); + let plain = fuel_for("^(a{10000})", &a, true); + assert!(long >= plain + 625, "{long} < {plain} + 625"); + // One that fails on its first unit takes a step for those 16 units only. + let ab = format!("{}{}", "a".repeat(10_000), "b".repeat(10_000)); + let (by_backref, by_char) = (fuel_for("^(a{10000})\\1", &ab, false), fuel_for("^(a{10000})c", &ab, false)); + assert!(by_backref <= by_char + 2, "{by_backref} > {by_char} + 2"); +} + +#[test] +fn clearing_matcher_state_takes_fuel() { + let limits = Limits { fuel: 500_000, ..Limits::default() }; + let groups = "var p = ''; for (var i = 0; i < 1000; i++) p += '(x)'; var s = ''; for (var i = 0; i < 2000; i++) s += 'a';"; + // Each iteration of the `*` clears the 1000 groups inside it, though only `a` runs. + let src = format!("{groups} new RegExp('(?:a|' + p + ')*').test(s)"); + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{e:?}"); + // Each attempt at a new position clears the state of all 1001 groups. + let src = format!("{groups} new RegExp(p + 'z').test(s.replace(/a/g, 'b'))"); + let e = fail_with(Realm::with_options(Options::default(), limits), &src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{e:?}"); +} + +#[test] +fn deeply_nested_groups_give_a_syntax_error_and_never_overflow_the_stack() { + let deep = |n: usize| format!("{}a{}", "(".repeat(n), ")".repeat(n)); + let src = format!("try {{ new RegExp('{}'); 'compiled' }} catch (e) {{ e.name }}", deep(50_000)); + assert_eq!(run(&src), JsValue::String("SyntaxError".into())); + // With the depth limit raised, the same nesting compiles and matches on a small stack: the + // compiler and the matcher keep their state on the heap. + let handle = std::thread::Builder::new() + .stack_size(256 << 10) + .spawn(move || { + let options = Options { max_depth: 60_000, ..Options::default() }; + let mut realm = Realm::with_options(options, Limits::default()); + let src = format!("var m = new RegExp('{}').exec('xa'); m.length + ':' + m[50000]", deep(50_000)); + realm.run(&src) + }) + .unwrap(); + assert_eq!(handle.join().unwrap(), Ok(JsValue::String("50001:a".into()))); +} + +#[test] +fn the_matcher_stack_counts_against_the_heap() { + let limits = Limits { heap_bytes: 4 << 20, ..Limits::default() }; + let src = "var s = 'ab'; for (var i = 0; i < 18; i++) s = s + s; /(?:a|b)*c/.test(s)"; + let e = fail_with(Realm::with_options(Options::default(), limits), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); +} + +proptest! { + #[test] + fn arbitrary_patterns_and_inputs_never_panic(pattern in "[ab()|*+?{}\\[\\]^$.\\\\dws0-9,:=!-]{0,24}", input in "[ab\\n0-9 ]{0,24}", flags in "[gim]{0,2}") { + let mut realm = Realm::with_options(Options::default(), Limits { fuel: 200_000, ..Limits::default() }); + let quote = |s: &str| s.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n"); + let src = format!( + "try {{ var r = new RegExp('{}', '{}'); var s = '{}'; [r.exec(s), s.replace(r, '$1'), s.split(r), s.match(r), s.search(r)].length }} catch (e) {{ e.name }}", + quote(&pattern), flags, quote(&input) + ); + match realm.run(&src) { + Ok(_) => {} + Err(e) => prop_assert!(matches!(e.kind, RunErrorKind::Limit(_)), "{src}: {e:?}"), + } + } +} + +// ---- Date ---- + +proptest! { + #[test] + fn arbitrary_date_strings_never_panic(text in "[0-9TZ:.+ ,()a-zA-Z-]{0,32}") { + let mut realm = Realm::new(); + let src = format!("var t = Date.parse('{text}'); new Date('{text}').toString(); t"); + prop_assert!(realm.run(&src).is_ok(), "{src}"); + } +} + +fn date_realm(now: f64, offset_minutes: f64) -> Realm { + let mut realm = Realm::new(); + realm.set_clock(move || now); + realm.set_time_zone(move |_| offset_minutes * 60_000.0); + realm +} + +#[test] +fn dates_read_the_host_clock_and_time_zone() { + // 2026-11-07T12:34:56.789Z. + let now = 1_794_054_896_789.0; + let mut utc = date_realm(now, 0.0); + assert_eq!(run_in(&mut utc, "Date.now()"), JsValue::Number(now)); + assert_eq!(run_in(&mut utc, "new Date().getTime()"), JsValue::Number(now)); + assert_eq!(run_in(&mut utc, "new Date().toISOString()"), JsValue::String("2026-11-07T12:34:56.789Z".into())); + assert_eq!(run_in(&mut utc, "String(new Date())"), JsValue::String("Sat Nov 07 2026 12:34:56 GMT+0000".into())); + assert_eq!(run_in(&mut utc, "Date()"), JsValue::String("Sat Nov 07 2026 12:34:56 GMT+0000".into())); + assert_eq!(run_in(&mut utc, "new Date().toUTCString()"), JsValue::String("Sat, 07 Nov 2026 12:34:56 GMT".into())); + let mut east = date_realm(now, 90.0); + assert_eq!(run_in(&mut east, "String(new Date())"), JsValue::String("Sat Nov 07 2026 14:04:56 GMT+0130".into())); + assert_eq!( + run_in(&mut east, "var d = new Date(); d.getHours() + ',' + d.getUTCHours() + ',' + d.getMinutes() + ',' + d.getTimezoneOffset()"), + JsValue::String("14,12,4,-90".into()) + ); + assert_eq!(run_in(&mut east, "new Date(2026, 10, 7, 14, 4, 56, 789).getTime()"), JsValue::Number(now)); + // An offset of a day or more, or one that is not finite, counts as 0. + for offset in [f64::MAX, 86_400_000.0, f64::NAN] { + let mut odd = Realm::new(); + odd.set_clock(move || now); + odd.set_time_zone(move |_| offset); + assert_eq!(run_in(&mut odd, "var d = new Date(); d.getHours() + ',' + d.getTimezoneOffset()"), JsValue::String("12,0".into()), "{offset}"); + } + let mut west = date_realm(now, -300.0); + assert_eq!(run_in(&mut west, "new Date(2026, 0, 31).toDateString()"), JsValue::String("Sat Jan 31 2026".into())); +} + +#[test] +fn dates_round_trip_through_their_strings() { + let mut realm = date_realm(1_794_054_896_789.0, -420.0); + for src in [ + "var d = new Date(); Date.parse(d.toString()) === Math_floor(d.getTime())", + "var d = new Date(); Date.parse(d.toUTCString()) === Math_floor(d.getTime())", + "var d = new Date(); Date.parse(d.toISOString()) === d.getTime()", + "var d = new Date(-1e14); Date.parse(d.toISOString()) === d.getTime() && Date.parse(d.toString()) === d.getTime()", + "new Date(new Date(5).toISOString()).getTime() === 5", + "Date.UTC(2000, 1, 29) === Date.parse('2000-02-29') && Date.UTC(99, 0) === Date.UTC(1999, 0)", + "Date.UTC(2000) === Date.UTC(2000, 0) && Date.UTC(1970) === 0 && isNaN_(Date.UTC())", + "isNaN_(Date.parse('nonsense')) && isNaN_(new Date(8.64e15 + 1).getTime()) && new Date(8.64e15).getTime() === 8.64e15", + "var d = new Date(2000, 0, 31); d.setMonth(1); d.getMonth() === 2 && d.getDate() === 2", + "var d = new Date(NaN); d.setFullYear(2001); d.getFullYear() === 2001 && d.getMonth() === 0", + "var d = new Date(0); d.setUTCHours(25, 61, 61, 1001); d.toISOString() === '1970-01-02T02:02:02.001Z'", + "var d = new Date(0); d.setMinutes(); isNaN_(d.getTime())", + "var d = new Date(2000, 0, 1); d.setYear(99); d.getFullYear() === 1999 && d.getYear() === 99", + "Date.prototype.toGMTString === Date.prototype.toUTCString && String(new Date(NaN)) === 'Invalid Date'", + "new Date(0).toJSON() === '1970-01-01T00:00:00.000Z' && new Date(NaN).toJSON() === null", + "Object.prototype.toString.call(Date.prototype) === '[object Date]' && isNaN_(Date.prototype.getTime())", + "var d = new Date(0); d + d === d.toString() + d.toString() && d - d === 0 && d == d.toString()", + "isNaN_(Date.parse('1970-00-01')) && isNaN_(Date.parse('1970-13-01')) && isNaN_(Date.parse('1970-02-30'))", + "Date.parse('thu, 01 JAN 1970 00:00:00 gmt') === 0 && Date.parse('jan 2 1970 utc+01:00') === 82800000", + "isNaN_(Date.parse('Jan 1 1970 1:2:3:4 GMT')) && isNaN_(Date.parse('Jan 1 1970 GMT+01000')) && isNaN_(Date.parse('Jan 1 1970 x'))", + ] { + let full = format!("function Math_floor(t) {{ return t - t % 1000; }} function isNaN_(n) {{ return n !== n; }} {src}"); + assert_eq!(run_in(&mut realm, &full), JsValue::Bool(true), "{src}"); + } + let e = fail_with(date_realm(0.0, 0.0), "new Date(NaN).toISOString()"); + assert!(e.message.starts_with("RangeError"), "{e:?}"); + let e = fail_with(date_realm(0.0, 0.0), "Date.prototype.getTime.call({})"); + assert!(e.message.starts_with("TypeError"), "{e:?}"); +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs index 8c87bb19d..6303ab078 100644 --- a/crates/js/tests/test262.rs +++ b/crates/js/tests/test262.rs @@ -19,6 +19,11 @@ //! passes when it completes, or, for `negative: phase: runtime`, when it throws an object whose //! `constructor` is the global its `type:` names. Its counts are printed apart from the parse //! counts, and its failures go to `run-failures.txt`. +//! +//! A third test runs the tests under `test/built-ins/String`, `Number`, `RegExp` and `Date` the same +//! way, counting the tests that name an ES5 section apart from the other tests without a feature +//! tag; its failures go to `builtins-failures.txt`. The interpreter tests fail when the +//! interpreter panics, when a harness or test file cannot be read, or when no ES5 test runs. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -203,8 +208,20 @@ const MISSING_BUILTINS: &str = "Array String Number Boolean Math JSON Date RegEx fromCharCode substring substr split replace match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential \ bind now isArray compareArray verifyProperty fnGlobalObject"; -/// The stand-in for `String` that the harness's failure messages call. -const HARNESS_STRING: &str = "function String(value) { return '' + value; }"; +/// The names in [`MISSING_BUILTINS`] that `String`, `Number`, `RegExp` and `Date` provide. +const TEXT_BUILTINS: &str = "String Number Date RegExp charAt charCodeAt fromCharCode substring substr split replace match search exec test \ + toUpperCase toLowerCase trim toFixed toPrecision toExponential now"; + +/// String methods whose names `Array.prototype` also uses: provided for the tests under +/// `built-ins/String/`, and still missing elsewhere, where the name usually means the array method. +const STRING_METHODS_SHARED_WITH_ARRAY: &str = "indexOf lastIndexOf concat slice"; + +/// The names in [`MISSING_BUILTINS`] the interpreter still lacks, without those in each of +/// `provided`. +fn missing_builtins<'a>(provided: &[&str]) -> HashSet<&'a str> { + let provided: HashSet<&str> = provided.iter().flat_map(|p| p.split_whitespace()).collect(); + MISSING_BUILTINS.split_whitespace().filter(|name| !provided.contains(name)).collect() +} /// The source names a missing built-in, as an identifier, a property name or a word inside a /// string literal (code passed to `eval` or `Function`), or does not parse as ES5 (the tests that @@ -249,32 +266,110 @@ fn es5_language_subset_runs() { handle.join().unwrap(); } +/// The counts of one run of a set of tests through the interpreter. +#[derive(Default)] +struct RunCounts { + tests: usize, + skipped: usize, + passed: usize, + failed: usize, + panicked: usize, + failures: String, +} + +impl RunCounts { + fn summary(&self) -> String { + format!( + "{} tests that need only the built-ins present, {} runs passed, {} failed ({} panicked), {} skipped", + self.tests, self.passed, self.failed, self.panicked, self.skipped + ) + } +} + fn run_language_subset(dir: &Path) { let language = dir.join("test").join("language"); + assert!(language.is_dir(), "{} is not a test262 checkout", dir.display()); + let missing = missing_builtins(&[TEXT_BUILTINS]); + let (es5, _) = run_tests(dir, &language, &missing, false); + assert!(es5.tests > 0, "no ES5 tests ran under {}", language.display()); + let report = dir.join("run-failures.txt"); + if let Err(e) = std::fs::write(&report, &es5.failures) { + println!("could not write {}: {e}", report.display()); + } + println!( + "test262 ES5 language subset through the interpreter: {} tests that need only the built-ins present, {} runs passed, \ + {} failed ({} panicked). Skipped {} tests that need other built-ins, use syntax after ES5 or are asynchronous. Failures: {}", + es5.tests, + es5.passed, + es5.failed, + es5.panicked, + es5.skipped, + report.display() + ); + assert_eq!(es5.panicked, 0, "the interpreter panicked; see {}", report.display()); +} + +#[test] +fn es5_text_builtins_run() { + let Some(dir) = suite_dir() else { + println!("test262 built-ins: SKIPPED (no suite; run cargo xtask test262)"); + return; + }; + let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_text_builtins(&dir)).unwrap(); + handle.join().unwrap(); +} + +/// Runs the tests under `test/built-ins/` for `String`, `Number`, `RegExp` and `Date`: those that +/// name an ES5 section and the other tests without a feature tag, counted apart. +fn run_text_builtins(dir: &Path) { + let mut report = String::new(); + let mut panicked = 0; + for name in ["String", "Number", "RegExp", "Date"] { + let root = dir.join("test").join("built-ins").join(name); + assert!(root.is_dir(), "{} is not a test262 checkout", dir.display()); + let missing = + if name == "String" { missing_builtins(&[TEXT_BUILTINS, STRING_METHODS_SHARED_WITH_ARRAY]) } else { missing_builtins(&[TEXT_BUILTINS]) }; + let (es5, later) = run_tests(dir, &root, &missing, true); + assert!(es5.tests > 0, "no ES5 tests ran under {}", root.display()); + println!("test262 built-ins/{name}, ES5 tests: {}. Other tests without a feature tag: {}.", es5.summary(), later.summary()); + report.push_str(&format!("# built-ins/{name}, ES5 tests\n{}\n# built-ins/{name}, other tests\n{}\n", es5.failures, later.failures)); + panicked += es5.panicked + later.panicked; + } + let path = dir.join("builtins-failures.txt"); + if let Err(e) = std::fs::write(&path, &report) { + println!("could not write {}: {e}", path.display()); + } + println!("test262 built-ins failures: {}", path.display()); + assert_eq!(panicked, 0, "the interpreter panicked; see {}", path.display()); +} + +/// Runs the tests under `root` that have no feature tag and are not module, asynchronous or +/// negative parse tests, each in a new realm after the harness. Returns the counts for the tests +/// that name an ES5 section and, when `later` is set, for the others (otherwise those are left out). +fn run_tests(dir: &Path, root: &Path, missing: &HashSet<&str>, later: bool) -> (RunCounts, RunCounts) { let harness_dir = dir.join("harness"); - // A file that cannot be read gives an empty source. - let read = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap_or_default()).into_owned(); - // `assert.js` formats a failed assertion's message with the global `String`, which the - // interpreter does not provide yet; the stand-in converts with `+` so a failure reports the - // assertion's message instead of a ReferenceError. - let prelude = format!("{HARNESS_STRING}\n{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js"))); - let missing: HashSet<&str> = MISSING_BUILTINS.split_whitespace().collect(); + let read = |p: &Path| { + let bytes = std::fs::read(p).map_err(|e| format!("cannot read {}: {e}", p.display())); + assert!(bytes.is_ok(), "{}", bytes.as_ref().err().map_or("", String::as_str)); + String::from_utf8_lossy(&bytes.unwrap_or_default()).into_owned() + }; + let prelude = format!("{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js"))); let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some(); - let (mut tests, mut skipped, mut passed, mut failed, mut panicked) = (0usize, 0usize, 0usize, 0usize, 0usize); - let mut failures = String::new(); - for path in test_files(&language) { + let (mut es5_counts, mut later_counts) = (RunCounts::default(), RunCounts::default()); + for path in test_files(root) { let source = read(&path); let meta = meta(&source); let negative_parse = matches!(meta.negative_phase.as_deref(), Some("parse" | "resolution")); - if !meta.es5 || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") { + if (!meta.es5 && !later) || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") { continue; } + let counts = if meta.es5 { &mut es5_counts } else { &mut later_counts }; let includes: String = meta.includes.iter().map(|i| read(&harness_dir.join(i)) + "\n").collect(); - if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&source, &missing) || needs_missing_builtin(&includes, &missing) { - skipped += 1; + if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&source, missing) || needs_missing_builtin(&includes, missing) { + counts.skipped += 1; continue; } - tests += 1; + counts.tests += 1; let has = |flag: &str| meta.flags.iter().any(|f| f == flag); let modes: &[bool] = if has("raw") || has("noStrict") { &[false] @@ -294,27 +389,18 @@ fn run_language_subset(dir: &Path) { let negative_type = meta.negative_type.clone().filter(|_| meta.negative_phase.as_deref() == Some("runtime")); let outcome = std::panic::catch_unwind(|| run_one(&script, negative_type.as_deref())); match outcome { - Ok(Ok(())) => passed += 1, + Ok(Ok(())) => counts.passed += 1, Ok(Err(why)) => { - failed += 1; - failures.push_str(&format!("{rel} ({mode}): {why}\n")); + counts.failed += 1; + counts.failures.push_str(&format!("{rel} ({mode}): {why}\n")); } Err(_) => { - failed += 1; - panicked += 1; - failures.push_str(&format!("{rel} ({mode}): PANIC\n")); + counts.failed += 1; + counts.panicked += 1; + counts.failures.push_str(&format!("{rel} ({mode}): PANIC\n")); } } } } - let report = dir.join("run-failures.txt"); - if let Err(e) = std::fs::write(&report, &failures) { - println!("could not write {}: {e}", report.display()); - } - println!( - "test262 ES5 language subset through the interpreter: {tests} tests that need only the built-ins present, {passed} runs passed, \ - {failed} failed ({panicked} panicked). Skipped {skipped} tests that need other built-ins, use syntax after ES5 or are asynchronous. Failures: {}", - report.display() - ); - assert_eq!(panicked, 0, "the interpreter panicked; see {}", report.display()); + (es5_counts, later_counts) } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 0a81bb127..59b3f973f 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -315,6 +315,8 @@ impl Headless { /// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension). pub fn export(&mut self, p: &Value) -> Result { + // The key check of `document.export` runs before the file is written. + self.session.check_params("document.export", p).map_err(|e| e.to_string())?; let roots = self.roots.clone(); file_access::confine(roots.as_ref(), || self.session.execute("document.export", p)).map_err(|e| e.to_string()) } diff --git a/crates/mcp/src/resources.rs b/crates/mcp/src/resources.rs index 704c3392e..50792de6d 100644 --- a/crates/mcp/src/resources.rs +++ b/crates/mcp/src/resources.rs @@ -188,6 +188,10 @@ fn decode(s: &str) -> String { /// caller turns it into `-32602`); other failures (no document, an unreachable app) stay /// backend errors. fn read_object(b: &mut dyn Backend, id: &str) -> Result, String> { + // `parse` alone would accept a leading `+`. + if id.is_empty() || !id.bytes().all(|c| c.is_ascii_digit()) { + return Ok(None); + } let Ok(id) = id.parse::() else { return Ok(None) }; match b.call("engine.execute", json!({"command": "document.node", "params": {"id": id, "summary": true}})) { Ok(v) => Ok(Some(v)), @@ -268,6 +272,17 @@ mod tests { assert!(matches!(read(empty.as_mut(), "vectorcraft://object/1"), Err(ReadError::Backend(_)))); } + #[test] + fn object_id_must_be_plain_digits() { + let mut b = Box::new(crate::Headless::with_document()); + let made = b.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}})).unwrap(); + let id = made["id"].as_i64().expect("shape.rectangle returns an integer id"); + for uri in [format!("vectorcraft://object/+{id}"), format!("vectorcraft://object/-{id}")] { + assert!(matches!(read(b.as_mut(), &uri), Err(ReadError::Invalid(_))), "{uri}"); + } + assert!(read(b.as_mut(), &format!("vectorcraft://object/{id}")).is_ok()); + } + #[test] fn an_object_is_readable_once_it_exists() { let mut b = Box::new(crate::Headless::with_document()); diff --git a/crates/mcp/src/tests/exportas.rs b/crates/mcp/src/tests/exportas.rs index 0d9dc5c49..8bc958af8 100644 --- a/crates/mcp/src/tests/exportas.rs +++ b/crates/mcp/src/tests/exportas.rs @@ -16,6 +16,34 @@ fn three_boards() -> Headless { h } +/// The tool, `app.export` and `file.export` refuse a key no format takes, at the top level of +/// `options` and inside a PDF object. +#[test] +fn an_option_no_format_takes_is_an_error() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 30}})).unwrap(); + for (options, named) in [ + (json!({"standard": "pdfX4", "noSuchOption": true}), "noSuchOption"), + (json!({"marks": {"useDocumentBleed": true}}), "marks.useDocumentBleed"), + ] { + let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": options})); + assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains(named), "{r:?}"); + let mut params = options.clone(); + params["format"] = json!("pdf"); + for method in ["app.export", "file.export"] { + let e = if method == "app.export" { + h.call(method, params.clone()).unwrap_err() + } else { + h.call("engine.execute", json!({"command": method, "params": params})).unwrap_err() + }; + assert!(e.contains(named), "{method}: {e}"); + } + } + // One object can still carry several formats' options. + let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": {"quality": 80, "styling": "style"}})); + assert!(!r.is_error, "{r:?}"); +} + #[test] fn raster_export_uses_document_resolution_unless_overridden() { let mut h = Headless::new(); diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 2201894b4..7321626cd 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -5,6 +5,7 @@ //! [`tidy_segments`] then re-joins pieces that came from the same smooth curve by refitting, so //! results have roughly as few anchors as the inputs. +use std::cell::RefCell; use std::cmp::Reverse; use std::collections::HashSet; @@ -400,6 +401,9 @@ impl Arrangement { Ok(Self { top, rules: paths.iter().map(|p| p.1).collect(), tidy }) } + /// A flag per input telling whether it covers a face of winding `w`: the form the tests compare + /// [`Self::covered`] with. + #[cfg(test)] pub(crate) fn mask(&self, w: &Multi) -> Vec { // A zero winding is outside under both rules, so only the non-zero ones can set a flag. let mut m = vec![false; self.rules.len()]; @@ -416,22 +420,38 @@ impl Arrangement { w.iter().filter(|&(i, k)| self.rules.get(i).is_some_and(|&r| inside(r, k))).map(|(i, _)| i) } - /// [`Arrangement::contours`] with the test on the windings themselves (see [`Self::covered`]). + /// [`Arrangement::contours_covering`] with the test on the windings themselves (see [`Self::covered`]). pub(crate) fn contours_where(&self, pred: impl Fn(&Multi) -> bool) -> Contours { #[cfg(test)] CONTOUR_PASSES.with(|n| n.set(n.get() + 1)); self.top.contours(pred) } + /// The contours of the faces where `pred` holds for the flags of [`Self::mask`]: the form the + /// tests compare [`Self::contours_covering`] with. + #[cfg(test)] pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours { self.contours_where(|w| pred(&self.mask(w))) } - /// [`Self::contours`] with a panic in the sweep's contour pass turned into an error, as `new` does for the + /// The contours of the faces where `pred` holds for the inputs that cover them + /// ([`Self::covered`], ascending). One list is refilled for each winding the pass tests, so a + /// test costs the size of the face's cover, not the number of inputs. + pub(crate) fn contours_covering(&self, pred: impl Fn(&[usize]) -> bool) -> Contours { + let cover = RefCell::new(Vec::new()); + self.contours_where(|w| { + let mut c = cover.borrow_mut(); + c.clear(); + c.extend(self.covered(w)); + pred(&c) + }) + } + + /// [`Self::contours_covering`] with a panic in the sweep's contour pass turned into an error, as `new` does for the /// sweep itself (native builds only: on the web a panic aborts, whatever is caught here). Used by the calls that can return an error; the rest have none to return and let the engine's /// guard report it. - pub(crate) fn try_contours(&self, pred: impl Fn(&[bool]) -> bool) -> Result { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.contours(pred))).map_err(|_| PathOpsError::Degenerate) + pub(crate) fn try_contours(&self, pred: impl Fn(&[usize]) -> bool) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.contours_covering(pred))).map_err(|_| PathOpsError::Degenerate) } /// All distinct non-empty coverages that occur next to some edge, each as the inputs it covers @@ -831,7 +851,7 @@ pub fn boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, o pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp, precision: f64) -> Result { let (pa, pb) = (fill_bezpath(a), fill_bezpath(b)); let arr = Arrangement::new(vec![(pa.clone(), a_rule), (pb.clone(), b_rule)])?; - let c = arr.try_contours(|m| op.apply(m[0], m[1]))?; + let c = arr.try_contours(|c| op.apply(c.contains(&0), c.contains(&1)))?; if !arrangement_adds_up(&arr, [&pa, &pb]) && let Some(r) = resolved_boolean([(&pa, a_rule), (&pb, b_rule)], op, precision) { @@ -919,7 +939,7 @@ fn resolved_boolean(inputs: [(&BezPath, FillRule); 2], op: BoolOp, precision: f6 if !arrangement_adds_up(&arr, [&ra, &rb]) { return None; } - let c = arr.try_contours(|m| op.apply(m[0], m[1])).ok()?; + let c = arr.try_contours(|c| op.apply(c.contains(&0), c.contains(&1))).ok()?; // The refit keeps the operands' own anchors, not only those of their resolved contours. let tidy = Tidy::keeping(precision, [pa, pb]); Some(all_contours_to_path(&c, &tidy)) @@ -927,19 +947,19 @@ fn resolved_boolean(inputs: [(&BezPath, FillRule); 2], op: BoolOp, precision: f6 /// [`boolean_n`] that reports a refused input ([`PathOpsError::TooComplex`], non-finite /// coordinates) instead of returning an empty path. -pub fn try_boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> Result { +pub fn try_boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[usize]) -> bool) -> Result { let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect(); let arr = Arrangement::new(bps)?; Ok(all_contours_to_path(&arr.try_contours(pred)?, &arr.tidy)) } -/// N-ary boolean: the area where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a -/// point is inside `paths[i]` under its fill rule. A refused input gives an empty path; callers that -/// can report an error use [`try_boolean_n`]. -pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> PathData { +/// N-ary boolean: the area where `pred(inside)` holds, `inside` listing the indices `i`, ascending, +/// of the `paths[i]` a point is inside under their fill rules. A refused input gives an empty path; +/// callers that can report an error use [`try_boolean_n`]. +pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[usize]) -> bool) -> PathData { let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect(); match Arrangement::new(bps) { - Ok(arr) => all_contours_to_path(&arr.contours(pred), &arr.tidy), + Ok(arr) => all_contours_to_path(&arr.contours_covering(pred), &arr.tidy), Err(_) => PathData::default(), } } @@ -1300,7 +1320,110 @@ mod tests { #[cfg(panic = "unwind")] fn a_panic_in_the_contour_pass_becomes_an_error() { let arr = Arrangement::new(vec![(fill_bezpath(&square(0.0, 0.0, 10.0)), FillRule::NonZero)]).unwrap(); - assert!(arr.try_contours(|m| m[0]).is_ok()); + assert!(arr.try_contours(|c| c.contains(&0)).is_ok()); assert_eq!(arr.try_contours(|_| panic!("simulated panic in the sweep")).err(), Some(PathOpsError::Degenerate)); } + + /// Squares that overlap, nest, touch or lie apart, and 72 that overlap in rows, stacked in an + /// order other than across the page. + fn square_sets() -> Vec<(&'static str, Vec)> { + let mut many: Vec = (0..70) + .map(|i| { + let x = 10.0 * f64::from((i * 37) % 70); + square(x, 7.0 * f64::from(i % 5), 15.0) + }) + .collect(); + many.insert(20, square(100.0, -5.0, 60.0)); + many.push(square(400.0, 10.0, 30.0)); + vec![ + ("overlapping", vec![square(0.0, 0.0, 100.0), square(50.0, 50.0, 100.0), square(25.0, 75.0, 100.0)]), + ("nested", vec![square(0.0, 0.0, 300.0), square(50.0, 50.0, 200.0), square(100.0, 100.0, 100.0), square(140.0, 140.0, 20.0)]), + ("touching", vec![square(0.0, 0.0, 100.0), square(100.0, 0.0, 100.0), square(0.0, 100.0, 100.0), square(100.0, 100.0, 100.0)]), + ("apart", vec![square(0.0, 0.0, 10.0), square(50.0, 0.0, 10.0), square(0.0, 50.0, 10.0)]), + ("many", many), + ] + } + + /// The contours of a pass, in order, each with its path and whether it is an outer contour. + fn contour_list(c: &Contours) -> Vec<(BezPath, bool)> { + c.contours().map(|ct| (ct.path.clone(), ct.outer)).collect() + } + + type FlagTest = Box bool>; + type CoverTest = Box bool>; + + /// Each test of the boolean operations, Offset Path, the Pathfinder operations, compound shapes + /// and Intertwine finds the same contours in the same order from the inputs covering each face + /// as from a flag per input. + #[test] + fn contour_passes_on_the_covering_inputs_match_the_flag_tests() { + for (what, set) in square_sets() { + let n = set.len(); + let arr = Arrangement::new(set.iter().map(|p| (fill_bezpath(p), FillRule::NonZero)).collect()).unwrap(); + let mut tests: Vec<(String, FlagTest, CoverTest)> = vec![]; + for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Difference, BoolOp::Xor] { + tests.push(( + format!("{op:?}"), + Box::new(move |m: &[bool]| op.apply(m[0], m[1])), + Box::new(move |c: &[usize]| op.apply(c.contains(&0), c.contains(&1))), + )); + } + // Offset Path sweeps three inputs, so there any cover is one of them. + tests.push(( + "offset outward".into(), + Box::new(|m: &[bool]| m[0] || m[1] || m[2]), + Box::new(|c: &[usize]| c.first().is_some_and(|&i| i < 3)), + )); + tests.push(("offset inward".into(), Box::new(|m: &[bool]| m[0] && !m[1]), Box::new(|c: &[usize]| c.contains(&0) && !c.contains(&1)))); + tests.push(("unite".into(), Box::new(|m: &[bool]| m.contains(&true)), Box::new(|c: &[usize]| !c.is_empty()))); + tests.push(("intersect".into(), Box::new(|m: &[bool]| m.iter().all(|&b| b)), Box::new(move |c: &[usize]| c.len() == n))); + tests.push(("minus front".into(), Box::new(|m: &[bool]| m[0] && !m[1..].contains(&true)), Box::new(|c: &[usize]| c == [0]))); + tests.push(( + "minus back".into(), + Box::new(move |m: &[bool]| m[n - 1] && !m[..n - 1].contains(&true)), + Box::new(move |c: &[usize]| c == [n - 1]), + )); + tests.push(( + "exclude".into(), + Box::new(|m: &[bool]| m.iter().filter(|&&b| b).count() % 2 == 1), + Box::new(|c: &[usize]| c.len() % 2 == 1), + )); + tests.push(("intertwine".into(), Box::new(|m: &[bool]| m[0] && m[1] && !m[2..].contains(&true)), Box::new(|c: &[usize]| c == [0, 1]))); + // A compound shape whose members take two inputs each, with every shape mode in turn. + let modes = [BoolOp::Union, BoolOp::Difference, BoolOp::Intersect, BoolOp::Xor]; + let compound = move |here: &dyn Fn(usize) -> bool| { + let mut area = false; + for (k, start) in (0..n).step_by(2).enumerate() { + let h = here(start); + area = if k == 0 { h } else { modes[k % modes.len()].apply(area, h) }; + } + area + }; + tests.push(( + "compound shape".into(), + Box::new(move |m: &[bool]| compound(&|s| m[s..(s + 2).min(n)].contains(&true))), + Box::new(move |c: &[usize]| compound(&|s| c.get(c.partition_point(|&i| i < s)).is_some_and(|&i| i < s + 2))), + )); + for (op, flags, cover) in &tests { + let old = contour_list(&arr.contours(flags)); + assert_eq!(contour_list(&arr.contours_covering(cover)), old, "{what}: {op}"); + assert_eq!(contour_list(&arr.try_contours(cover).unwrap()), old, "{what}: {op}"); + } + } + } + + /// A contour pass hands its test the inputs covering each face, not a flag for every input: + /// with 100 squares apart, no test sees more than one. + #[test] + fn a_contour_pass_tests_only_the_inputs_covering_each_face() { + let set: Vec<(BezPath, FillRule)> = (0..100).map(|i| (fill_bezpath(&square(20.0 * f64::from(i), 0.0, 10.0)), FillRule::NonZero)).collect(); + let arr = Arrangement::new(set).unwrap(); + let widest = std::cell::Cell::new(0); + let c = arr.contours_covering(|c| { + widest.set(widest.get().max(c.len())); + !c.is_empty() + }); + assert_eq!(widest.get(), 1); + assert_eq!(c.contours().count(), 100); + } } diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 01c3d03cb..bf533476b 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -159,7 +159,7 @@ fn offset_with_check( vec![(fill, FillRule::NonZero), (ring, FillRule::NonZero), (open_bp, FillRule::NonZero)], MAX_OFFSET_SEGMENT_PAIRS, )?; - let c = if delta >= 0.0 { arr.contours(|m| m[0] || m[1] || m[2]) } else { arr.contours(|m| m[0] && !m[1]) }; + let c = if delta >= 0.0 { arr.contours_covering(|c| !c.is_empty()) } else { arr.contours_covering(|c| c.contains(&0) && !c.contains(&1)) }; // When `d` exceeds a curvature radius the stroker's inner offset inverts and its loops cancel // winding, leaving faces uncovered that are really within `d` of the path. Every face of the // true offset is bounded by arrangement edges, so a contour is spurious iff a point deep inside diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index e866ba5cb..375b319b3 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -470,6 +470,21 @@ fn numbers_in_threaded_frames_are_written_as_text_in_each_frame() { assert_markers_in_place(&d, &back, &drawn.iter().map(String::as_str).collect::>(), 0.1); } +/// The numbers of list paragraphs at a deeper level are written as text where the canvas draws +/// them, 18 pt further in, each level with its own count. +#[test] +fn numbers_at_a_deeper_list_level_are_written_as_text_where_the_canvas_draws_them() { + let number = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)); + let d = doc(vec![listed("first\nsecond\nthird\nfourth", &[number(1), number(2), number(2), number(1)])]); + let origins = marker_origins(&d); + let x = |m: usize| origins.get(m).and_then(|o| o.first()).map_or(f64::NAN, |p| p.x); + assert!((x(1) - x(0) - 18.0).abs() < 1e-6 && (x(3) - x(0)).abs() < 1e-6, "{origins:?}"); + let back = import_as(&pdf(&d, false).bytes, TextAs::Text); + let all = texts(&back).concat(); + assert!(before(&all, "1.", "first") && before(&all, "2.", "third") && all.matches("2.").count() == 2, "{all}"); + assert_markers_in_place(&d, &back, &["1.", "1.", "2.", "2."], 0.1); +} + /// A number set in oldstyle figures (the first character's OpenType features) is written as its /// digits, though the character map gives the digits other glyphs. The reopened type has no /// OpenType features and sets lining figures, so only where each number starts is compared. diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 4ac06db48..dc0aa0127 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -25,7 +25,9 @@ //! document in a file format of its own. The process-wide [`registry`] holds the installed //! plug-ins; in [`safe_mode`] none of them loads or runs. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show //! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is -//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. +//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. A +//! filter or a live effect may also add menu commands of its own ([`Command`]), which the host +//! runs through `vc_run` with the command's id in `_context`. #![forbid(unsafe_code)] pub mod dialog; @@ -43,7 +45,7 @@ pub mod wat; pub use dialog::ParamUi; pub use host::{LogLevel, LogLine, Report}; -pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; +pub use manifest::{Budget, Command, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; /// What the host puts before a plug-in's id to name what it adds: its live effect diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index da66ae820..74a7be7de 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -20,6 +20,10 @@ pub const MAX_TEXT: usize = 4_096; pub const MAX_LIST: usize = 256; /// Most file extensions an exporter may declare. pub const MAX_EXTENSIONS: usize = 8; +/// Most menu commands a plug-in may add (`commands`). +pub const MAX_COMMANDS: usize = 32; +/// Longest command id, in characters. +pub const MAX_COMMAND_ID: usize = 32; /// What a plug-in is. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -172,6 +176,30 @@ pub struct Param { pub ui: ParamUi, } +/// A menu command a plug-in adds (`commands`): the host lists it under Object › Plug-ins › the +/// plug-in's name (Effect › Plug-ins for a live effect's), runs it as the command +/// `plugin..`, and calls the plug-in's `vc_run` with `_context.command` set to +/// `id` and the parameters it declares. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Command { + /// `[a-z][a-z0-9_]*`, at most [`MAX_COMMAND_ID`] characters, unique within the manifest. + pub id: String, + /// The menu label: 1 to 64 printable characters, shown with bidirectional characters escaped + /// ([`crate::permissions::display_text`]). + pub label: String, + /// Parameters in declaration (dialog) order, declared as the manifest's own are. + #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] + pub params: Vec<(String, Param)>, +} + +impl Command { + /// The label as menus and errors show it: control and bidirectional characters escaped, at + /// most [`crate::permissions::MAX_SHOWN_NAME`] characters. + pub fn shown_label(&self) -> String { + crate::permissions::display_text(self.label.trim(), crate::permissions::MAX_SHOWN_NAME) + } +} + impl From for Param { fn from(spec: ParamSpec) -> Self { Param { spec, ui: ParamUi::default() } @@ -260,6 +288,9 @@ pub struct Manifest { /// How much a run may compute: only a filter may be [`Budget::Heavy`]. #[serde(default, skip_serializing_if = "Budget::is_light")] pub budget: Budget, + /// The menu commands it adds, in menu order (a filter or a live effect only). + #[serde(default, deserialize_with = "ordered_commands", skip_serializing_if = "Vec::is_empty")] + pub commands: Vec, } impl Manifest { @@ -288,9 +319,21 @@ impl Manifest { params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, + commands: Vec::new(), } } + /// The manifest command `c` runs with: this one with `c`'s label as its name and `c`'s + /// parameters as its own, so dialogs show them and [`Manifest::resolve_params`] checks them. + pub fn for_command(&self, c: &Command) -> Manifest { + Manifest { name: c.label.clone(), params: c.params.clone(), commands: Vec::new(), ..self.clone() } + } + + /// The command with id `id`. + pub fn command(&self, id: &str) -> Option<&Command> { + self.commands.iter().find(|c| c.id == id) + } + /// Checks what the module declared: its id, name and text lengths, its extensions, a budget its /// kind may have (only a filter may be heavy), and each parameter and its dialog layout. fn validate(&self) -> Result<()> { @@ -308,7 +351,8 @@ impl Manifest { return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api))); } if let Some(names) = &self.permissions - && let Some(host) = permissions::validate(self.kind, names)?.into_iter().find(|p| permissions::HOST.contains(p)) + && let Some(host) = + permissions::validate(self.kind, !self.commands.is_empty(), names)?.into_iter().find(|p| permissions::HOST.contains(p)) && self.api < PROGRESS_API { let permission = host.name(); @@ -324,6 +368,12 @@ impl Manifest { return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into())); } } + self.validate_params()?; + self.validate_commands() + } + + /// Checks each parameter and its dialog layout. + fn validate_params(&self) -> Result<()> { for (i, (k, p)) in self.params.iter().enumerate() { if !valid_key(k) { return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); @@ -337,6 +387,47 @@ impl Manifest { Ok(()) } + /// A filter or a live effect lists at most [`MAX_COMMANDS`] commands, each with a distinct + /// id, a printable label and parameters declared as the manifest's own are. A heavy filter + /// lists none. The error names the command: its id, or its place in the list. + fn validate_commands(&self) -> Result<()> { + if self.commands.is_empty() { + return Ok(()); + } + if self.kind == Kind::Exporter { + return Err(Error::Manifest("only a filter or a live effect declares `commands`".into())); + } + if self.budget == Budget::Heavy { + return Err(Error::Manifest("a heavy filter runs in the background, so it can't declare `commands`".into())); + } + if self.commands.len() > MAX_COMMANDS { + return Err(Error::Manifest(format!("at most {MAX_COMMANDS} `commands`"))); + } + for (i, c) in self.commands.iter().enumerate() { + let name = command_name(i, &c.id); + if !valid_command_id(&c.id) { + return Err(Error::Manifest(format!( + "command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter" + ))); + } + let label = c.label.trim(); + if label.is_empty() || label.chars().count() > 64 || label.chars().any(char::is_control) { + return Err(Error::Manifest(format!("command {name}: `label` must be 1-64 printable characters"))); + } + if self.commands.get(..i).is_some_and(|before| before.iter().any(|b| b.id == c.id)) { + return Err(Error::Manifest(format!("command {name} is declared twice"))); + } + self.for_command(c).validate_params().map_err(|e| { + let e = match e { + Error::Manifest(why) => why, + other => other.to_string(), + }; + Error::Manifest(format!("command {name}: {e}")) + })?; + } + Ok(()) + } + /// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case /// letters and digits; other kinds list none. fn validate_extensions(&self) -> Result<()> { @@ -389,11 +480,14 @@ impl Manifest { self.params.iter().map(|(k, p)| (k.clone(), p.spec.default_value())).collect() } - /// What the plug-in may do: the permissions it lists, else the ones its kind implies. + /// What the plug-in may do: the permissions it lists, else every one its kind may list + /// ([`Permission::allowed`]: a live effect with commands also changes the document and keeps + /// data with it). pub fn permissions(&self) -> Vec { + let commands = !self.commands.is_empty(); match &self.permissions { - Some(names) => permissions::validate(self.kind, names).unwrap_or_default(), - None => Permission::implied(self.kind).to_vec(), + Some(names) => permissions::validate(self.kind, commands, names).unwrap_or_default(), + None => Permission::allowed(self.kind, commands).to_vec(), } } @@ -492,6 +586,119 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result d.deserialize_any(V) } +/// Reads the `commands` list one entry at a time from the manifest itself, so each command's +/// `params` keep their declaration order and a repeated parameter is refused. A malformed entry +/// is an error that names it (its id when the id comes before the fault, otherwise its place in +/// the list), and a list is refused at its first entry past [`MAX_COMMANDS`]. +fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + use serde::de::{Error as _, IgnoredAny, MapAccess, SeqAccess, Visitor}; + + /// A command's `params`, read in declaration order. + struct Params(Vec<(String, Param)>); + impl<'de> Deserialize<'de> for Params { + fn deserialize>(d: D) -> std::result::Result { + ordered_params(d).map(Params) + } + } + + /// The entry at place `.0` in the list. + struct Entry(usize); + impl<'de> serde::de::DeserializeSeed<'de> for Entry { + type Value = Command; + fn deserialize>(self, d: D) -> std::result::Result { + d.deserialize_any(self) + } + } + impl<'de> Visitor<'de> for Entry { + type Value = Command; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "command [{}] as {{id, label, params?}}", self.0) + } + fn visit_map>(self, mut map: A) -> std::result::Result { + let (mut id, mut label, mut params) = (None::, None::, None::); + let named = |id: &Option, e: &dyn std::fmt::Display| { + A::Error::custom(format!("command {}: {e}", command_name(self.0, id.as_deref().unwrap_or_default()))) + }; + while let Some(key) = map.next_key::().map_err(|e| named(&id, &e))? { + let repeated = match key.as_str() { + "id" if id.is_none() => { + id = Some(map.next_value().map_err(|e| named(&id, &e))?); + false + } + "label" if label.is_none() => { + label = Some(map.next_value().map_err(|e| named(&id, &e))?); + false + } + "params" if params.is_none() => { + params = Some(map.next_value().map_err(|e| named(&id, &e))?); + false + } + "id" | "label" | "params" => true, + _ => { + map.next_value::().map_err(|e| named(&id, &e))?; + false + } + }; + if repeated { + return Err(named(&id, &format_args!("duplicate field `{key}`"))); + } + } + let Some(id) = id else { + return Err(named(&None, &"missing field `id`")); + }; + let Some(label) = label else { + return Err(named(&Some(id), &"missing field `label`")); + }; + Ok(Command { id, label, params: params.map(|p| p.0).unwrap_or_default() }) + } + } + + struct List; + impl<'de> Visitor<'de> for List { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("`commands` as a list of {id, label, params?}") + } + fn visit_unit(self) -> std::result::Result { + Ok(Vec::new()) + } + fn visit_map>(self, _: A) -> std::result::Result { + Err(A::Error::custom("`commands` must be a list of {id, label, params?}")) + } + fn visit_seq>(self, mut seq: A) -> std::result::Result { + let mut out = Vec::new(); + while out.len() < MAX_COMMANDS { + match seq.next_element_seed(Entry(out.len()))? { + Some(c) => out.push(c), + None => return Ok(out), + } + } + match seq.next_element::()? { + Some(_) => Err(A::Error::custom(format!("at most {MAX_COMMANDS} `commands`"))), + None => Ok(out), + } + } + } + + d.deserialize_any(List) +} + +/// How errors name the command at place `i` with id `id`: the id in backticks, escaped and capped +/// as prompts show text, or `[i]` when it has none. +fn command_name(i: usize, id: &str) -> String { + match id { + "" => format!("[{i}]"), + id => format!("`{}`", crate::permissions::display_text(id, crate::permissions::MAX_SHOWN_FIELD)), + } +} + +/// `[a-z][a-z0-9_]*`, 1 to [`MAX_COMMAND_ID`] characters. +fn valid_command_id(id: &str) -> bool { + id.len() <= MAX_COMMAND_ID + && id.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) + && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} + fn params_as_object(p: &[(String, Param)], s: S) -> std::result::Result { use serde::ser::SerializeMap; let mut m = s.serialize_map(Some(p.len()))?; @@ -650,6 +857,16 @@ mod tests { assert!(serde_json::to_value(&plain).unwrap().get("permissions").is_none()); let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect"}"#).unwrap(); assert_eq!(effect.permissions(), [Permission::DocRead, Permission::EffectRender]); + // A live effect's commands may change the document: one with commands gets `doc.write` + // and `state` too, and may list them. + let commands = br#"{"id":"a","name":"x","kind":"effect","commands":[{"id":"c","label":"C"}]}"#; + let with_commands = Manifest::parse(commands).unwrap(); + assert_eq!(with_commands.permissions(), [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender]); + let listing = + br#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read","effect.render","doc.write"],"commands":[{"id":"c","label":"C"}]}"#; + assert_eq!(Manifest::parse(listing).unwrap().permissions(), [Permission::DocRead, Permission::DocWrite, Permission::EffectRender]); + let without = br#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read","effect.render","doc.write"]}"#; + assert!(Manifest::parse(without).unwrap_err().to_string().contains("`doc.write` doesn't apply")); let exporter = Manifest::parse(br#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"]}"#).unwrap(); assert_eq!(exporter.permissions(), [Permission::DocRead, Permission::ExportWrite]); assert!(!Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","permissions":[]}"#).unwrap().asks_permissions()); @@ -680,6 +897,72 @@ mod tests { } } + /// A filter or a live effect lists menu commands with their own parameters; a `menu` key is + /// ignored. A malformed entry, a bad or repeated id, a label that isn't printable and too many + /// commands are refused, and the error names the command. + #[test] + fn commands_are_checked_and_errors_name_them() { + let m = Manifest::parse( + br#"{"id":"org.example.tidy","name":"Tidy","kind":"filter","commands":[ + {"id":"tidy_all","label":"Tidy All","menu":"File"}, + {"id":"grow","label":"Grow","params":{"mode":{"type":"bool"},"by":{"type":"number","min":0,"max":10,"default":2}}}]}"#, + ) + .unwrap(); + assert_eq!(m.commands.iter().map(|c| c.id.as_str()).collect::>(), ["tidy_all", "grow"]); + let grow = m.command("grow").unwrap(); + assert_eq!(grow.params.iter().map(|p| p.0.as_str()).collect::>(), ["mode", "by"], "declaration order"); + let run = m.for_command(grow); + assert_eq!((run.name.as_str(), run.id.as_str()), ("Grow", "org.example.tidy")); + assert_eq!(Value::Object(run.resolve_params(&json!({"by": 50})).unwrap()), json!({"by": 10.0, "mode": false})); + let written = serde_json::to_value(&m).unwrap(); + assert_eq!(written["commands"][1]["params"]["by"]["max"], json!(10.0)); + assert!(written["commands"][0].get("menu").is_none(), "a menu key is not kept"); + assert!(serde_json::to_value(Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap()).unwrap().get("commands").is_none()); + let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","commands":[{"id":"c","label":"C"}]}"#).unwrap(); + assert_eq!(effect.commands.len(), 1); + let label = |l: &str| Command { id: "c".into(), label: l.into(), params: Vec::new() }.shown_label(); + assert_eq!(label("Flip\u{202E}txt.exe"), "Flip\\u{202E}txt.exe"); + + let many: Vec = (0..=MAX_COMMANDS).map(|i| format!(r#"{{"id":"c{i}","label":"C"}}"#)).collect(); + let too_many = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{}]}}"#, many.join(",")); + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"ok","label":"A"},{"label":"B"}]}"#, "command [1]: missing field `id`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"nolabel"}]}"#, "command `nolabel`: missing field `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"Upper","label":"A"}]}"#, "command `Upper`: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"9lives","label":"A"}]}"#, "command `9lives`: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"a.b","label":"A"}]}"#, "command `a.b`: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"","label":"A"}]}"#, "command [0]: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":" "}]}"#, "command `x`: `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"a\nb"}]}"#, "command `x`: `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A"},{"id":"x","label":"B"}]}"#, "command `x` is declared twice"), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}]}"#, + "command `x`: parameter `r`: bad range", + ), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"id":{"type":"bool"}}}]}"#, + "command `x`: invalid parameter", + ), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"bool"},"r":{"type":"int"}}}]}"#, + "command `x`: parameter `r` is declared twice", + ), + (r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0]"), + (r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, "must be a list"), + (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"commands":[{"id":"x","label":"A"}]}"#, "only a filter or a live effect"), + (r#"{"id":"a","name":"x","kind":"filter","budget":"heavy","commands":[{"id":"x","label":"A"}]}"#, "heavy filter"), + (too_many.as_str(), "at most 32 `commands`"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + let long_id = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{{"id":"{}","label":"A"}}]}}"#, "c".repeat(MAX_COMMAND_ID + 1)); + assert!(Manifest::parse(long_id.as_bytes()).is_err()); + let hidden = + Manifest::parse("{\"id\":\"a\",\"name\":\"x\",\"kind\":\"filter\",\"commands\":[{\"id\":\"x\u{202E}\",\"label\":\"A\"}]}".as_bytes()); + assert!(hidden.unwrap_err().to_string().contains("command `x\\u{202E}`"), "an id in an error is escaped"); + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 700c9641e..97293db2a 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -115,10 +115,10 @@ impl Permission { } } - /// May a plug-in of `kind` list it: one its kind implies, or one of the host imports' ([`HOST`]), - /// which any kind may list. - pub fn applies(self, kind: Kind) -> bool { - HOST.contains(&self) || Permission::implied(kind).contains(&self) + /// May a plug-in of `kind` that declares `commands` or not list it: one [`Permission::allowed`] + /// gives it, or one of the host imports' ([`HOST`]), which any kind may list. + pub fn applies(self, kind: Kind, commands: bool) -> bool { + HOST.contains(&self) || Permission::allowed(kind, commands).contains(&self) } /// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list @@ -131,6 +131,17 @@ impl Permission { } } + /// The permissions a plug-in of `kind` that declares `commands` or not may list besides + /// `progress`, which are also the ones its manifest gets without `permissions`: those `kind` + /// implies, and for a live effect with commands also `doc.write` and `state`, which only its + /// commands use (drawing the effect changes no object and keeps no data). + pub fn allowed(kind: Kind, commands: bool) -> &'static [Permission] { + match kind { + Kind::Effect if commands => &[Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender], + _ => Permission::implied(kind), + } + } + /// The permissions a plug-in of `kind` can't work without, which its list must hold: a live /// effect and an exporter are always handed objects, and only draw or write. A filter may /// leave any of its permissions out. @@ -142,10 +153,11 @@ impl Permission { } } -/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has. A name -/// that is not a permission, a reserved one, one that doesn't apply to the kind, one listed twice -/// and a list without what the kind needs are errors that name it. -pub(crate) fn validate(kind: Kind, names: &[String]) -> Result> { +/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has; `commands` +/// is whether it declares commands ([`Permission::allowed`]). A name that is not a permission, a +/// reserved one, one that doesn't apply to the kind, one listed twice and a list without what the +/// kind needs are errors that name it. +pub(crate) fn validate(kind: Kind, commands: bool, names: &[String]) -> Result> { if names.len() > MAX_PERMISSIONS { return Err(Error::Manifest(format!("at most {MAX_PERMISSIONS} `permissions`"))); } @@ -158,7 +170,7 @@ pub(crate) fn validate(kind: Kind, names: &[String]) -> Result> } return Err(Error::Manifest(format!("unknown permission `{shown}`"))); }; - if !p.applies(kind) { + if !p.applies(kind, commands) { return Err(Error::Manifest(format!("permission `{shown}` doesn't apply to a plug-in of kind `{}`", kind.name()))); } if out.contains(&p) { @@ -255,41 +267,55 @@ mod tests { #[test] fn lists_are_checked_against_the_kind() { let list = |names: &[&str]| names.iter().map(|s| s.to_string()).collect::>(); - assert_eq!(validate(Kind::Filter, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]); - assert_eq!(validate(Kind::Filter, &[]).unwrap(), []); - assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok()); - assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Filter, false, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]); + assert_eq!(validate(Kind::Filter, false, &[]).unwrap(), []); + assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render"])).is_ok()); + assert!(validate(Kind::Exporter, false, &list(&["export.write", "doc.read"])).is_ok()); // Any kind may list `progress`, which no kind implies. - assert_eq!(validate(Kind::Filter, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]); - assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render", "progress"])).is_ok()); - assert!(validate(Kind::Exporter, &list(&["progress", "export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Filter, false, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]); + assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render", "progress"])).is_ok()); + assert!(validate(Kind::Effect, true, &list(&["doc.read", "effect.render", "progress"])).is_ok()); + assert!(validate(Kind::Exporter, false, &list(&["progress", "export.write", "doc.read"])).is_ok()); assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress))); // So may `settings` and `cache`, which no kind implies either. assert_eq!( - validate(Kind::Effect, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(), + validate(Kind::Effect, false, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(), [Permission::DocRead, Permission::EffectRender, Permission::Settings, Permission::Cache] ); - assert!(validate(Kind::Exporter, &list(&["settings", "export.write", "doc.read"])).is_ok()); - assert_eq!(validate(Kind::Filter, &list(&["cache"])).unwrap(), [Permission::Cache]); + assert!(validate(Kind::Exporter, false, &list(&["settings", "export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Filter, false, &list(&["cache"])).unwrap(), [Permission::Cache]); for kind in [Kind::Filter, Kind::Effect, Kind::Exporter] { - assert!(HOST.iter().all(|p| p.applies(kind) && !Permission::implied(kind).contains(p))); + for commands in [false, true] { + assert!(HOST.iter().all(|p| p.applies(kind, commands) && !Permission::implied(kind).contains(p))); + } } - for (kind, names, says) in [ - (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), - (Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"), - (Kind::Filter, &["progress", "progress"], "`progress` is listed twice"), - (Kind::Filter, &["cache", "cache"], "`cache` is listed twice"), - (Kind::Filter, &["storage"], "unknown permission `storage`"), - (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), - (Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"), - (Kind::Effect, &["doc.read"], "needs the permission `effect.render`"), - (Kind::Exporter, &["export.write"], "needs the permission `doc.read`"), + // A live effect with commands may also change the document and keep data with it. + assert_eq!( + validate(Kind::Effect, true, &list(&["state", "effect.render", "doc.write", "doc.read"])).unwrap(), + [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender] + ); + assert_eq!(Permission::allowed(Kind::Effect, false), Permission::implied(Kind::Effect)); + assert_eq!(Permission::allowed(Kind::Filter, true), Permission::implied(Kind::Filter)); + for (kind, commands, names, says) in [ + (Kind::Filter, false, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), + (Kind::Filter, false, &["ui.panel"], "`ui.panel` is reserved"), + (Kind::Filter, false, &["progress", "progress"], "`progress` is listed twice"), + (Kind::Filter, false, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), + (Kind::Exporter, false, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"), + (Kind::Effect, false, &["doc.read"], "needs the permission `effect.render`"), + (Kind::Effect, false, &["doc.read", "effect.render", "doc.write"], "`doc.write` doesn't apply to a plug-in of kind `effect`"), + (Kind::Effect, true, &["doc.read", "doc.write"], "needs the permission `effect.render`"), + (Kind::Effect, true, &["doc.read", "effect.render", "export.write"], "`export.write` doesn't apply"), + (Kind::Exporter, true, &["doc.read", "export.write", "doc.write"], "`doc.write` doesn't apply"), + (Kind::Exporter, false, &["export.write"], "needs the permission `doc.read`"), + (Kind::Filter, false, &["cache", "cache"], "`cache` is listed twice"), + (Kind::Filter, false, &["storage"], "unknown permission `storage`"), ] { - let e = validate(kind, &list(names)).unwrap_err().to_string(); + let e = validate(kind, commands, &list(names)).unwrap_err().to_string(); assert!(e.contains(says), "{names:?}: {e}"); } let many = list(&["doc.read"; MAX_PERMISSIONS + 1]); - assert!(validate(Kind::Filter, &many).unwrap_err().to_string().contains("at most")); + assert!(validate(Kind::Filter, false, &many).unwrap_err().to_string().contains("at most")); } /// Control, bidirectional override and separator characters show as escapes, so a name can't diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs index f4643ab20..326a8c18e 100644 --- a/crates/render/src/frame.rs +++ b/crates/render/src/frame.rs @@ -1,6 +1,9 @@ use super::options::{AntiAlias, RenderOptions}; use super::threads::single_threaded_context; -use super::{Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Rect, RenderContext, StrokeLayer, SymbolBoxes, TextObject, kurbo, live}; +use super::{ + Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Pixmap, Rect, RenderContext, StrokeLayer, SymbolBoxes, TextObject, TypeAntiAlias, + fx, kurbo, live, +}; /// Hasher for pointer-keyed caches: a multiply-xorshift (SipHash showed up in frame profiles). #[derive(Default, Clone, Copy)] @@ -116,6 +119,26 @@ pub(crate) struct PlacedEntry { pub(crate) slice: Option, } +/// Type drawn with an anti-aliasing method other than Sharp, as [`Renderer::draw_type_edges`] drew +/// it offscreen on the pixels of the output ([`Frame::output`]). +pub(crate) struct TypeEdgeEntry { + /// A copy of the type's object: the kept pixels are used only for an object equal to it. + pub(crate) node: Node, + /// The output's zoom and rotation ([`fx::linear_key`]). + pub(crate) linear: [u64; 4], + /// The method, the edge threshold, the frame's edge smoothing, whether the frame composites + /// precisely, and the opacity folded into the paints. + pub(crate) settings: (TypeAntiAlias, Option, AntiAlias, bool, u32), + /// What the type read from the document and the color settings ([`Renderer::raster_inputs`]). + pub(crate) inputs: fx::Inputs, + /// The output's offset: in a frame whose offset differs from it by whole pixels, the pixels are + /// placed moved by them. + pub(crate) shift: kurbo::Vec2, + /// The output pixel at the image's top-left corner. + pub(crate) origin: (f64, f64), + pub(crate) image: Arc, +} + /// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing. pub(crate) const MAX_INLINE_DEPTH: u32 = 4; @@ -147,6 +170,10 @@ pub struct FrameStats { /// How many threads composed the frame from its two ink planes, in a CMYK document that shows /// transparency; 0 for other frames. pub ink_threads: usize, + /// The type objects the frame drew offscreen with an anti-aliasing method other than Sharp + /// ([`Renderer::draw_type_edges`]). Type drawn from the pixels an earlier frame kept is not + /// counted. + pub type_edges: usize, pub micros: u64, } @@ -160,11 +187,16 @@ pub(crate) struct Frame<'a> { pub(crate) view: Affine, /// Visible region in document coordinates (for culling). pub(crate) visible: Rect, - /// Size of one output pixel in document units. + /// Size of one of this frame's pixels in document units. pub(crate) px: f64, pub(crate) opts: &'a RenderOptions, /// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]). pub(crate) ink: Ink, + /// Document → pixels of the output being drawn: `view` itself, except while an export draws + /// its art at several times its size (the export's own pixels) and in offscreen layers (the + /// pixels they are placed back on). Type with an anti-aliasing method other than Sharp is + /// drawn on these pixels ([`Renderer::draw_type_edges`]). + pub(crate) output: Affine, /// With Separations Preview, the tables placed images show the visible plates through. pub(crate) plates: Option<&'a crate::proof::PlateLut>, } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index abdf1666c..463a08ba3 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -73,13 +73,13 @@ pub(crate) use bounds::visual_bounds; pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds}; #[cfg(test)] pub(crate) use entries::Drawn; -pub(crate) use entries::{DocInputs, FxGeom, Reads, ShadowEntry}; +pub(crate) use entries::{DocInputs, FxGeom, Inputs, Reads, ShadowEntry}; pub(crate) use predicates::{has_fx, has_object_fx}; -pub(crate) use raster::PixelEntry; #[cfg(test)] pub(crate) use raster::RASTERIZED; #[cfg(test)] -use raster::{DRAG_PIXEL_BUDGET, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin}; +use raster::{DRAG_PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin}; +pub(crate) use raster::{PIXEL_BUDGET, PixelEntry, linear_key}; #[cfg(test)] mod tests; diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index a2ace3dbd..410123776 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -534,7 +534,8 @@ impl Renderer { (true, false) => crate::AntiAlias::Art, }; let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() }; - let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f }; + // The image's pixels are the output's. + let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, output: view, ..*f }; let mut off = frame.offscreen_context(w, h); self.inside_layer(|s| paint(s, &mut off, &frame)); off.flush(); diff --git a/crates/render/src/fx/predicates.rs b/crates/render/src/fx/predicates.rs index 7fc85b71a..e28a2fa27 100644 --- a/crates/render/src/fx/predicates.rs +++ b/crates/render/src/fx/predicates.rs @@ -77,5 +77,6 @@ pub(super) fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx] /// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset). pub(super) fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> { - Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f } + let moved = Affine::translate((dx, dy)); + Frame { view: f.view * moved, visible: f.visible - Vec2::new(dx, dy), output: f.output * moved, ..*f } } diff --git a/crates/render/src/fx/raster.rs b/crates/render/src/fx/raster.rs index 5fa9b107e..efbab00da 100644 --- a/crates/render/src/fx/raster.rs +++ b/crates/render/src/fx/raster.rs @@ -29,7 +29,7 @@ pub(super) fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Col /// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is /// cropped to the view when its effects only look nearby, else rendered at a lower resolution. -pub(super) const PIXEL_BUDGET: f64 = 8.4e6; +pub(crate) const PIXEL_BUDGET: f64 = 8.4e6; /// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each /// draws the content again inside the one after it. @@ -121,7 +121,7 @@ impl PixelEntry { } /// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys. -pub(super) fn linear_key(view: Affine) -> [u64; 4] { +pub(crate) fn linear_key(view: Affine) -> [u64; 4] { let c = view.as_coeffs(); [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] } diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 31ae6d102..d6992c734 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -838,6 +838,7 @@ mod tests { px: 1.0, opts: &opts, ink: Ink::Display, + output: Affine::IDENTITY, plates: None, }; let mut ctx = frame.offscreen_context(100, 100); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 68020dcfa..38516db44 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -29,6 +29,7 @@ mod stroke; use std::collections::HashMap; use std::sync::Arc; +use vectorcraft_doc::text::TypeAntiAlias; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject, TraceView}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; use vello_cpu::kurbo; @@ -124,6 +125,9 @@ pub struct Renderer { /// Objects' content run through Photoshop-style effects, per object and content slot (see /// `fx`): kept only while drawn. pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>, + /// Type drawn with an anti-aliasing method other than Sharp, per object and ink (see + /// [`Self::draw_type_edges`]). + type_edges: FrameCache<(usize, Ink), TypeEdgeEntry>, /// What the contents of those rasters read from the document, and the color settings, as last /// found for a frame (`stamp`) and document (address) (see [`Self::doc_inputs`]). doc_inputs: Option<((u64, usize), Arc)>, @@ -226,7 +230,10 @@ mod threads; pub use bounds::{drawn_bounds, drawn_outline, drawn_outlines, effect_bounds, painted_bounds}; pub use frame::FrameStats; -use frame::{ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey}; +use frame::{ + ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey, + TypeEdgeEntry, +}; #[cfg(test)] use frame::{OFFSCREEN_PIXELS, Slices}; pub use geometry::region_pixels; @@ -236,6 +243,8 @@ use node_checks::painted_items; pub use options::{AntiAlias, ISOLATION_DIM, MAX_RASTER_PIXELS, MAX_RASTER_SIDE, RenderOptions, Rendered, raster_size, supersample_factor}; pub(crate) use paint::blend_mode; use paint::fill_rule; +#[cfg(test)] +use renderer::{edge_coverage, edge_grid, type_reach}; use text_geom::TextGeom; #[cfg(test)] pub(crate) use text_geom::is_opentype_alternate; diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index bb4808d0e..5f5976a09 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -51,6 +51,8 @@ pub(crate) const FREEFORM_BUDGET: usize = 32 << 20; /// Dithered gradient grids, per thread (`dither::set_dithered_paint`): a grid is at most 4096² /// pixels, 64 MiB. pub(crate) const DITHER_BUDGET: usize = 64 << 20; +/// Type drawn with an anti-aliasing method other than Sharp, as pixels (`Renderer::draw_type_edges`). +pub(crate) const TYPE_EDGE_BUDGET: usize = 128 << 20; /// Frames an entry may go unused before [`FrameCache::sweep`] drops it. const AGE: u64 = 3; diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index f23786b49..de3a8af15 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -201,6 +201,7 @@ fn rasterize( px: 1.0 / v.determinant().abs().sqrt().max(1e-12), opts: &opts, ink, + output: v, plates: None, }; for a in &def.art { @@ -345,7 +346,8 @@ impl Renderer { let comp = crate::group::Composite { opacity: dim, ..Default::default() }; self.group(ctx, f, comp, &mut |r, c, fr| { for o in offsets { - let frame = Frame { view: fr.view * Affine::translate(*o), visible: fr.visible - *o, ..*fr }; + let moved = Affine::translate(*o); + let frame = Frame { view: fr.view * moved, visible: fr.visible - *o, output: fr.output * moved, ..*fr }; for a in children { r.draw_arc(c, &frame, a); } diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index 9ebd347de..3827829d0 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -428,7 +428,7 @@ impl Renderer { // The art in its own coordinates, with its own resources. let symbols = e.doc.symbol_boxes().quick(); let visible = m.inverse().transform_rect_bbox(f.visible); - let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, ..*f }; + let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, output: f.output * m, ..*f }; let _inside = Inside::enter(); self.draw_arc(ctx, &inner, &e.art); } diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 490703aa2..ccde5f799 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -28,6 +28,8 @@ mod text; #[cfg(test)] pub(crate) use paint::{CROP_STROKES, crop_line}; +#[cfg(test)] +pub(crate) use text::{edge_coverage, edge_grid, type_reach}; impl Renderer { pub fn new() -> Self { @@ -59,6 +61,7 @@ impl Renderer { brushes: Default::default(), shadows: PtrMap::default(), pixel_fx: PtrMap::default(), + type_edges: FrameCache::new(lru::TYPE_EDGE_BUDGET, 0), doc_inputs: None, transparency: None, live: live::LiveCache::default(), @@ -94,19 +97,22 @@ impl Renderer { /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { let _frame = vectorcraft_text::KeptLayoutsFrame::begin(); - self.render_as(doc, width, height, view, opts, false) + self.render_as(doc, width, height, view, opts, false, 1) } /// [`Self::render`], or with `inks` the ink amounts in the working CMYK space instead of - /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). - fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered { + /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). `cell` is the side of a + /// pixel of the output in pixels of this render: the supersampling factor while an export + /// draws its art at several times its size, else 1 ([`Frame::output`]). + #[allow(clippy::too_many_arguments)] + fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool, cell: u32) -> Rendered { let start = now(); if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() { let (w, h) = (width.clamp(1, MAX_CONTEXT_SIDE), height.clamp(1, MAX_CONTEXT_SIDE)); if let Some(k) = supersample_factor(w, h) { let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; let hairline = std::mem::replace(&mut self.hairline, f64::from(k)); - let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks); + let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks, k); self.hairline = hairline; let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize); self.stats.micros = now().saturating_sub(start); @@ -129,8 +135,9 @@ impl Renderer { let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display }; let symbols = doc.symbol_boxes().quick(); + let output = Affine::scale(1.0 / f64::from(cell.max(1))) * view; let plates = proof::plate_lut(opts); - let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen, plates: plates.as_deref() }; + let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen, output, plates: plates.as_deref() }; self.begin_frame(); // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) { @@ -211,6 +218,7 @@ impl Renderer { } // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); + self.type_edges.sweep(g); self.texts.sweep(g); self.text_ids.retain(|_, key| self.texts.contains_key(key)); self.char_strokes.sweep(g); @@ -319,7 +327,7 @@ impl Renderer { let (w, h) = region_pixels(region, scale); let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); let _frame = vectorcraft_text::KeptLayoutsFrame::begin(); - self.render_as(doc, w, h, view, opts, true).pixels + self.render_as(doc, w, h, view, opts, true, 1).pixels } /// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels. @@ -342,7 +350,7 @@ impl Renderer { let mut ctx = single_threaded_context(w, h); ctx.set_aliasing_threshold(opts.anti_alias.threshold()); let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, plates: None }; + let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, output: view, plates: None }; self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); @@ -387,8 +395,18 @@ impl Renderer { ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64)); } let symbols = doc.symbol_boxes().quick(); - let frame = - Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display, plates: None }; + let frame = Frame { + mt: false, + doc, + symbols: &symbols, + view, + visible: b.inflate(1.0, 1.0), + px: 1.0 / s, + opts, + ink: Ink::Display, + output: view, + plates: None, + }; self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index cc0302dee..b4a0dd0d2 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -3,7 +3,9 @@ use super::{ PlacedKey, Rect, RenderContext, Renderer, SUBSTITUTED, Shape, TextGeom, TextObject, blend_mode, blob_pixmap, depends_on_document, fx, holds_type, image_mip, ink, kurbo, lru, peniko, place, proof, stain_bits, text_geom, text_geom_snapped, vello_cpu, }; +use super::{AntiAlias, Pixmap, single_threaded_context}; use crate::paint; +use crate::{MAX_RASTER_SIDE, TypeAntiAlias, TypeEdgeEntry}; impl Renderer { pub(super) fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) { @@ -222,6 +224,9 @@ impl Renderer { self.draw_inlines(ctx, f, t, g); return; } + if t.anti_alias != TypeAntiAlias::Sharp && self.draw_type_edges(ctx, f, n, t, g, kept) { + return; + } let tb = t.xf.transform_rect_bbox(g.bounds); // Overprinting characters darken per draw, like overprinting fills and strokes // ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer. @@ -293,6 +298,233 @@ impl Renderer { } } + /// Draw type `t` of node `n` (laid out in `g`) with its anti-aliasing method, None, Crisp or + /// Strong, on the pixels of the output ([`Frame::output`]): the type is drawn offscreen on those + /// pixels and placed back pixel for pixel. With None it is drawn with hard edges, so a pixel + /// takes the type's color where the type covers at least half of it. With Crisp and Strong each + /// group of its paints ([`type_paints`]: the object's own fills and strokes one at a time, and + /// the characters' fills and strokes) is drawn on its own as Sharp draws it, and again in + /// opaque black, which gives the share `c` of each pixel the group covers; the pixel's color and + /// opacity are then scaled by [`edge_coverage`] of `c` divided by `c`, so the opacity of the + /// paints is kept, and the groups are composited in the order they are drawn. A pixel that + /// every group covers wholly or not at all is the pixel Sharp draws. Where the output's pixels would number more than the frame's there + /// and more than [`fx::PIXEL_BUDGET`], or be more than [`MAX_RASTER_SIDE`] a side (a frame + /// drawn coarser than the output), the frame's own pixels are used. + /// + /// The pixels are kept (`type_edges`). In a later frame they are placed without drawing the + /// type offscreen while the object, the output's zoom and rotation, the method and edge + /// smoothing and what the type reads ([`Self::raster_inputs`]) are unchanged, the output has + /// moved by whole pixels, and the kept pixels hold every pixel the frame needs. After a pan, + /// the kept pixels are placed moved by it; after a pan that brings more of the type into view, + /// the type is drawn offscreen again. The outlines of its character strokes are cached when + /// `g` is kept in `texts` (`geom_kept`). + /// + /// Returns false, having drawn nothing, for type that is drawn as Sharp draws it: type with + /// inline graphics, type with color glyphs (bitmap or COLR), type whose own fills or strokes + /// include a brush stroke or a blend mode other than Normal, type that overprints while + /// Overprint Preview is on, and type that shows a substitution highlight on screen. Drawn + /// offscreen, those would blend with nothing or take the method on art that is not the + /// characters'. It also returns false for type whose offscreen drawing would be more than + /// [`MAX_RASTER_SIDE`] pixels a side or more than [`fx::PIXEL_BUDGET`] pixels in all. + fn draw_type_edges(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &Arc, geom_kept: bool) -> bool { + if proof::overprints(f.opts) && t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke) { + return false; + } + let normal = vectorcraft_color::BlendMode::Normal; + let brush = |i: &AppearanceItem| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some()); + if t.runs.iter().any(|r| r.inline.is_some()) || n.appearance.items.iter().any(|i| i.visible() && (i.blend() != normal || brush(i))) { + return false; + } + // Color glyphs paint their own pixels and layers, whose opacity is not the characters' coverage. + if !g.images.is_empty() || !g.colours.is_empty() { + return false; + } + if f.opts.highlight_substitutions { + let shows = |on: bool, path: &BezPath| on && !path.elements().is_empty(); + if shows(f.opts.highlight_substituted_fonts, &g.substituted_fonts) + || shows(f.doc.setup.highlight_substituted_glyphs, &g.substituted_glyphs) + // Measured only when shown: it lays the text out again. + || (f.opts.highlight_alternate_glyphs && !g.alternate_glyphs(t).elements().is_empty()) + { + return false; + } + } + // Where the type draws: its glyphs, grown by the farthest its strokes reach, besides its + // bounds (an area type frame's, or a point type estimate before it is laid out). + let glyphs = type_reach(n, t, g); + let reach = fx::cull_bounds(f.symbols, n).map_or(glyphs, |b| b.union(glyphs)); + // This frame's pixels to the output's: a scale and an offset (a supersampled export's + // factor, an offscreen layer's crop). + let grid = f.output * f.view.inverse(); + let [sx, _, _, sy, _, _] = grid.as_coeffs(); + let reach = reach.inflate(2.0 * f.px / sx.abs().min(1.0), 2.0 * f.px / sy.abs().min(1.0)); + let on = f.view.transform_rect_bbox(reach).intersect(Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()))); + if !on.is_finite() { + return false; + } + if on.width() <= 0.0 || on.height() <= 0.0 { + return true; + } + let grid = edge_grid(on, grid); + let output = grid * f.view; + let [sx, _, _, sy, _, _] = grid.as_coeffs(); + let on = grid.transform_rect_bbox(on); + // Whole pixels of the output. + let (x0, y0) = (on.x0.floor(), on.y0.floor()); + let (Ok(w), Ok(h)) = (u16::try_from((on.x1 - x0).ceil() as u64), u16::try_from((on.y1 - y0).ceil() as u64)) else { return false }; + // (vello_cpu panics drawing near `u16::MAX` pixels a side.) Past the pixel budget the type + // is drawn as Sharp draws it. + if u32::from(w) > MAX_RASTER_SIDE || u32::from(h) > MAX_RASTER_SIDE || f64::from(w) * f64::from(h) > fx::PIXEL_BUDGET { + return false; + } + let view = Affine::translate((-x0, -y0)) * output; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); + let frame = Frame { mt: false, view, visible, px, output: view, ..*f }; + let threshold = match t.anti_alias { + TypeAntiAlias::None => AntiAlias::None.threshold(), + // The frame's pixels are smaller than the output's in a supersampled export, whose art + // is smooth on the output's pixels (Art Optimized). + _ if sx.abs() < 0.999 || sy.abs() < 0.999 => None, + _ => f.opts.anti_alias.threshold(), + }; + // Pixels kept from an earlier frame, for the type of the node itself (not a copy of its + // text): drawn on output pixels of the same size, rotation and phase, holding those needed. + let own = matches!(&n.kind, NodeKind::Text(o) if std::ptr::eq(&**o, t)); + let key = (std::ptr::from_ref(n) as usize, f.ink); + let linear = fx::linear_key(output); + let shift = output.translation(); + let settings = (t.anti_alias, threshold, f.opts.anti_alias, f.opts.precise, self.alpha.to_bits()); + let inputs = own.then(|| self.raster_inputs(f, n, fx::Reads::of(n))); + let whole = |v: f64| (v - v.round()).abs() < 1e-6; + let kept = inputs.as_ref().and_then(|inputs| { + let e = self.type_edges.get(&key, self.stamp)?; + let d = shift - e.shift; + let (ox, oy) = (e.origin.0 + d.x.round(), e.origin.1 + d.y.round()); + let (iw, ih) = (f64::from(e.image.width()), f64::from(e.image.height())); + let holds = ox <= x0 && oy <= y0 && ox + iw >= x0 + f64::from(w) && oy + ih >= y0 + f64::from(h); + let fits = e.linear == linear && e.settings == settings && whole(d.x) && whole(d.y) && holds && e.inputs == *inputs && e.node == *n; + fits.then(|| (e.image.clone(), ox, oy)) + }); + let (image, ox, oy) = match kept { + Some(kept) => kept, + None => { + let pm = self.draw_type_pixels(&frame, n, t, g, geom_kept, (w, h), threshold); + self.stats.type_edges += 1; + let image = Arc::new(pm); + if let Some(inputs) = inputs { + let bytes = usize::from(w) * usize::from(h) * 4 + lru::node_bytes(n); + let e = TypeEdgeEntry { node: n.clone(), linear, settings, inputs, shift, origin: (x0, y0), image: image.clone() }; + self.type_edges.insert(key, e, bytes, self.stamp); + } + (image, x0, y0) + } + }; + ctx.set_transform(grid.inverse() * Affine::translate((ox, oy))); + // The pixmap maps onto the rect pixel for pixel, each one on a pixel of the output. + ctx.reset_paint_transform(); + let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() }; + let (iw, ih) = (f64::from(image.width()), f64::from(image.height())); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, iw, ih)); + ctx.set_transform(Affine::IDENTITY); + true + } + + /// The pixels of type `t` of node `n` (laid out in `g`) with its anti-aliasing method, drawn + /// offscreen in `frame` on `w` × `h` pixels with edge threshold `threshold` + /// ([`Self::draw_type_edges`]). The outlines of its character strokes are cached when `g` is + /// kept in `texts` (`kept`). + #[allow(clippy::too_many_arguments)] + fn draw_type_pixels( + &mut self, + frame: &Frame, + n: &Node, + t: &TextObject, + g: &Arc, + kept: bool, + (w, h): (u16, u16), + threshold: Option, + ) -> Pixmap { + let draw = |r: &mut Self, frame: &Frame, n: &Node, t: &TextObject| { + let mut off = single_threaded_context(w, h); + off.set_aliasing_threshold(threshold); + r.inside_layer(|r| r.draw_text_geom(&mut off, frame, n, t, g, kept)); + off.flush(); + let mut pm = Pixmap::new(w, h); + off.render_with(&mut pm, &mut r.resources, r.raster); + pm + }; + let sharp = TextObject { anti_alias: TypeAntiAlias::Sharp, ..t.clone() }; + if t.anti_alias == TypeAntiAlias::None { + return draw(self, frame, n, &sharp); + } + let black = |p: &mut vectorcraft_color::Paint| { + if !p.is_none() { + *p = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + } + }; + // A group of paints drawn on its own, its pixels taking the method from its own coverage, + // and that coverage. + let reshaped = |r: &mut Self, group: Option<&[TypePaint]>| { + let (pn, pt) = match group { + Some(keep) => type_paint_only(n, &sharp, keep), + None => (n.clone(), sharp.clone()), + }; + let mut pm = draw(r, frame, &pn, &pt); + let (mut inked, mut ink) = (pn, pt); + for r in &mut ink.runs { + black(&mut r.style.fill); + black(&mut r.style.stroke); + } + for item in &mut inked.appearance.items { + black(item.paint_mut()); + match item { + AppearanceItem::Fill(fl) => fl.opacity = 1.0, + AppearanceItem::Stroke(st) => st.opacity = 1.0, + } + } + // The coverage leaves out the opacity folded in from the object the type is drawn in + // (a translucent brushed path), which the pixels already take. + let folded = std::mem::replace(&mut r.alpha, 1.0); + let covered = draw(r, frame, &inked, &ink); + r.alpha = folded; + for (p, c) in pm.data_mut().iter_mut().zip(covered.data()) { + *p = edge_pixel(*p, c.a, t.anti_alias); + } + (pm, covered) + }; + let groups: Vec>> = match type_paints(n, &sharp, g, self.alpha >= 1.0) { + Some(groups) => groups.into_iter().map(Some).collect(), + None => vec![None], + }; + if groups.len() <= 1 { + return groups.first().map_or_else(|| Pixmap::new(w, h), |group| reshaped(self, group.as_deref()).0); + } + // Where every group covers a pixel wholly or not at all, the pixel is the type's as Sharp + // draws it. Elsewhere it is the groups composited in the order they are drawn, source over. + let mut pm = draw(self, frame, n, &sharp); + let mut sum = vec![[0.0f32; 4]; usize::from(w) * usize::from(h)]; + let mut edge = vec![false; sum.len()]; + for group in &groups { + let (layer, covered) = reshaped(self, group.as_deref()); + for (((s, e), p), c) in sum.iter_mut().zip(&mut edge).zip(layer.data()).zip(covered.data()) { + *e |= c.a != 0 && c.a != 255; + let keep = 1.0 - f32::from(p.a) / 255.0; + for (v, c) in s.iter_mut().zip([p.r, p.g, p.b, p.a]) { + *v = f32::from(c) + *v * keep; + } + } + } + for ((p, s), e) in pm.data_mut().iter_mut().zip(&sum).zip(&edge) { + if *e { + let [red, green, blue, a] = s.map(|v| v.round().clamp(0.0, 255.0) as u8); + *p = vello_cpu::color::PremulRgba8 { r: red.min(a), g: green.min(a), b: blue.min(a), a }; + } + } + pm + } + /// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all` /// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode; /// strokes take every stroke option, as on paths. @@ -358,3 +590,125 @@ impl Renderer { ctx.reset_paint_transform(); } } + +/// How much of a pixel (out of 255) type that covers `c` of it shows with anti-aliasing method +/// `m`: `c` with Sharp; all of it from half coverage (127) up, else none, with None; `2c - 0.5` +/// with Crisp; and `2c` with Strong; each kept between 0 and all. +pub(crate) fn edge_coverage(m: TypeAntiAlias, c: u8) -> u8 { + let c = i32::from(c); + let shown = match m { + TypeAntiAlias::None => { + if c >= 127 { + 255 + } else { + 0 + } + } + TypeAntiAlias::Sharp => c, + TypeAntiAlias::Crisp => 2 * c - 128, + TypeAntiAlias::Strong => 2 * c, + }; + shown.clamp(0, 255) as u8 +} + +/// Where (document space) type `t` of node `n`, laid out in `g`, paints: its glyphs grown by the +/// farthest the characters' strokes reach in text space, transformed as the type is, then grown by +/// the farthest the object's own strokes reach in document space. A stroke's reach takes in its +/// miter spikes and square caps ([`StrokeLayer::reach`]). +pub(crate) fn type_reach(n: &Node, t: &TextObject, g: &TextGeom) -> Rect { + let chars = t.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max); + let own = n.appearance.outset(); + t.xf.transform_rect_bbox(g.bounds.inflate(chars, chars)).inflate(own, own) +} + +/// The map from a frame's pixels to the pixels [`Renderer::draw_type_edges`] draws type on, for +/// the region `on` of the frame's pixels: `grid`, which maps them to the output's, unless the +/// output's pixels there would number more than the frame's and more than [`fx::PIXEL_BUDGET`], +/// or be more than [`MAX_RASTER_SIDE`] a side, and then the frame's own pixels (the identity). +pub(crate) fn edge_grid(on: Rect, grid: Affine) -> Affine { + let area = |r: Rect| r.width() * r.height(); + let (out, side) = (grid.transform_rect_bbox(on), f64::from(MAX_RASTER_SIDE)); + if area(out) > area(on).max(fx::PIXEL_BUDGET) || out.width() > side || out.height() > side { Affine::IDENTITY } else { grid } +} + +/// One paint of type: one of the object's own fills and strokes (its index), or the fill or the +/// stroke of a run (its index). +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum TypePaint { + Item(usize), + Fill(usize), + Stroke(usize), +} + +/// Most groups of paints [`Renderer::draw_type_pixels`] draws one at a time ([`type_paints`]). +const MAX_TYPE_PAINTS: usize = 8; + +/// The paints of type `t` of node `n` (laid out in `g`) in groups that +/// [`Renderer::draw_type_pixels`] draws one at a time, in the order they are drawn: the object's +/// own fills and strokes below the characters, the runs' fills and strokes, then the object's own +/// fills and strokes above the characters. Each of the object's own fills and strokes is a group, +/// and so is each fill and stroke of a run, except that fills and strokes of runs one after +/// another in solid colors are one group when colors are drawn opaque (`opaque`). A run's stroke +/// starts a new group rather than join its own fill's, so the stroke's edges inside the characters +/// take the method too. Paints that draw nothing are left out. `None` for type with more than +/// [`MAX_TYPE_PAINTS`] groups, which is drawn as one. +fn type_paints(n: &Node, t: &TextObject, g: &TextGeom, opaque: bool) -> Option>> { + let shown = |i: &AppearanceItem| i.visible() && !i.paint().is_none() && !matches!(i, AppearanceItem::Stroke(st) if st.width <= 0.0); + let items = &n.appearance.items; + let k = n.appearance.contents_at(); + let mut groups: Vec> = (0..k).filter(|j| shown(&items[*j])).map(|j| vec![TypePaint::Item(j)]).collect(); + // Whether the last group holds run paints in opaque solid colors, which the next such paint joins. + let mut joins = false; + for (i, run) in t.runs.iter().enumerate() { + if g.run(i).elements().is_empty() { + continue; + } + let fill = (!run.style.fill.is_none()).then_some((TypePaint::Fill(i), &run.style.fill)); + let stroke = run.style.has_stroke().then_some((TypePaint::Stroke(i), &run.style.stroke)); + for (p, paint) in [fill, stroke].into_iter().flatten() { + let solid = opaque && matches!(paint, vectorcraft_color::Paint::Solid { .. }); + // A run's fill and stroke overlap along every outline, so they never share a group. + let own_fill = matches!(p, TypePaint::Stroke(_)) && groups.last().is_some_and(|last| last.contains(&TypePaint::Fill(i))); + match groups.last_mut() { + Some(last) if joins && solid && !own_fill => last.push(p), + _ => groups.push(vec![p]), + } + joins = solid; + } + } + groups.extend((k..items.len()).filter(|j| shown(&items[*j])).map(|j| vec![TypePaint::Item(j)])); + (groups.len() <= MAX_TYPE_PAINTS).then_some(groups) +} + +/// Node `n` and type `t` with every paint but those in `keep` taken away. +fn type_paint_only(n: &Node, t: &TextObject, keep: &[TypePaint]) -> (Node, TextObject) { + let keep: std::collections::HashSet = keep.iter().copied().collect(); + let (mut n, mut t) = (n.clone(), t.clone()); + for (j, item) in n.appearance.items.iter_mut().enumerate() { + if !keep.contains(&TypePaint::Item(j)) { + *item.paint_mut() = vectorcraft_color::Paint::None; + } + } + for (i, run) in t.runs.iter_mut().enumerate() { + if !keep.contains(&TypePaint::Fill(i)) { + run.style.fill = vectorcraft_color::Paint::None; + } + if !keep.contains(&TypePaint::Stroke(i)) { + run.style.stroke = vectorcraft_color::Paint::None; + } + } + (n, t) +} + +/// Pixel `p` of type the anti-aliasing method `m` draws, where the type covers `c` (out of 255) of +/// the pixel: its color and opacity scaled by [`edge_coverage`] of `c` divided by `c`. A pixel the +/// type covers wholly or not at all is kept. +fn edge_pixel(p: vello_cpu::color::PremulRgba8, c: u8, m: TypeAntiAlias) -> vello_cpu::color::PremulRgba8 { + if c == 0 || c == 255 { + return p; + } + let (to, c) = (u32::from(edge_coverage(m, c)), u32::from(c)); + let scale = |v: u8| ((u32::from(v) * to + c / 2) / c).min(255) as u8; + let a = scale(p.a); + vello_cpu::color::PremulRgba8 { r: scale(p.r).min(a), g: scale(p.g).min(a), b: scale(p.b).min(a), a } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f7799d02a..a12e79e86 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -32,6 +32,7 @@ mod setup; mod strokegradient; mod thincoverage; mod tileedge; +mod typeedges; mod viewraster; mod widthzoom; diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index 173d91bb0..e6054b722 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -857,6 +857,94 @@ fn a_paths_gradient_moves_with_its_transform_and_free_distort() { assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50)); } +#[test] +fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() { + use vectorcraft_color::GradientGeom; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let draw = |paint: &GradientPaint, e: Effect| { + let square = shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint.clone())), Paint::None, 0.0)); + n.appearance.effects = vec![e]; + render(&doc_with(Document::new(100.0, 100.0), n)) + }; + // Placed from the square's left edge to its right edge, the gradient is drawn as one fitted to + // the square is: after a warp both run from the warped outline's left edge to its right edge. + let placed = GradientPaint { + geom: Some(GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(60.0, 50.0), aspect: 1.0, focal: None }), + ..ramp.clone() + }; + let styles = [ + ("arc", json!({"bend": 50})), + ("bulge", json!({"bend": 60, "orientation": "vertical"})), + ("rise", json!({"bend": 40, "vertical": 50, "orientation": "vertical"})), + ]; + for (style, params) in styles.clone() { + let e = fx(&format!("warp.{style}"), params.clone()); + let (got, want) = (draw(&placed, e.clone()), draw(&ramp, e)); + // The warp moves the outline's left or right edge, so a gradient left in place would not + // run from edge to edge. + let square = vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0); + let path = shapes::rectangle(square); + let b = vectorcraft_effects::apply_geometry(&[fx(&format!("warp.{style}"), params.clone())], &path, square).bounds().unwrap(); + assert!((b.x0 - 30.0).abs() > 0.5 || (b.x1 - 60.0).abs() > 0.5, "warp.{style}: the outline spans {b:?}"); + let mut worst = 0; + for y in 0..100 { + for x in 0..100 { + let (a, b) = (got.pixel(x, y), want.pixel(x, y)); + worst = worst.max((0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0)); + } + } + assert!(worst <= 3, "warp.{style}: worst channel difference {worst}"); + } + // A stroke's own Warp: the canvas draws its gradient moved and scaled from the stroke-inflated + // bounds before the warp to those after it, as on the warped path with that gradient. + let geom = GradientGeom { start: Point::new(35.0, 50.0), end: Point::new(55.0, 50.0), aspect: 1.0, focal: None }; + let stroked = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp.clone() })); + let square = vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0); + let follow = |p: Point, from: vectorcraft_geom::Rect, to: vectorcraft_geom::Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + for (style, params) in styles { + let mut n = Node::path(NodeId(0), shapes::rectangle(square), Appearance::basic(Paint::None, stroked.clone(), 6.0)); + let Some(AppearanceItem::Stroke(layer)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).cloned() else { + panic!("a stroke") + }; + let Some(item) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") }; + item.effects_mut().push(fx(&format!("warp.{style}"), params)); + let d = doc_with(Document::new(100.0, 100.0), n); + let live = render(&d); + let baked = vectorcraft_effects::bake_document(&d).unwrap(); + let mut warped = None; + for l in &baked.layers { + l.walk(&mut |c: &Node| { + if let (Some(path), true) = (c.path_data(), c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))) { + warped.get_or_insert_with(|| path.clone()); + } + }); + } + let path = warped.unwrap(); + let b = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); + assert_ne!(b, square, "warp.{style}: the warp changes the bounds"); + let (from, to) = (layer.paint_bounds(square), layer.paint_bounds(b)); + let moved = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom }; + let want_node = Node::path( + NodeId(0), + path, + Appearance::basic(Paint::None, Paint::Gradient(Box::new(GradientPaint { geom: Some(moved), ..ramp.clone() })), 6.0), + ); + let want = render(&doc_with(Document::new(100.0, 100.0), want_node)); + let mut worst = 0; + for y in 0..100 { + for x in 0..100 { + let (a, b) = (live.pixel(x, y), want.pixel(x, y)); + worst = worst.max((0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0)); + } + } + assert!(worst <= 3, "warp.{style} on the stroke: worst channel difference {worst}"); + } +} + #[test] fn darkness_subtracts_from_rgb_before_shadow_opacity() { for opacity in [50.0, 100.0] { diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs new file mode 100644 index 000000000..fae0207d0 --- /dev/null +++ b/crates/render/src/tests/typeedges.rs @@ -0,0 +1,921 @@ +//! Character panel › Anti-aliasing Method: type drawn as pixels with None, Sharp, Crisp or Strong +//! edges. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::pattern::{PatternDef, PatternEdit}; +use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun, TypeAntiAlias}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, TextKind}; +use vectorcraft_geom::Point; +use vectorcraft_geom::shapes::rectangle; + +use crate::options::MAX_CONTEXT_SIDE; +use crate::*; + +const W: u32 = 260; +const H: u32 = 90; + +/// A page with "Hamburg" in 40 pt black type drawn with `method`, and `fill` as the type object's +/// own fill (instead of the characters' fill) when given. +fn page(method: TypeAntiAlias, fill: Option) -> Document { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(10.3, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() }); + t.anti_alias = method; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + if let Some(fl) = fill { + if let NodeKind::Text(t) = &mut n.kind { + t.runs[0].style.fill = Paint::None; + } + n.appearance.items.push(AppearanceItem::Fill(fl)); + } + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +/// The opacity of each pixel of `d` drawn with `opts` on a transparent page. +fn alphas(d: &Document, opts: &RenderOptions) -> Vec { + Renderer::new().render(d, W, H, Affine::IDENTITY, opts).pixels.chunks(4).map(|p| p[3]).collect() +} + +/// Pixels the type covers only in part. +fn partial(a: &[u8]) -> usize { + a.iter().filter(|v| **v > 0 && **v < 255).count() +} + +/// The first pixels of `drawn` (type drawn with `m`) that are not the share `edge_coverage` gives +/// the same pixel of `sharp` (the type drawn with Sharp), within a step of rounding: (pixel, +/// Sharp, drawn). +fn off_the_share(m: TypeAntiAlias, sharp: &[u8], drawn: &[u8]) -> Vec<(usize, u8, u8)> { + sharp + .iter() + .zip(drawn) + .enumerate() + .filter(|(_, (c, v))| { + // One step of rounding either way in the coverage. + let near = [c.saturating_sub(1), **c, c.saturating_add(1)].map(|c| edge_coverage(m, c)); + let (lo, hi) = (near.iter().min().copied().unwrap_or(0), near.iter().max().copied().unwrap_or(255)); + **v < lo.saturating_sub(1) || **v > hi.saturating_add(1) + }) + .map(|(i, (c, v))| (i, *c, *v)) + .take(5) + .collect() +} + +/// On the canvas each method gives every pixel the share of it that `edge_coverage` gives the +/// share Sharp draws: None paints whole pixels from half coverage up, Crisp doubles the contrast +/// around half coverage, and Strong doubles the coverage. +#[test] +fn each_method_draws_the_share_of_each_pixel_it_defines() { + let opts = RenderOptions::default(); + let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + assert!(partial(&sharp) > 200, "Sharp draws smooth edges: {} partial pixels", partial(&sharp)); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = alphas(&page(m, None), &opts); + assert_ne!(drawn, sharp, "{m:?} changes the edges"); + let wrong = off_the_share(m, &sharp, &drawn); + assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } + assert_eq!(partial(&alphas(&page(TypeAntiAlias::None, None), &opts)), 0, "None paints whole pixels"); + // The same text drawn with each of the four methods gives four different pictures. + let methods = [TypeAntiAlias::None, TypeAntiAlias::Sharp, TypeAntiAlias::Crisp, TypeAntiAlias::Strong]; + let drawn: Vec> = methods.iter().map(|m| alphas(&page(*m, None), &opts)).collect(); + for (i, a) in drawn.iter().enumerate() { + for (j, b) in drawn.iter().enumerate().skip(i + 1) { + assert_ne!(a, b, "{:?} and {:?} draw alike", methods[i], methods[j]); + } + } +} + +/// `edge_coverage` gives each method the share its documentation defines: None shows all of a +/// pixel from half coverage (127) up and none of it below, Sharp shows the coverage, Crisp shows +/// `2c - 0.5` and Strong shows `2c`, each kept between 0 and all. +#[test] +fn edge_coverage_gives_each_methods_documented_share() { + // Coverage, then what None, Sharp, Crisp and Strong show of the pixel (out of 255). + let table: [(u8, [u8; 4]); 11] = [ + (0, [0, 0, 0, 0]), + (1, [0, 1, 0, 2]), + (63, [0, 63, 0, 126]), + (64, [0, 64, 0, 128]), + (65, [0, 65, 2, 130]), + (126, [0, 126, 124, 252]), + (127, [255, 127, 126, 254]), + (128, [255, 128, 128, 255]), + (191, [255, 191, 254, 255]), + (192, [255, 192, 255, 255]), + (255, [255, 255, 255, 255]), + ]; + for (c, shown) in table { + let got = [TypeAntiAlias::None, TypeAntiAlias::Sharp, TypeAntiAlias::Crisp, TypeAntiAlias::Strong].map(|m| edge_coverage(m, c)); + assert_eq!(got, shown, "coverage {c}: None, Sharp, Crisp, Strong"); + } +} + +/// A raster export draws its art at several times its size and averages it down; type with None +/// still has hard edges on the exported pixels, Crisp has fewer partly covered pixels than Sharp, +/// and Strong draws heavier type. +#[test] +fn an_exports_pixels_take_the_method() { + let opts = RenderOptions { supersample: true, precise: true, ..Default::default() }; + let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + let none = alphas(&page(TypeAntiAlias::None, None), &opts); + let crisp = alphas(&page(TypeAntiAlias::Crisp, None), &opts); + let strong = alphas(&page(TypeAntiAlias::Strong, None), &opts); + assert!(partial(&sharp) > 200, "the export smooths Sharp type: {}", partial(&sharp)); + assert_eq!(partial(&none), 0, "None type has hard edges in the export"); + assert!(none.iter().filter(|v| **v == 255).count() > 1000); + assert!(partial(&crisp) < partial(&sharp), "Crisp: {} partial pixels, Sharp {}", partial(&crisp), partial(&sharp)); + let ink = |a: &[u8]| a.iter().map(|v| u64::from(*v)).sum::(); + assert!(ink(&strong) > ink(&sharp), "Strong draws heavier type"); +} + +/// A frame drawn with hard edges (Anti-aliased Artwork off, an export's Anti-aliasing None) draws +/// the type of every method as it draws Sharp type. +#[test] +fn hard_edged_frames_draw_every_method_alike() { + for opts in [ + RenderOptions { anti_alias: AntiAlias::None, ..Default::default() }, + RenderOptions { anti_alias: AntiAlias::None, precise: true, supersample: true, ..Default::default() }, + ] { + let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + assert_eq!(partial(&sharp), 0); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(alphas(&page(m, None), &opts), sharp, "{m:?}"); + } + } +} + +/// Crisp and Strong reshape the edges of type that is drawn with a translucent fill, and keep +/// its opacity inside the characters; None keeps it too. +#[test] +fn the_methods_keep_the_opacity_of_the_paint() { + let half = FillLayer { opacity: 0.5, ..FillLayer::new(Paint::solid(Color::BLACK)) }; + let opts = RenderOptions::default(); + let sharp = alphas(&page(TypeAntiAlias::Sharp, Some(half.clone())), &opts); + let most = |a: &[u8]| a.iter().copied().max().unwrap_or(0); + assert!((126..=129).contains(&most(&sharp)), "half opaque: {}", most(&sharp)); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = alphas(&page(m, Some(half.clone())), &opts); + assert!((126..=129).contains(&most(&drawn)), "{m:?} keeps the fill's opacity: {}", most(&drawn)); + assert_ne!(drawn, sharp, "{m:?} changes the edges"); + } +} + +/// Crisp and Strong reshape the edges of each of the type's paints on its own: where two +/// half-opaque fills of the object overlap, each edge pixel shows the first fill with the +/// method's share of the pixel under the second fill with that share. +#[test] +fn the_methods_reshape_each_paint_on_its_own() { + let half = FillLayer { opacity: 0.5, ..FillLayer::new(Paint::solid(Color::BLACK)) }; + let opts = RenderOptions::default(); + let coverage = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + assert!(partial(&coverage) > 200, "{} partial pixels", partial(&coverage)); + for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let mut d = page(m, Some(half.clone())); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + d.node_mut(id).unwrap().appearance.items.push(AppearanceItem::Fill(half.clone())); + let drawn = alphas(&d, &opts); + // Two half-opaque layers that each show `e` of the pixel, within a step of rounding in the + // coverage and in the result. + let both = |c: u8| { + let e = f64::from(edge_coverage(m, c)) / 255.0; + 255.0 * (1.0 - (1.0 - 0.5 * e).powi(2)) + }; + let wrong: Vec<_> = coverage + .iter() + .zip(&drawn) + .enumerate() + .filter(|(_, (c, v))| { + let near = [c.saturating_sub(1), **c, c.saturating_add(1)].map(both); + let (lo, hi) = (near.iter().copied().fold(f64::MAX, f64::min), near.iter().copied().fold(0.0, f64::max)); + f64::from(**v) < lo - 1.5 || f64::from(**v) > hi + 1.5 + }) + .map(|(i, (c, v))| (i, *c, *v)) + .take(5) + .collect(); + assert!(wrong.is_empty(), "{m:?}: (pixel, coverage, drawn) {wrong:?}"); + } +} + +/// Crisp and Strong reshape the inner edge of a character stroke, where the stroke lies on the +/// characters' fill in another color: at a pixel the fill covers wholly, the stroke shows the +/// method's share of its own coverage. +#[test] +fn the_methods_reshape_a_character_strokes_inner_edge() { + let opts = RenderOptions::default(); + // "Hamburg" with the characters' fill `fill` and a 1.5 pt stroke `stroke`, drawn with `m`. + let draw = |m: TypeAntiAlias, fill: Paint, stroke: Paint| { + let mut d = page(m, None); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + let s = &mut t.runs[0].style; + (s.fill, s.stroke, s.stroke_width) = (fill, stroke, 1.5); + } + Renderer::new().render(&d, W, H, Affine::IDENTITY, &opts).pixels + }; + let black = || Paint::solid(Color::BLACK); + let alpha = |p: Vec| p.chunks(4).map(|p| p[3]).collect::>(); + let fill = alpha(draw(TypeAntiAlias::Sharp, black(), Paint::None)); + let stroke = alpha(draw(TypeAntiAlias::Sharp, Paint::None, black())); + // Pixels inside the fill that the stroke covers in part: the stroke's inner edge. + let inner: Vec = (0..fill.len()).filter(|i| fill[*i] == 255 && stroke[*i] > 8 && stroke[*i] < 247).collect(); + assert!(inner.len() > 50, "{} pixels on the stroke's inner edge", inner.len()); + for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let wrong: Vec<_> = inner + .iter() + .filter(|i| { + let c = stroke[**i]; + let near = [c.saturating_sub(1), c, c.saturating_add(1)].map(|c| edge_coverage(m, c)); + let (lo, hi) = (near.iter().min().copied().unwrap_or(0), near.iter().max().copied().unwrap_or(255)); + let red = drawn[**i * 4]; + red < lo.saturating_sub(2) || red > hi.saturating_add(2) + }) + .take(5) + .map(|i| (*i, stroke[*i], drawn[*i * 4])) + .collect(); + assert!(wrong.is_empty(), "{m:?}: (pixel, stroke coverage, red) {wrong:?}"); + } +} + +/// Type with more groups of paints than the renderer draws one at a time is drawn as one group, +/// and each method still reshapes its outer edges. Nine runs, each with an opaque solid fill and a +/// stroke, make ten groups: each run's stroke starts a group that the next run's fill joins. Crisp +/// and Strong give each pixel the share `edge_coverage` gives the share Sharp draws, and None +/// paints whole pixels. +#[test] +fn type_with_more_paint_groups_than_the_limit_takes_the_method() { + let opts = RenderOptions::default(); + let draw = |m: TypeAntiAlias| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let style = CharStyle { + size: 32.0, + fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), + stroke: Paint::solid(Color::rgb(1.0, 0.0, 0.0)), + stroke_width: 1.0, + ..Default::default() + }; + let mut t = TextObject::point(Point::new(10.3, 60.4), "H", style.clone()); + for c in "amburger".chars() { + t.runs.push(TextRun::new(c.to_string(), style.clone())); + } + assert_eq!(t.runs.len(), 9, "nine runs"); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &opts) + }; + let sharp = draw(TypeAntiAlias::Sharp); + assert!(partial(&sharp) > 200, "Sharp draws smooth edges: {} partial pixels", partial(&sharp)); + for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m); + assert_ne!(drawn, sharp, "{m:?} changes the edges"); + let wrong = off_the_share(m, &sharp, &drawn); + assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } + let none = draw(TypeAntiAlias::None); + assert_ne!(none, sharp, "None changes the edges"); + assert_eq!(partial(&none), 0, "None paints whole pixels"); +} + +/// Crisp and Strong type with several faint fills of the object draws the pixels inside the +/// characters as Sharp draws them: the fills are composited there before their colors are rounded. +#[test] +fn the_methods_keep_faint_fills_inside_the_characters() { + let faint = FillLayer { opacity: 0.01, ..FillLayer::new(Paint::solid(Color::BLACK)) }; + let draw = |m: TypeAntiAlias| { + let mut d = page(m, Some(faint.clone())); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let n = d.node_mut(id).unwrap(); + for _ in 0..7 { + n.appearance.items.push(AppearanceItem::Fill(faint.clone())); + } + alphas(&d, &RenderOptions::default()) + }; + let sharp = draw(TypeAntiAlias::Sharp); + // The pixels the characters cover wholly. + let coverage = alphas(&page(TypeAntiAlias::Sharp, None), &RenderOptions::default()); + let inside: Vec = coverage.iter().enumerate().filter(|(_, c)| **c == 255).map(|(i, _)| i).collect(); + assert!(inside.len() > 200, "{} pixels inside the characters", inside.len()); + assert!(inside.iter().all(|i| sharp[*i] > 0), "the faint fills show inside the characters"); + for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m); + let wrong: Vec<_> = inside.iter().filter(|i| drawn[**i] != sharp[**i]).take(5).map(|i| (*i, sharp[*i], drawn[*i])).collect(); + assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } +} + +/// In an export that composites with floating point, None, Crisp and Strong type composite its +/// translucent fills as precisely as Sharp type: inside the characters each method draws the +/// pixels Sharp draws, within the one step of rounding that keeping the type's pixels as 8-bit +/// values adds. +#[test] +fn the_methods_keep_precise_compositing() { + let fills = [(Color::rgb(0.9, 0.2, 0.1), 0.37), (Color::rgb(0.1, 0.6, 0.8), 0.53), (Color::rgb(0.3, 0.1, 0.7), 0.29)]; + let opts = RenderOptions { precise: true, ..Default::default() }; + let draw = |m: TypeAntiAlias| { + let mut d = page(m, Some(FillLayer { opacity: 0.41, ..FillLayer::new(Paint::solid(Color::rgb(0.2, 0.7, 0.3))) })); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let n = d.node_mut(id).unwrap(); + for (c, o) in fills { + n.appearance.items.push(AppearanceItem::Fill(FillLayer { opacity: o, ..FillLayer::new(Paint::solid(c)) })); + } + Renderer::new().render(&d, W, H, Affine::IDENTITY, &opts).pixels + }; + let sharp = draw(TypeAntiAlias::Sharp); + let most = sharp.chunks(4).map(|p| p[3]).max().unwrap_or(0); + let inside: Vec = sharp.chunks(4).enumerate().filter(|(_, p)| p[3] == most).map(|(i, _)| i).collect(); + assert!(inside.len() > 200, "{} pixels inside the characters", inside.len()); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m); + let px = |p: &[u8], i: usize| [p[i * 4], p[i * 4 + 1], p[i * 4 + 2], p[i * 4 + 3]]; + let off = |i: usize| px(&drawn, i).iter().zip(px(&sharp, i)).any(|(a, b)| a.abs_diff(b) > 1); + let wrong: Vec<_> = inside.iter().filter(|i| off(**i)).take(5).map(|i| (*i, px(&sharp, *i), px(&drawn, *i))).collect(); + assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } +} + +/// Type whose own fill multiplies with the art below it is drawn as Sharp draws it, so the fill +/// still multiplies with that art. +#[test] +fn type_with_a_blended_fill_is_drawn_as_sharp() { + let multiply = FillLayer { blend: vectorcraft_color::BlendMode::Multiply, ..FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))) }; + let on_orange = |m: TypeAntiAlias| { + let mut d = page(m, Some(multiply.clone())); + let orange = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0); + let back = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, f64::from(W), f64::from(H))), orange); + let l = d.layers[0].id; + d.insert(Some(l), 0, back).unwrap(); + Renderer::new().render(&d, W, H, Affine::IDENTITY, &RenderOptions::default()).pixels + }; + let sharp = on_orange(TypeAntiAlias::Sharp); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(on_orange(m), sharp, "{m:?}"); + } +} + +/// Type with an inline graphic is drawn as Sharp draws it, so the graphic, which multiplies with +/// the art below it here, is drawn as before. +#[test] +fn type_with_an_inline_graphic_is_drawn_as_sharp() { + let on_orange = |m: TypeAntiAlias| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let orange = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0); + let back = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, f64::from(W), f64::from(H))), orange); + let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let mut art = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, -30.0, 30.0, 0.0)), blue); + art.blend = vectorcraft_color::BlendMode::Multiply; + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + let style = CharStyle { size: 40.0, ..Default::default() }; + let mut t = TextObject::point(Point::new(10.3, 60.4), "I", style.clone()); + t.runs.push(TextRun::inline(InlineArt::new("S"), style)); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, back).unwrap(); + d.insert(Some(l), 1, n).unwrap(); + d.resolve_inline_art(); + Renderer::new().render(&d, W, H, Affine::IDENTITY, &RenderOptions::default()).pixels + }; + let sharp = on_orange(TypeAntiAlias::Sharp); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(on_orange(m), sharp, "{m:?}"); + } +} + +/// Type with a color glyph (a bitmap in `sbix` or `CBDT`, or COLR v0 or v1 layers) is drawn as +/// Sharp draws it, so the glyph's own opacity is kept. +#[test] +fn type_with_a_color_glyph_is_drawn_as_sharp() { + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font, colr_family, colr_font}; + let _adding = crate::tests_fontchange::adding_fonts(); + let fonts = [ + (colour_bitmap_family(true), colour_bitmap_font(true)), + (colour_bitmap_family(false), colour_bitmap_font(false)), + (colr_family(false), colr_font(false)), + (colr_family(true), colr_font(true)), + ]; + for (family, font) in fonts { + let Some(font) = font else { panic!("{family}: the test font is built") }; + vectorcraft_text::FontDb::global().add_font(font); + let draw = |m: TypeAntiAlias| { + let mut d = Document::new(160.0, 160.0); + let style = CharStyle { font_family: family.into(), size: 100.0, ..Default::default() }; + let mut t = TextObject::point(Point::new(20.3, 120.4), &COLOUR_CHAR.to_string(), style); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions::default()).pixels + }; + let sharp = draw(TypeAntiAlias::Sharp); + assert!(sharp.chunks(4).any(|p| p[3] > 0), "{family}: the glyph is drawn"); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert!(draw(m) == sharp, "{family}, {m:?}"); + } + } +} + +/// In a supersampled export, None type in a group drawn offscreen (it isolates art that blends) +/// still has hard edges on the exported pixels, wherever the group's layer starts. +#[test] +fn type_in_an_offscreen_group_keeps_the_exports_pixels() { + let opts = RenderOptions { supersample: true, precise: true, ..Default::default() }; + for x in [10.3, 10.55, 10.8, 11.05] { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(x, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() }); + t.anti_alias = TypeAntiAlias::None; + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + let mut square = Node::path(d.alloc_id(), rectangle(Rect::new(220.0, 10.0, 250.0, 30.0)), black); + square.blend = vectorcraft_color::BlendMode::Multiply; + let mut group = Node::group(d.alloc_id(), vec![Arc::new(square), Arc::new(text)]); + group.isolate = true; + let l = d.layers[0].id; + d.insert(Some(l), 0, group).unwrap(); + let a = alphas(&d, &opts); + assert!(a.iter().filter(|v| **v == 255).count() > 1000, "x {x}"); + assert_eq!(partial(&a), 0, "x {x}: None type has hard edges"); + } +} + +/// In a supersampled export, None type in a layer drawn offscreen over a copy of the art below it +/// (the layer multiplies, and art inside it blends) still has hard edges on the exported pixels, +/// wherever the layer's pixels start. +#[test] +fn type_in_an_offscreen_layer_keeps_the_exports_pixels() { + let opts = RenderOptions { supersample: true, precise: true, ..Default::default() }; + for x in [10.3, 10.55, 10.8, 11.05] { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(x, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() }); + t.anti_alias = TypeAntiAlias::None; + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + let mut square = Node::path(d.alloc_id(), rectangle(Rect::new(220.0, 10.0, 250.0, 30.0)), black); + square.blend = vectorcraft_color::BlendMode::Multiply; + let l = d.layers[0].id; + d.insert(Some(l), 0, square).unwrap(); + d.insert(Some(l), 1, text).unwrap(); + Arc::make_mut(&mut d.layers[0]).blend = vectorcraft_color::BlendMode::Multiply; + let a = alphas(&d, &opts); + assert!(a.iter().filter(|v| **v == 255).count() > 1000, "x {x}"); + assert_eq!(partial(&a), 0, "x {x}: None type has hard edges"); + } +} + +/// Type in a placed document, placed at half and at twice its size with its frame starting part +/// of a pixel in, is drawn on the page's pixels: on the canvas each method gives every pixel the +/// share of it `edge_coverage` defines, and in a supersampled export None type has hard edges. +#[test] +fn type_in_a_placed_document_takes_the_pages_pixels() { + use vectorcraft_doc::{ImageBlob, LinkInfo, PlacedDocument}; + // A page showing a file that holds "Hamburg" at 40 / `k` pt drawn with `m`, placed at `k` times + // its size, so the type is 40 pt on the page. + let placing = |m: TypeAntiAlias, k: f64| { + let frame = Rect::new(0.0, 0.0, f64::from(W) / k, f64::from(H) / k); + let mut t = TextObject::point(Point::new(10.0 / k, 60.0 / k), "Hamburg", CharStyle { size: 40.0 / k, ..Default::default() }); + t.anti_alias = m; + let text = Node::new(NodeId(1), NodeKind::Text(Box::new(t))); + let art = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(text)], clip: false }); + let key = format!("typeedges-{m:?}-{k}"); + let blob = ImageBlob::new("application/json", key.clone().into_bytes()); + let p = PlacedDocument { + link: LinkInfo::new("/art/type.vectorcraft"), + key: key.clone(), + bounding: false, + page_box: Default::default(), + width: f64::from(W), + height: f64::from(H), + xf: Affine::translate((0.3, 0.4)), + placement: Default::default(), + }; + // The file as read, kept for the object, so drawing it needs no loader. + vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (Document::new(frame.width(), frame.height()), art, frame)); + let mut d = Document::new(f64::from(W), f64::from(H)); + d.images.insert(key, blob); + let n = Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(p))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d + }; + let export = RenderOptions { supersample: true, precise: true, ..Default::default() }; + for k in [0.5, 2.0] { + let sharp = alphas(&placing(TypeAntiAlias::Sharp, k), &RenderOptions::default()); + assert!(partial(&sharp) > 200, "k {k}: Sharp draws smooth edges: {} partial pixels", partial(&sharp)); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = alphas(&placing(m, k), &RenderOptions::default()); + assert_ne!(drawn, sharp, "k {k}: {m:?} changes the edges"); + let wrong = off_the_share(m, &sharp, &drawn); + assert!(wrong.is_empty(), "k {k}, {m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } + let none = alphas(&placing(TypeAntiAlias::None, k), &export); + assert!(none.iter().filter(|v| **v == 255).count() > 1000, "k {k}"); + assert_eq!(partial(&none), 0, "k {k}: None type has hard edges in the export"); + } +} + +/// The opacity of each pixel of `d` drawn at `view` on a transparent canvas `w` × `h` pixels. +fn alphas_at(d: &Document, (w, h): (u32, u32), view: Affine) -> Vec { + Renderer::new().render(d, w, h, view, &RenderOptions::default()).pixels.chunks(4).map(|p| p[3]).collect() +} + +/// `page(m, None)` with the effect `id` (parameters `params`) on the type object, and the object's +/// id. +fn page_with_effect(m: TypeAntiAlias, id: &str, params: Value) -> (Document, NodeId) { + let mut d = page(m, None); + let n = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + d.node_mut(n).unwrap().appearance.effects.push(Effect { id: id.into(), params, visible: true }); + (d, n) +} + +/// "Ham" in 30 pt type drawn with `m`, its baseline at (5.3, 30.4). +fn ham(d: &mut Document, m: TypeAntiAlias) -> Node { + let mut t = TextObject::point(Point::new(5.3, 30.4), "Ham", CharStyle { size: 30.0, ..Default::default() }); + t.anti_alias = m; + Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))) +} + +/// `draw` gives the pixels of type drawn with a method: Sharp type has smooth edges there, and +/// each other method changes them and gives every pixel the share of it `edge_coverage` defines. +fn each_method_takes_the_share(what: &str, draw: impl Fn(TypeAntiAlias) -> Vec) { + let sharp = draw(TypeAntiAlias::Sharp); + assert!(partial(&sharp) > 200, "{what}: Sharp draws smooth edges: {} partial pixels", partial(&sharp)); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m); + assert_ne!(drawn, sharp, "{what}: {m:?} changes the edges"); + let wrong = off_the_share(m, &sharp, &drawn); + assert!(wrong.is_empty(), "{what}, {m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } +} + +/// A drop shadow whose offset is not a whole number of pixels draws the type on the canvas's +/// pixels: each method gives every pixel, the shadow's below the type included, the share of it +/// `edge_coverage` defines. +#[test] +fn type_in_a_drop_shadow_takes_the_canvas_pixels() { + let shadow = json!({"mode": "normal", "opacity": 100, "x": 3.4, "y": 45.3, "blur": 0, "color": "#000000"}); + let draw = |m| alphas_at(&page_with_effect(m, "stylize.dropShadow", shadow.clone()).0, (W, 130), Affine::IDENTITY); + // Below the type's descender, the shadow alone. + let below = |a: &[u8]| a[(W * 75) as usize..].iter().filter(|v| **v == 255).count(); + assert!(below(&draw(TypeAntiAlias::Sharp)) > 500, "the shadow is drawn below the type"); + each_method_takes_the_share("drop shadow", draw); +} + +/// Effect › Rasterize draws the type on the pixels of its image: at 36 ppi each pixel of the image +/// is 2 × 2 pixels of a canvas whose grid it lies on, and each method gives every pixel the share +/// of it `edge_coverage` defines. +#[test] +fn type_in_a_rasterize_takes_the_images_pixels() { + let draw = |m| { + let (d, id) = page_with_effect(m, "rasterize", json!({"useDocumentResolution": false, "resolution": 36})); + let image = drawn_bounds(&d, d.node(id).unwrap()).unwrap(); + // The image's corner on a corner of a canvas pixel. + let view = Affine::translate((image.x0.ceil() - image.x0, image.y0.ceil() - image.y0)); + alphas_at(&d, (W, H), view) + }; + each_method_takes_the_share("rasterize", draw); +} + +/// A pattern tile holding type draws it on the pixels of the tile's image, also in the copies of +/// the tile 80.25 pixels away: in the swatch each method gives every pixel the share of it +/// `edge_coverage` defines. +#[test] +fn type_in_a_pattern_tile_takes_the_tiles_pixels() { + let draw = |m| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let text = ham(&mut d, m); + let mut def = PatternDef::new("Type", vec![Arc::new(text)]); + // The swatch draws 2 pixels a point and shows the copies above and below the tile, 80.25 + // pixels away. + def.tile = Rect::new(0.0, 0.0, 120.5, 40.125); + d.patterns.push(def); + let swatch = render_pattern_swatch(&d, "Type", 241).unwrap(); + swatch.pixels.chunks(4).map(|p| p[3]).collect::>() + }; + // The rows of the copy above the tile. + let above = |a: &[u8]| a[..241 * 60].iter().filter(|v| **v == 255).count(); + assert!(above(&draw(TypeAntiAlias::Sharp)) > 200, "the swatch shows the copy above the tile"); + each_method_takes_the_share("pattern tile", draw); +} + +/// Pattern editing mode draws the copies of the tile, 120.25 pixels apart across and 40.125 pixels +/// apart down, on the canvas's pixels: each method gives every pixel the share of it +/// `edge_coverage` defines. +#[test] +fn type_in_the_copies_of_an_edited_tile_takes_the_canvas_pixels() { + let draw = |m| { + let mut d = Document::new(400.0, 200.0); + let text = ham(&mut d, m); + let mut def = PatternDef::new("Type", vec![Arc::new(text.clone())]); + def.tile = Rect::new(0.0, 0.0, 120.25, 40.125); + // The eight copies around the tile, at full opacity. + (def.copies, def.dim_copies, def.show_tile_edge) = (3, 100.0, false); + d.patterns.push(def); + let layer = d.add_layer(Some("Pattern Editing Mode")); + d.insert(Some(layer), 0, text).unwrap(); + d.pattern_edit = Some(PatternEdit { pattern: "Type".into(), layer, original: None }); + alphas_at(&d, (380, 130), Affine::translate((125.0, 45.0))) + }; + // The rows of the copies above the tile. + let above = |a: &[u8]| a[..380 * 40].iter().filter(|v| **v == 255).count(); + assert!(above(&draw(TypeAntiAlias::Sharp)) > 200, "the copies above the tile are drawn"); + each_method_takes_the_share("pattern editing", draw); +} + +/// Type with a brush stroke of its own is drawn as Sharp draws it. +#[test] +fn type_with_a_brush_stroke_is_drawn_as_sharp() { + let draw = |m: TypeAntiAlias| { + let mut d = page(m, None); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let n = d.node_mut(id).unwrap(); + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0); + st.brush = Some("Charcoal".into()); + n.appearance.items.push(AppearanceItem::Stroke(st)); + alphas(&d, &RenderOptions::default()) + }; + let sharp = draw(TypeAntiAlias::Sharp); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(draw(m), sharp, "{m:?}"); + } +} + +/// Strong and Crisp type in the art of a scatter brush, on an undashed path at 50% opacity, draw +/// every pixel at half of what the same path draws at full opacity: the coverage the method +/// reshapes the edges by is drawn without the path's opacity. +#[test] +fn type_in_a_translucent_scatter_brush_stroke_keeps_its_opacity() { + use vectorcraft_brush::{Brush, BrushKind, Scatter}; + let draw = |m: TypeAntiAlias, opacity: f32| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(0.0, 0.0), "H", CharStyle { size: 40.0, ..Default::default() }); + t.anti_alias = m; + let art = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Text(Box::new(t))); + let mut lib = vectorcraft_brush::library(&d); + lib.push(Brush { name: "Letters".into(), kind: BrushKind::Scatter(Scatter { art, spacing: (200.0, 200.0), ..Default::default() }) }); + vectorcraft_brush::store(&mut d, &lib); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = ap.stroke_mut() { + st.brush = Some("Letters".into()); + } + let line = vectorcraft_geom::shapes::line(Point::new(20.0, 45.0), Point::new(240.0, 45.0)); + let mut n = Node::path(d.alloc_id(), line, ap); + n.opacity = opacity; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &RenderOptions::default()) + }; + let most = |a: &[u8]| a.iter().copied().max().unwrap_or(0); + let sharp = draw(TypeAntiAlias::Sharp, 0.5); + assert!((126..=129).contains(&most(&sharp)), "half opaque: {}", most(&sharp)); + for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let (half, full) = (draw(m, 0.5), draw(m, 1.0)); + assert_ne!(half, sharp, "{m:?} changes the edges"); + let off = half.iter().zip(&full).filter(|&(&h, &f)| (2 * i32::from(h) - i32::from(f)).abs() > 6).count(); + assert_eq!(off, 0, "{m:?}: pixels not at half of full opacity"); + } +} + +/// On screen, type in a font the system doesn't have draws as Sharp draws it while Document +/// Setup highlights substituted fonts, and with its method when nothing is highlighted. +#[test] +fn type_with_a_substitution_highlight_is_drawn_as_sharp() { + let draw = |m: TypeAntiAlias, highlight: bool| { + let mut d = Document::new(f64::from(W), f64::from(H)); + d.setup.highlight_substituted_fonts = true; + let style = CharStyle { size: 40.0, font_family: "No Such Family 7Q".into(), ..Default::default() }; + let mut t = TextObject::point(Point::new(10.3, 60.4), "Hamburg", style); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &RenderOptions { highlight_substitutions: highlight, ..Default::default() }) + }; + assert_eq!(draw(TypeAntiAlias::Strong, true), draw(TypeAntiAlias::Sharp, true)); + assert_ne!(draw(TypeAntiAlias::Strong, false), draw(TypeAntiAlias::Sharp, false)); +} + +/// On screen, type with a ligature draws as Sharp draws it while Preferences › Type › Highlight +/// Alternate Glyphs is on, and with its method while it is off. +#[test] +fn type_with_an_alternate_glyph_highlight_is_drawn_as_sharp() { + // Source Serif 4 (bundled with vectorcraft-text's `bundled-fonts`) ligates "fi". + if vectorcraft_text::FontDb::global().face("Source Serif 4", "Regular").is_none() { + eprintln!("SKIPPED: type_with_an_alternate_glyph_highlight_is_drawn_as_sharp; no Source Serif 4"); + return; + } + let draw = |m: TypeAntiAlias, alternates: bool| { + let mut d = Document::new(f64::from(W), f64::from(H)); + d.setup.highlight_substituted_fonts = false; + d.setup.highlight_substituted_glyphs = false; + let style = CharStyle { size: 40.0, font_family: "Source Serif 4".into(), ..Default::default() }; + let mut t = TextObject::point(Point::new(10.3, 60.4), "fish", style); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &RenderOptions { highlight_substitutions: true, highlight_alternate_glyphs: alternates, ..Default::default() }) + }; + assert_eq!(draw(TypeAntiAlias::Strong, true), draw(TypeAntiAlias::Sharp, true)); + assert_ne!(draw(TypeAntiAlias::Strong, false), draw(TypeAntiAlias::Sharp, false)); +} + +/// The type is drawn on the output's pixels unless, in a frame coarser than the output, they +/// would number more than the frame's there and more than the effects' pixel budget, or be more +/// than `MAX_RASTER_SIDE` a side. +#[test] +fn type_edges_are_drawn_on_at_most_the_budgets_pixels() { + let finer = Affine::scale(0.25); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 4000.0, 4000.0), finer), finer, "a supersampled export's pixels"); + let coarser = Affine::scale(4.0); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 100.0, 100.0), coarser), coarser, "160,000 pixels"); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 1000.0, 1000.0), coarser), Affine::IDENTITY, "16 million pixels"); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 10_000.0, 1.0), coarser), Affine::IDENTITY, "40,000 pixels a side"); +} + +/// Type whose offscreen drawing would hold more pixels than the effects' pixel budget (in a large +/// raster export) is drawn as Sharp draws it, without the offscreen drawing; just within the +/// budget it is drawn offscreen with its method. +#[test] +fn type_past_the_pixel_budget_is_drawn_as_sharp() { + // "Hamburg" at 10,000%, its letters across every pixel of the render. + let draw = |m: TypeAntiAlias, side: u32| { + let half = f64::from(side) / 2.0; + let view = Affine::translate((half - 9_000.0, half - 4_500.0)) * Affine::scale(100.0); + let mut r = Renderer::new(); + let pixels = r.render(&page(m, None), side, side, view, &RenderOptions::default()).pixels; + (r.stats.type_edges, pixels) + }; + // 3,000 × 3,000 pixels, past the 8.4 million of the budget. + let (drawn, strong) = draw(TypeAntiAlias::Strong, 3_000); + assert_eq!(drawn, 0, "the type is not drawn offscreen"); + assert!(strong == draw(TypeAntiAlias::Sharp, 3_000).1, "the type is drawn as Sharp draws it"); + // 2,800 × 2,800 pixels, within it. + assert_eq!(draw(TypeAntiAlias::Strong, 2_800).0, 1, "the type is drawn offscreen"); +} + +/// Type across a canvas of the largest side the renderer draws (`MAX_CONTEXT_SIDE` pixels) is +/// drawn with its method on every pixel. +#[test] +fn type_across_the_widest_canvas_is_drawn_with_its_method() { + // "Hamburg" at 25,000%, about 37,000 pixels wide, past both sides of the canvas, on four rows + // through its letters. + let view = Affine::translate((-3_000.0, -12_500.0)) * Affine::scale(250.0); + let draw = |m| { + let pixels = Renderer::new().render(&page(m, None), MAX_CONTEXT_SIDE, 4, view, &RenderOptions::default()).pixels; + pixels.chunks(4).map(|p| p[3]).collect::>() + }; + let sharp = draw(TypeAntiAlias::Sharp); + assert!(partial(&sharp) > 0, "the rows cross the edges of the letters"); + let none = draw(TypeAntiAlias::None); + assert_eq!(none.len(), sharp.len()); + assert_eq!(partial(&none), 0, "None paints whole pixels"); + let wrong = off_the_share(TypeAntiAlias::None, &sharp, &none); + assert!(wrong.is_empty(), "(pixel, Sharp, drawn) {wrong:?}"); +} + +/// The offscreen drawing takes in the farthest a stroke reaches: a mitered character stroke's +/// spikes up to its miter limit, transformed with the type (stretched along one axis too), and +/// the object's own strokes. +#[test] +fn the_offscreen_drawing_takes_in_the_strokes_reach() { + let style = CharStyle { + stroke: Paint::solid(Color::BLACK), + stroke_width: 4.0, + stroke_join: vectorcraft_doc::LineJoin::Miter, + stroke_miter_limit: 10.0, + ..Default::default() + }; + let mut t = TextObject::point(Point::new(0.0, 0.0), "V", style); + let n = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); + // How far the reach goes past the transformed glyph bounds on the left and on the top. + let past = |n: &Node, t: &TextObject| { + let g = text_geom(t); + let (r, b) = (type_reach(n, t, &g), t.xf.transform_rect_bbox(g.bounds)); + (b.x0 - r.x0, b.y0 - r.y0) + }; + let near = |(a, b): (f64, f64), (x, y): (f64, f64)| (a - x).abs() < 1e-6 && (b - y).abs() < 1e-6; + assert!(near(past(&n(&t), &t), (20.0, 20.0)), "half the weight times the miter limit: {:?}", past(&n(&t), &t)); + t.xf = Affine::scale_non_uniform(10.0, 0.1); + assert!(near(past(&n(&t), &t), (200.0, 2.0)), "stretched with the type: {:?}", past(&n(&t), &t)); + t.xf = Affine::scale(2.0); + let mut own = n(&t); + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 10.0); + st.miter_limit = 10.0; + own.appearance.items.push(AppearanceItem::Stroke(st)); + assert!(near(past(&own, &t), (90.0, 90.0)), "the object's own stroke after the characters': {:?}", past(&own, &t)); +} + +/// Crisp type with a character stroke expands the stroke once for both of its offscreen drawings, +/// and keeps the outline as Sharp type does. Its pixels are those of a renderer that keeps no +/// glyph geometry, which expands the stroke in every drawing. +#[test] +fn crisp_type_keeps_its_character_stroke_outline() { + let _fonts = crate::tests_fontchange::fonts_steady(); + let mut d = page(TypeAntiAlias::Crisp, None); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) { + t.runs[0].style.stroke = Paint::solid(Color::BLACK); + t.runs[0].style.stroke_width = 1.0; + } + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + let pixels = r.render(&d, W, H, Affine::IDENTITY, &opts).pixels; + assert_eq!(r.stats.type_edges, 1, "the type is drawn offscreen"); + assert_eq!(r.char_strokes.len(), 1, "one outline for the one run's stroke, used by both drawings"); + let mut uncached = Renderer::new(); + uncached.texts = FrameCache::new(0, 0); + assert!(uncached.render(&d, W, H, Affine::IDENTITY, &opts).pixels == pixels, "the kept outline draws the same pixels"); + assert_eq!(uncached.char_strokes.len(), 0); +} + +/// Area type draws its punctuation outside its frame with Optical Margin Alignment, and None type +/// draws it there too. +#[test] +fn type_outside_its_frame_is_drawn() { + let page = |m: TypeAntiAlias| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(0.0, 0.0), "“Quoted.”", CharStyle { size: 30.0, ..Default::default() }); + t.kind = TextKind::Area { frame: rectangle(Rect::new(40.0, 10.0, 240.0, 80.0)) }; + t.optical_margin = true; + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &RenderOptions::default()) + }; + let outside = |a: &[u8]| a.iter().enumerate().filter(|(i, v)| (*i as u32 % W) < 39 && **v > 0).count(); + assert!(outside(&page(TypeAntiAlias::Sharp)) > 20, "the opening quotation mark hangs outside the frame"); + assert!(outside(&page(TypeAntiAlias::None)) > 20, "None type draws it there too"); +} + +/// A frame draws Crisp type from the pixels an earlier frame kept when the view only moved by +/// whole pixels, and they are the pixels a new renderer draws. Another zoom, another method, a pan +/// that brings more of the type into view, a pan by part of a pixel and other edge smoothing draw +/// the type again. +#[test] +fn a_pan_draws_the_type_from_the_kept_pixels() { + let _fonts = crate::tests_fontchange::fonts_steady(); + let opts = RenderOptions::default(); + let fonts = || vectorcraft_text::FontDb::global().generation(); + // How many type objects a renderer drew offscreen for `b` in `second` after drawing `a` in + // `first`. The second frame's pixels are a new renderer's, each channel within `near`. The + // frames are drawn again while the fonts change between them (other tests load fonts, and the + // type is then drawn again). + let drawn = |(a, first): (&Document, Affine), (b, second): (&Document, Affine), near: u8| { + for _ in 0..5 { + let before = fonts(); + let mut r = Renderer::new(); + r.render(a, W, H, first, &opts); + assert_eq!(r.stats.type_edges, 1, "{first:?}"); + let pixels = r.render(b, W, H, second, &opts).pixels; + if fonts() != before { + continue; + } + let want = Renderer::new().render(b, W, H, second, &opts).pixels; + let differ: Vec<_> = pixels + .chunks(4) + .zip(want.chunks(4)) + .enumerate() + .filter(|(_, (p, q))| p.iter().zip(*q).any(|(p, q)| p.abs_diff(*q) > near)) + .map(|(i, (p, q))| (i as u32 % W, i as u32 / W, p[3], q[3])) + .collect(); + assert!( + differ.is_empty(), + "{second:?}: {} pixels differ from a new renderer's, first (x, y, alpha, new alpha) {:?}", + differ.len(), + &differ[..differ.len().min(8)] + ); + return r.stats.type_edges; + } + panic!("the fonts changed in every try"); + }; + let (crisp, strong) = (page(TypeAntiAlias::Crisp, None), page(TypeAntiAlias::Strong, None)); + let at = |d, (x, y): (f64, f64)| (d, Affine::translate((x, y))); + for pan in [(7.0, -3.0), (2.0, 5.0), (-4.0, -1.0)] { + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&crisp, pan), 0), 0, "a pan by {pan:?}"); + } + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), (&crisp, Affine::scale(1.5)), 0), 1, "another zoom"); + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&strong, (0.0, 0.0)), 0), 1, "another method"); + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&crisp, (0.5, 0.0)), 0), 1, "a pan by half a pixel"); + // At 400% the type fills the view, so the pixels kept hold every pixel of the next frame. + let zoomed = Affine::translate((-200.0, -150.0)) * Affine::scale(4.0); + let nudged = Affine::translate((0.3, 0.0)) * zoomed; + assert_eq!(drawn((&crisp, zoomed), (&crisp, nudged), 0), 1, "a pan by part of a pixel at 400%"); + assert_eq!(drawn(at(&strong, (-120.0, 0.0)), at(&strong, (0.0, 0.0)), 0), 1, "a pan that brings the rest into view"); + // Type the view cuts is drawn offscreen from the view's edge, and its coverage there can round + // a step the other way: Strong doubles the step, and the color takes a step of rounding. + assert_eq!(drawn(at(&strong, (0.0, 0.0)), at(&strong, (-120.0, 0.0)), 3), 0, "a pan that takes half of it out of view"); + // With hard edges (Anti-aliased Artwork off) and with type snapped to pixels (an export's Type + // Optimized), the frame draws the type again. + for smoothing in [AntiAlias::None, AntiAlias::Type] { + let other = RenderOptions { anti_alias: smoothing, ..Default::default() }; + let mut r = Renderer::new(); + r.render(&crisp, W, H, Affine::IDENTITY, &opts); + let pixels = r.render(&crisp, W, H, Affine::IDENTITY, &other).pixels; + assert_eq!(r.stats.type_edges, 1, "{smoothing:?}"); + assert!(pixels == Renderer::new().render(&crisp, W, H, Affine::IDENTITY, &other).pixels, "{smoothing:?}"); + } +} diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index c9d9027b8..19bbbd5be 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -1,4 +1,4 @@ -use super::output::{xml_attr_valid, xml_text}; +use super::output::{xml_attr_escape, xml_attr_valid, xml_text}; use super::{ Affine, AppearanceItem, Arc, BTreeSet, BezPath, BlendMode, CharStyle, FillRule, FontDb, FontFace, GradientKind, HashMap, Justify, Node, NodeId, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, Styling, TextKind, TextObject, Writer, base64_encode, css, css_string, fmt_num, @@ -166,10 +166,19 @@ impl Writer<'_> { } let id = self.id_attr(n); let a = self.attrs(&css::transparency(n)); - // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group - // names its text for screen readers and search. - let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { - format!(" role=\"img\" aria-label=\"{}\"", xml_attr_valid(&t.plain_text())) + // Text outlined because SVG text can't draw it (bent glyphs, vertical type, ruby, effects on + // a path and the like) names its text for screen readers and search; Convert to Outlines + // leaves only the paths, except for bent glyphs. Inline graphics are written apart from the + // glyphs, so their placeholder characters stay out of the name; empty text has none. + let text: String = t.plain_text().chars().filter(|&c| c != vectorcraft_doc::text::INLINE_CHAR).collect(); + let label = if !text.trim().is_empty() && (!self.opts.outline_text || lay.glyphs.iter().any(|g| g.bend.is_some())) { + // SVG import names an object by its `aria-label` before its id, so an id that stands for + // the name is written as `data-name` too, and the object keeps its name. + let name = match self.names.get(&n.id) { + Some(named) if !id.is_empty() && !id.contains(" data-name=\"") => format!(" data-name=\"{}\"", xml_attr_escape(named)), + _ => String::new(), + }; + format!("{name} role=\"img\" aria-label=\"{}\"", xml_attr_valid(&text)) } else { String::new() }; diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index 925c17176..1d10c5d9f 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -657,6 +657,62 @@ fn an_exported_filter_image_has_an_xlink_reference() { } } +/// SVG export writes a filter only in a form SVG import reads back. A filter at the size limit, +/// whose id or region written out would make it longer, is left out of the SVG with the other pixel +/// effects (exports that turn pixel effects into images draw it as an image), and the SVG opens +/// without a filter that import can't read. +#[test] +fn an_exported_filter_is_one_import_reads_back() { + let max = vectorcraft_doc::SvgFilter::MAX_XML; + let close = r#""/>"#; + let short_id = r##">(), ["svg.filter"]); +} + +/// With the Latin-1 encoding, export writes each character past U+00FF as a character reference, +/// which import counts as text. A filter is measured with those references in either encoding: one +/// that passes the limit with them is left out of both, and one that fits with them is written live +/// in Latin-1 and read back. +#[test] +fn an_exported_filter_counts_the_references_latin1_writes() { + let open = r##""#; + let max = vectorcraft_doc::SvgFilter::MAX_XML; + let latin1 = ExportOptions { encoding: vectorcraft_svg::Encoding::Latin1, ..ExportOptions::default() }; + // `国` takes three bytes as UTF-8 and eight as `国`. + let xml = format!("{open}{}{close}", "\u{56FD}".repeat((max / 2 - open.len() - close.len()) / 3)); + let d = filtered_rect(&xml); + assert_eq!(effects(&d).len(), 1); + assert!(!export(&d, &ExportOptions::default()).contains(">(), ["svg.filter"]); +} + /// SVG 1.1 has no `feDropShadow`: export writes the SVG 1.1 primitives it stands for, which draw /// as the canvas draws the shadow and import back as a filter that draws the same. #[test] diff --git a/crates/svg/tests/it/roundtrip.rs b/crates/svg/tests/it/roundtrip.rs index 8f479f348..b13ecf160 100644 --- a/crates/svg/tests/it/roundtrip.rs +++ b/crates/svg/tests/it/roundtrip.rs @@ -527,6 +527,66 @@ fn text_on_path_effects_svg_cannot_express_are_outlined() { } } +/// Type that SVG text can't draw is outlined with its text as the group's accessible name: type on +/// a path with an effect other than Rainbow, vertical type, right-to-left text, and type on a path +/// with inline graphics. With Convert to Outlines the paths carry no name, and empty type gets none +/// either. +#[test] +fn text_outlined_for_svg_keeps_an_accessible_name() { + use vectorcraft_doc::PathEffect; + let mut curve = vectorcraft_geom::BezPath::new(); + curve.move_to((20.0, 150.0)); + curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0)); + let style = || CharStyle { size: 14.0, ..CharStyle::default() }; + let svg_of = |t: TextObject, outline_text: bool| { + let mut d = Document::new(200.0, 200.0); + let id = d.alloc_id(); + let opts = ExportOptions { outline_text, ..ExportOptions::default() }; + export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &opts) + }; + let mut skewed = TextObject::point(Point::ZERO, "On a curve", style()); + skewed.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: None }; + skewed.path_effect = PathEffect::Skew; + let mut vertical = TextObject::point(Point::new(50.0, 20.0), "On a curve", style()); + vertical.vertical = true; + let mut rtl = TextObject::point(Point::new(20.0, 50.0), "On a curve", style()); + rtl.runs[0].style.char_direction = Some(vectorcraft_doc::ParaDirection::RightToLeft); + for (name, t) in [("skew", skewed), ("vertical", vertical), ("rtl", rtl)] { + let svg = svg_of(t.clone(), false); + assert!(!svg.contains("`. diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index eee6e2fbf..adcc101f2 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -435,6 +435,17 @@ fn numbers_are_written_as_their_characters_where_they_are_drawn() { assert!(at(">I.\t<") < at("alpha") && at("alpha") < at(">II.\t<") && at(">II.\t<") < at("zeta") && at("zeta") < at(">III.\t<"), "{svg}"); assert!(!svg.contains(">-<"), "{svg}"); assert_drawn_in_place(&d); + // Aligned right: the gap comes before each number, which a viewer draws where the canvas does, + // against the text. + let mut t = TextObject::point(Point::new(20.0, 20.0), "alpha beta gamma delta epsilon\nzeta\neta", style(12.0)); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 160.0)) }; + t.para.list = Some(ParaList::numbered(NumberStyle::UpperRoman).aligned(vectorcraft_doc::text::ListAlign::Right)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let d = doc(t); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.contains(">III.<") && !svg.contains(">III.\t<") && !svg.contains(">-<"), "{svg}"); + assert_drawn_in_place(&d); let mut line = BezPath::new(); line.move_to((0.0, 50.0)); line.line_to((250.0, 50.0)); diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index faee695ca..0e1d9afa8 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -31,6 +31,13 @@ impl FontDb { { let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); if map.get(&key).is_some_and(|f| **f == *font) { + // The same definition a guard lends: it stays when the guard drops. + let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(loan) = loans.get_mut(&key) + && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) + { + loan.adopted = true; + } return false; } map.insert(key, Arc::new(font.clone())); @@ -194,7 +201,7 @@ impl FontDb { } 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 }); + loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1, adopted: false }); lent.push((key, font)); changed = true; } @@ -300,6 +307,9 @@ pub(super) struct CompositeLoan { previous: Option>, /// How many guards lend it. guards: usize, + /// Whether [`FontDb::set_composite_font`] set the same definition while it was lent: the last + /// guard to drop then leaves it in place. + adopted: bool, } /// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops. @@ -327,9 +337,11 @@ impl Drop for LentCompositeFonts<'_> { continue; } let previous = loan.previous.take(); + let adopted = loan.adopted; 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)) { + // Given back unless a font of that name has been set (the same definition included) + // or removed since. + if !adopted && 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), diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 053cd2981..7a468f51e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; -use vectorcraft_doc::text::Kashida; +use vectorcraft_doc::text::{Kashida, ListAlign}; use vectorcraft_doc::{ Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, @@ -243,12 +243,16 @@ impl Ctx<'_> { /// before its glyphs `sg` and return how many glyphs went in (none for a paragraph without a /// marker). The marker's characters are set in /// [`vectorcraft_doc::text::ParaList::marker_style`], and a tab after them takes the text to - /// the next tab stop past them on the first line, the left indent counting as a stop when no - /// explicit stop comes before it. In a horizontal right-to-left paragraph, whose indents are - /// mirrored ([`line_indents`]), these are measured from the right, and a number reads left to - /// right with its period at its left. The tab keeps that width however the line is aligned or - /// justified. Each of these glyphs stands for no character of the text: length 0, at the - /// paragraph's first byte, in its first character's run, at the paragraph's embedding level. + /// the next tab stop past them on the first line, the left indent plus its list level's indent + /// ([`ParaStyle::laid_out_left_indent`]) counting as a stop when no explicit stop comes before + /// it. With the list's [`ListAlign::Right`] that tab goes before them instead, with the same + /// width: the marker ends where the text starts, and the text starts where it does with + /// [`ListAlign::Left`]. In a horizontal right-to-left paragraph, + /// whose indents are mirrored ([`line_indents`]), these are measured from the right, and a + /// number reads left to right with its period at its left. The tab keeps that width however + /// the line is aligned or justified. Each of these glyphs stands for no character of the + /// text: length 0, at the paragraph's first byte, in its first character's run, at the + /// paragraph's embedding level. fn prepend_marker(&self, pr: &Range, para: &ParaStyle, text: Option<&str>, rtl: bool, sg: &mut Vec) -> usize { let Some(text) = text.filter(|_| para.list.is_some() && !pr.is_empty()) else { return 0 }; let st = vectorcraft_doc::text::ParaList::marker_style(self.style_at(pr.start)); @@ -281,11 +285,16 @@ impl Ctx<'_> { tab.kern_shift = 0.0; tab.tcy = None; tab.vpal = None; - let pen = para.left_indent + para.first_line_indent + marker.iter().map(|g| g.adv).sum::(); - let to_indent = para.left_indent - pen; - let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < para.left_indent - EPS); + let left = para.laid_out_left_indent(); + let pen = left + para.first_line_indent + marker.iter().map(|g| g.adv).sum::(); + let to_indent = left - pen; + let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < left - EPS); tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg).0 }; - marker.push(tab); + if para.list.is_some_and(|l| l.align == ListAlign::Right) { + marker.insert(0, tab); + } else { + marker.push(tab); + } let n = marker.len(); sg.splice(0..0, marker); n diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index fd663c9a9..a2534c420 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -192,7 +192,7 @@ pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, /// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the /// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their -/// glyph. The tab after a list marker (length 0) keeps its own width. +/// glyph. The tab of a list marker (length 0) keeps its own width. pub(super) fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { let mut x = x_start; for (j, g) in sg.iter().enumerate().take(to).skip(from) { @@ -313,7 +313,8 @@ pub(super) fn break_line( } } } - let cluster_width: f64 = if gl.ch == '\t' { + // A list marker's glyphs, its tab included, keep their own widths. + let cluster_width: f64 = if gl.ch == '\t' && gl.len > 0 { tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0 } else { g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() @@ -348,14 +349,14 @@ pub(super) fn break_line( if j >= g.len() { return (g.len(), false); } - // Hyphenate the word that overflows. - let word_start = last_break.map_or(i, |b| b.0); + // Hyphenate the word that overflows. A list marker starting the line is not part of a word. + let word_start = last_break.map_or(first, |b| b.0); let in_zone = |zone: f64| { let mut te = word_start; - while te > i && g[te - 1].is_space() { + while te > first && g[te - 1].is_space() { te -= 1; } - te > i && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::() <= zone + EPS + te > first && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::() <= zone + EPS }; if let Some((opts, zone)) = hyphenate && g[j].is_letter() @@ -555,7 +556,9 @@ pub(super) fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&Hyphenatio let mut v = vec![]; let mut ws = 0; for (j, gl) in g.iter().enumerate() { - let end_of_word = gl.is_space() || gl.break_after(); + // A list marker (the glyphs of length 0 here) ends before the first word. + let marker_end = gl.len == 0 && g.get(j + 1).is_some_and(|n| n.len > 0); + let end_of_word = gl.is_space() || gl.break_after() || marker_end; if end_of_word { if let Some(opts) = hyphenate && j > ws diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 79cc79ba6..698596215 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -34,7 +34,7 @@ fn compose_para( let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). - // The tab after a list marker always starts the first line and has a fixed width. + // The tab of a list marker is on the first line, with the marker, and has a fixed width. let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); let has_soft_break = sg.iter().any(|g| g.ch == '\u{2028}'); if composer != Composer::EveryLine @@ -66,7 +66,7 @@ fn compose_para( // indents of a right-to-left list ([`line_indents`]) keep. while widths.len() < sg.len() { let first = widths.is_empty(); - let ind_l = para.left_indent + if first && !continued { para.first_line_indent } else { 0.0 }; + let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 }; // Only the first line's first word is known here. Later lines are placed as the composed // lines need: each fits its width, so its first word does too. let word = if first { word_width(sg, 0) } else { 0.0 }; @@ -104,11 +104,11 @@ fn compose_para( } /// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers -/// counted through consecutive list paragraphs from [`TextObject::list_count`], as +/// counted per level through consecutive list paragraphs from [`TextObject::list_counts`], as /// [`vectorcraft_doc::text::ParaList::next_marker`] counts them. pub(super) fn list_markers(t: &TextObject, paras: &[Range]) -> Vec> { - let mut count = t.list_count; - paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut count)).collect() + let mut counts = t.list_counts(); + paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut counts)).collect() } /// Whether paragraph `para` (right to left when `rtl`) has its indents mirrored: a horizontal @@ -120,9 +120,10 @@ fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool { /// The indents at the left and at the right of a line of paragraph `para` (its first line when /// `first`). With `mirror` ([`mirrors_indents`]) the left and first-line indents are measured from /// the right and the right indent from the left: the bullet of a right-to-left list hangs at the -/// right, and the lines it wraps to line up with the text after the bullet. +/// right, and the lines it wraps to line up with the text after the bullet. A list level past the +/// first adds to the left indent ([`ParaStyle::laid_out_left_indent`]), mirrored with it. fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { - let start = para.left_indent + if first { para.first_line_indent } else { 0.0 }; + let start = para.laid_out_left_indent() + if first { para.first_line_indent } else { 0.0 }; if mirror { (para.right_indent, start) } else { (start, para.right_indent) } } @@ -723,7 +724,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData let mut scale = 1.0; let mut leader = ""; if glyph.ch == '\t' { - // The tab after a list marker keeps the width `prepend_marker` gave it. + // The tab of a list marker keeps the width `prepend_marker` gave it. if glyph.len > 0 { (advance, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]); } @@ -764,7 +765,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData if glyph_index < paragraph.marker { let at = cx.out.glyphs.len(); marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |range: Range| range.start.min(at)..range.end.max(at + 1))); - if glyph_index + 1 == paragraph.marker { + if glyph.ch == '\t' { marker_tab = at; } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 2dc80a3e9..dad7f0a6f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -331,7 +331,8 @@ pub struct ListMarker { /// takes the text to the next tab stop. pub glyphs: std::ops::Range, /// The tab's glyph, which draws nothing: the last of [`Self::glyphs`], or the first in a - /// right-to-left paragraph. + /// right-to-left paragraph. A marker aligned right + /// ([`vectorcraft_doc::text::ListAlign::Right`]) has it the other way round. pub tab: usize, /// The marker's characters ("•"). pub text: String, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ef171316e..1338e08db 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1355,6 +1355,157 @@ fn a_right_to_left_numbered_paragraph_has_its_period_left_of_its_number() { assert!((last.origin.x + last.advance - 200.0).abs() < 1e-6, "the number ends the line at the right: {last:?}"); } +/// Bullets and Numbering › Alignment: Left puts a list paragraph's marker at the first line's +/// start, and Right puts it against the text, which starts at the same place with either. Numbers +/// of different widths aligned right end at the same place, the caret before the first character +/// stands at the text, and the lines the paragraph wraps to start at the left indent. A number +/// wider than the room before the left indent takes the text to the next tab stop and ends there. +/// In a right-to-left paragraph, a marker aligned right starts where the text ends. +#[test] +fn a_list_marker_aligned_right_ends_where_the_text_starts() { + use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList}; + // Each marker's characters' left and right edges, and where its line's text starts. + let spans = |t: &TextObject| -> Vec<(f64, f64, f64)> { + let lay = layout(db(), t); + let mut out = vec![]; + for m in &lay.markers { + let chars: Vec = m.glyphs.clone().filter(|&i| i != m.tab).collect(); + let left = chars.iter().map(|&i| lay.glyphs[i].origin.x).fold(f64::MAX, f64::min); + let right = chars.iter().map(|&i| lay.glyphs[i].origin.x + lay.glyphs[i].advance).fold(f64::MIN, f64::max); + let line = &lay.lines[lay.glyphs[m.tab].line]; + let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + out.push((left, right, text.origin.x)); + } + out + }; + let numbered = |align: ListAlign, count: u32| { + let mut t = listed("One\nTwo", &[Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); 2]); + t.list_count = count; + t + }; + // "9." and "10.": aligned left, both start at the first-line indent (0); aligned right, both end + // at the left indent (18), where the text starts either way. + let left = spans(&numbered(ListAlign::Left, 8)); + let right = spans(&numbered(ListAlign::Right, 8)); + assert_eq!((left.len(), right.len()), (2, 2)); + for (k, ((l0, r0, text0), (l, r, text))) in left.iter().zip(&right).enumerate() { + assert!(l0.abs() < 1e-6 && (text0 - 18.0).abs() < 1e-6, "item {k} aligned left: {l0} {text0}"); + assert!((r - 18.0).abs() < 1e-6 && (text - text0).abs() < 1e-6, "item {k} aligned right ends at the text: {r} {text}"); + assert!(((r - l) - (r0 - l0)).abs() < 1e-6, "item {k} keeps its width"); + } + assert!(right[1].0 < right[0].0, "the wider number starts further left: {right:?}"); + let t = numbered(ListAlign::Right, 0); + let lay = layout(db(), &t); + let m = &lay.markers[0]; + assert_eq!((m.tab, lay.marker_text(m.tab), lay.marker_text(m.glyphs.start + 1)), (m.glyphs.start, Some("\t"), Some("1."))); + let (top, _) = caret_position(&lay, 0); + assert!((top.x - 18.0).abs() < 1e-6, "the caret before the first character: {top:?}"); + // A wrapped paragraph's other lines start at the left indent. + let long = listed(LOREM, &[Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))]); + let lay = layout(db(), &long); + assert!(lay.lines.len() > 2, "the paragraph wraps"); + for (i, line) in lay.lines.iter().enumerate() { + let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + assert!((first.origin.x - 18.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x); + } + let bullet = &spans(&long)[0]; + assert!((bullet.1 - 18.0).abs() < 1e-6 && bullet.0 > 0.0, "{bullet:?}"); + // "1000." is wider than the 18 pt before the left indent: the text and the number's end go to + // the next default stop. + let mut wide = listed("One", &[Some(ParaList::numbered(NumberStyle::ThreeDigits).aligned(ListAlign::Right))]); + wide.list_count = 999; + let (_, end, text) = spans(&wide)[0]; + assert!((text - 36.0).abs() < 1e-6 && (end - 36.0).abs() < 1e-6, "{end} {text}"); + // Right to left: aligned left the bullet ends the line at the right; aligned right it starts + // where the text ends, and the text ends where it does aligned left. + let rtl = |align: ListAlign| { + let mut t = listed("שלום עולם", &[Some(ParaList::bulleted(Bullet::Dot).aligned(align))]); + t.edit_paras(None, |p| p.justify = Justify::Auto); + let lay = layout(db(), &t); + let m = &lay.markers[0]; + let bullet = m.glyphs.clone().find(|&i| i != m.tab).map(|i| (lay.glyphs[i].origin.x, lay.glyphs[i].origin.x + lay.glyphs[i].advance)); + let text_right = lay.glyphs.iter().filter(|g| g.len > 0).map(|g| g.origin.x + g.advance).fold(0.0, f64::max); + (bullet.unwrap(), text_right) + }; + let ((_, end), text_left_aligned) = rtl(ListAlign::Left); + assert!((end - 200.0).abs() < 1e-6, "{end}"); + let ((start, _), text_right_aligned) = rtl(ListAlign::Right); + assert!((start - text_right_aligned).abs() < 1e-6 && (text_right_aligned - text_left_aligned).abs() < 1e-6, "{start} {text_right_aligned}"); +} + +/// A list paragraph breaks its lines where the same paragraph without a marker, its text at the +/// same indent, breaks them: with either marker alignment, a bullet or a letter, and either +/// composer, the marker and its gap count at their own width and the first word hyphenates. +#[test] +fn a_list_paragraph_breaks_its_lines_as_its_text_alone_does() { + use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList}; + let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything."; + // Each line's end, and whether it ends with a hyphen. + let breaks = |list: Option, composer: vectorcraft_doc::Composer| { + let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 98.0, 600.0), Justify::Left); + t.para.left_indent = 18.0; + t.para.first_line_indent = if list.is_some() { -18.0 } else { 0.0 }; + t.para.hyphenate = true; + t.para.composer = composer; + t.para.list = list; + let lay = layout(db(), &t); + let hyphen = |k: usize| lay.glyphs[k].len == 0 && lay.marker_of(k).is_none(); + lay.lines.iter().map(|l| (l.end, (l.glyph_start..l.glyph_end).any(hyphen))).collect::>() + }; + for composer in [vectorcraft_doc::Composer::SingleLine, vectorcraft_doc::Composer::EveryLine] { + let alone = breaks(None, composer); + assert!(alone.first().is_some_and(|&(_, hyphen)| hyphen), "the first word hyphenates: {alone:?}"); + for list in [ParaList::bulleted(Bullet::Dot), ParaList::numbered(NumberStyle::UpperLetters)] { + for align in [ListAlign::Left, ListAlign::Right] { + assert_eq!(breaks(Some(list.aligned(align)), composer), alone, "{composer:?} {list:?} {align:?}"); + } + } + } +} + +/// List levels: a paragraph at a level past the first is set 18 pt further in for each level, its +/// marker, its text and the lines it wraps to; each level counts its own numbers, a deeper level +/// starting at 1 and a shallower one going on with its count, from the counts the earlier frames of +/// a thread carry. A right-to-left list paragraph is set that much further in from the right. +#[test] +fn a_list_paragraph_at_a_deeper_level_is_set_further_in_and_counts_on_its_own() { + use vectorcraft_doc::text::{Bullet, ListCounts, NumberStyle, ParaList}; + let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)); + let t = listed("One\nTwo\nThree\nFour\nFive\nSix\nSeven", &[num(1), num(2), num(2), num(3), num(2), num(1), num(2)]); + assert_eq!(marker_texts(&t), ["1.", "1.", "2.", "1.", "3.", "2.", "1."]); + let lay = layout(db(), &t); + for (k, (m, level)) in lay.markers.iter().zip([1, 2, 2, 3, 2, 1, 2]).enumerate() { + let number = &lay.glyphs[m.glyphs.start]; + let line = &lay.lines[number.line]; + let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + let step = 18.0 * f64::from(level - 1); + assert!((number.origin.x - step).abs() < 1e-6, "item {k}: the number starts at {}", number.origin.x); + assert!((text.origin.x - 18.0 - step).abs() < 1e-6, "item {k}: the text starts at {}", text.origin.x); + } + // The counts the earlier frames carry, per level. + let mut t = listed("Two\nThree\nFour", &[num(2), num(3), num(1)]); + t.set_list_counts(ListCounts([4, 6, 0, 0, 0, 0, 0, 0, 0])); + assert_eq!(marker_texts(&t), ["7.", "1.", "5."]); + // The lines a deeper paragraph wraps to start at its left indent plus the level's 18 pt. + let long = listed(LOREM, &[Some(ParaList::bulleted(Bullet::Dot).at_level(3))]); + let lay = layout(db(), &long); + assert!(lay.lines.len() > 2, "the paragraph wraps"); + assert!((lay.glyphs[lay.markers[0].glyphs.start].origin.x - 36.0).abs() < 1e-6); + for (i, line) in lay.lines.iter().enumerate() { + let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + assert!((first.origin.x - 54.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x); + } + // Right to left: the bullet at level 2 ends 18 pt before the right edge. + let rtl = |level| { + let mut t = listed("שלום עולם", &[Some(ParaList::bulleted(Bullet::Dot).at_level(level))]); + t.edit_paras(None, |p| p.justify = Justify::Auto); + let lay = layout(db(), &t); + let m = &lay.markers[0]; + m.glyphs.clone().find(|&i| i != m.tab).map(|i| lay.glyphs[i].origin.x + lay.glyphs[i].advance).unwrap() + }; + assert!((rtl(1) - 200.0).abs() < 1e-6 && (rtl(2) - 182.0).abs() < 1e-6, "{} {}", rtl(1), rtl(2)); +} + /// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when /// they aren't: the bundled fonts have none, and [`db`] reads no system fonts. fn japanese_fonts() -> bool { diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 370fa4557..729afa0d5 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -129,6 +129,13 @@ fn lent_composite_fonts_last_as_long_as_a_guard() { db.set_composite_font(&replaced); drop(lent); assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0); + // So does the same definition set while lent, after every guard lending it drops. + assert!(db.remove_composite_font("Lent")); + let (one, two) = (db.lend_composite_fonts(&fonts[..1]), db.lend_composite_fonts(&fonts[..1])); + assert!(!db.set_composite_font(&fonts[0])); + drop(one); + drop(two); + assert!(db.composite_font("Lent").is_some()); // 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]); diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index 191963107..cfdd41cc7 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -4,6 +4,7 @@ //! testing need nothing special; after an edit the engine joins the slices and re-distributes them //! with [`distribute`]. +use vectorcraft_doc::text::ListCounts; use vectorcraft_doc::{ParaStyle, TextObject, TextRun}; use crate::edit::{normalize, runs_len, slice_runs, style_at}; @@ -82,29 +83,38 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize { } /// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the -/// numbered list paragraphs counted before its first one ([`TextObject::list_count`]). +/// numbered list paragraphs counted before its first one, at level 1 and at the levels past it +/// ([`TextObject::list_count`], [`TextObject::list_level_counts`]). #[derive(Clone, Debug, PartialEq)] pub struct Slice { pub runs: Vec, pub paras: Vec, pub list_count: u32, + pub list_level_counts: Vec, /// The first paragraph continues from the frame before /// ([`TextObject::first_para_continues`]). pub continues: bool, } -/// The count of numbered list paragraphs after the paragraphs of `runs` (styles `paras`, one per -/// paragraph) but the last one, counted on from `count` as the layout counts them +impl Slice { + /// The list counts the slice's frame starts from ([`TextObject::list_counts`]). + pub fn list_counts(&self) -> ListCounts { + ListCounts::from_parts(self.list_count, &self.list_level_counts) + } +} + +/// The counts of numbered list paragraphs at each level after the paragraphs of `runs` (styles +/// `paras`, one per paragraph) but the last one, counted on from `counts` as the layout counts them /// ([`vectorcraft_doc::text::ParaList::next_marker`]). A slice's last paragraph also starts the /// next frame, which counts it. -fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut count: u32) -> u32 { +fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut counts: ListCounts) -> ListCounts { let text: String = runs.iter().map(|r| r.text.as_str()).collect(); let mut pieces: Vec<&str> = text.split('\n').collect(); pieces.pop(); for (piece, para) in pieces.iter().zip(paras) { - vectorcraft_doc::text::ParaList::next_marker(para.list, piece.is_empty(), &mut count); + vectorcraft_doc::text::ParaList::next_marker(para.list, piece.is_empty(), &mut counts); } - count + counts } /// Whether the frame after one whose share ends with byte `before` starts inside a paragraph: it @@ -134,21 +144,36 @@ pub fn continued(frames: &[&TextObject]) -> Vec { /// keep one empty run carrying the style at the split, so typing there has a style. A paragraph /// split between frames has its style in both, and each slice that starts inside a paragraph is /// marked as continuing it ([`Slice::continues`]). Each frame's numbered list paragraphs are counted -/// on from the frames before it. Each frame's share is measured with that mark and that count. +/// on from the frames before it, at each level. Each frame's share is measured with that mark and +/// those counts. pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec { - distribute_continuing(db, frames, runs, paras, false, 0) + distribute_continuing_levels(db, frames, runs, paras, false, ListCounts::default()) } /// [`distribute`] for `runs` that follow text before them: the first frame's share is measured, /// and marked, as continuing a paragraph when `continued` is set, and its numbered list paragraphs -/// are counted on from `list_count`. +/// at level 1 are counted on from `list_count` ([`distribute_continuing_levels`] takes the counts +/// of every level). pub fn distribute_continuing( db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle], continued: bool, - mut list_count: u32, + list_count: u32, +) -> Vec { + distribute_continuing_levels(db, frames, runs, paras, continued, ListCounts::from_parts(list_count, &[])) +} + +/// [`distribute_continuing`] with the first frame's numbered list paragraphs counted on from +/// `counts` at each level. +pub fn distribute_continuing_levels( + db: &FontDb, + frames: &[&TextObject], + runs: &[TextRun], + paras: &[ParaStyle], + continued: bool, + mut counts: ListCounts, ) -> Vec { let mut rest = runs.to_vec(); let mut out = Vec::with_capacity(frames.len()); @@ -165,7 +190,7 @@ pub fn distribute_continuing( let mut probe = (*f).clone(); probe.runs = rest.clone(); probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); - probe.list_count = list_count; + probe.set_list_counts(counts); probe.first_para_continues = continues; fit(db, &probe, &rest) }; @@ -177,11 +202,12 @@ pub fn distribute_continuing( rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail }; let head_paras = slice_paras(paras, first_para, &head); next_para += head_paras.len().saturating_sub(1); - let next_count = count_list_paragraphs(&head, &head_paras, list_count); + let next_counts = count_list_paragraphs(&head, &head_paras, counts); let starts_continued = continues; continues = continues_after(head.iter().rev().find_map(|r| r.text.bytes().last()), continues); - out.push(Slice { runs: head, paras: head_paras, list_count, continues: starts_continued }); - list_count = next_count; + let (list_count, list_level_counts) = counts.parts(); + out.push(Slice { runs: head, paras: head_paras, list_count, list_level_counts, continues: starts_continued }); + counts = next_counts; } out } diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index 222df20c2..c3f05cef4 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -378,6 +378,40 @@ fn a_list_paragraph_continued_onto_a_path_has_no_bullet_there() { assert_eq!(layout(db, &alone).markers.len(), 1); } +/// List levels: each level's count runs on through the frames, a deeper level in the next +/// frame going on with the count its paragraphs reached in the frame before. +#[test] +fn list_levels_count_on_through_the_frames() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let db = FontDb::global(); + let (a, mut b) = (frame(0.0), frame(200.0)); + b.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(200.0, 0.0, 320.0, 400.0)) }; + let at = |level| ParaStyle { + list: Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)), + left_indent: 18.0, + first_line_indent: -18.0, + ..Default::default() + }; + let numbers = |f: &TextObject, s: &Slice| { + let mut t = f.clone(); + t.runs = s.runs.clone(); + t.set_paragraph_styles(s.paras.clone()); + t.set_list_counts(s.list_counts()); + layout(db, &t).markers.iter().map(|m| m.text.clone()).collect::>() + }; + // One item at level 1 and eleven at level 2: 1. then 1. to 11. through both frames. + let paras: Vec = (0..12).map(|i| at(if i == 0 { 1 } else { 2 })).collect(); + let text = (1..=12).map(|i| format!("Item {i}")).collect::>().join("\n"); + let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b], &story, ¶s); + let first = numbers(&a, &parts[0]); + assert!((2..12).contains(&first.len()), "both frames hold items: {first:?}"); + let both: Vec = [first.clone(), numbers(&b, &parts[1])].concat(); + let expected: Vec = std::iter::once("1.".to_string()).chain((1..=11).map(|n| format!("{n}."))).collect(); + assert_eq!(both, expected); + assert_eq!((parts[1].list_count, parts[1].list_level_counts.clone()), (1, vec![first.len() as u32 - 1])); +} + #[test] fn short_story_leaves_later_frames_empty_with_a_style() { let db = FontDb::global(); diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index 45b559f77..2f2f69844 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -100,14 +100,15 @@ fn up(v: V3) -> [f64; 3] { [v.0, -v.1, v.2] } -/// `faces` (view space) as one mesh, each cut into triangles turned to its normal (a face given no -/// normal takes its corners' own). +/// `faces` (view space) as one mesh, each cut into triangles turned to its normal, which is written +/// at unit length whatever length it is given (a face given no normal takes its corners' own). fn mesh(faces: &[Face3]) -> Result { let mut out = Mesh::default(); let mut work = 0usize; for (contours, normal) in faces { let geometric = newell(contours); - let n = if normal.dot(*normal) > 0.5 { *normal } else { geometric.unit() }; + let given = normal.unit(); + let n = if given.dot(given) > 0.5 { given } else { geometric.unit() }; if n.dot(n) < 0.5 { continue; } diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 1247926de..3251cbb3e 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -251,6 +251,24 @@ fn a_partial_revolve_caps_its_ends_with_sectors() { assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); } +#[test] +fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direction() { + // A wall 100 pt tall and one 0.5 pt tall, each drawn up and down the page. + for (from, to) in [(0.0, 100.0), (100.0, 0.0), (0.0, 0.5), (0.5, 0.0)] { + let mut b = BezPath::new(); + b.move_to((20.0, from)); + b.line_to((20.0, to)); + let p = PathData::from_bezpath(&b); + let r = p.bounds().unwrap(); + let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; + let m = revolve_mesh(&p, r, o).unwrap(); + assert!(!m.is_empty(), "{from} to {to}: the revolve made no triangles"); + assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9), "{from} to {to}: a normal is not unit length"); + assert!(closed(&m), "{from} to {to}"); + assert!(outward(&m), "{from} to {to}: a face turns inward"); + } +} + #[test] fn inflate_puffs_the_front_toward_the_viewer() { let mut b = BezPath::new(); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index 42396e241..7948b4707 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -128,6 +128,31 @@ fn a_press_on_a_clipping_path_takes_its_segment_over_the_art() { assert!(names(&a, art) && !names(&a, clip), "{a:?}"); } +/// #3748: in isolation mode Direct Selection and Group Selection take the art that the +/// isolated clip group hides outside its mask, and a drag from there moves it; outside +/// isolation mode a press there does not take it. +#[test] +fn isolation_lets_direct_selection_take_the_art_a_clip_group_hides() { + let (d, g, _, art) = doc_with_masked_square(); + let (s, p) = (Selection::default(), paint()); + let mut picked = Selection::default(); + picked.add(art); + let names = |actions: &[Action]| actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(art.0) || v["ids"] == json!([art.0]))); + for group in [false, true] { + let isolated = ToolContext { isolation: Some(g), ..cx(&d, &s, &p) }; + let mut t = DirectSelectionTool::new(group); + let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Down, 530.0, 450.0)); + assert!(names(&a), "group {group}: {a:?}"); + // The press selected the art: the drag that follows moves it. + let isolated = ToolContext { isolation: Some(g), ..cx(&d, &picked, &p) }; + let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Drag, 510.0, 470.0)); + assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "group {group}: {a:?}"); + assert!(a.iter().any(|a| matches!(a, Action::Preview(..))), "group {group}: {a:?}"); + let a = DirectSelectionTool::new(group).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 530.0, 450.0)); + assert!(!names(&a), "group {group}, not isolated: {a:?}"); + } +} + /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { let (mut d, _) = doc_with_rect(); diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index ed1ab47b0..8058101b1 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -170,7 +170,7 @@ impl FamilyTool { /// with the tool's decay and segments (a logarithm, so that a low decay with many segments does /// not round to 0). The decay ratio is clamped to 0.05…0.9999 as `shapes::spiral` clamps it. fn spiral_inner_end_ln(&self, r: f64) -> f64 { - r.ln() + f64::from(self.segments) * (self.decay / 100.0).clamp(0.05, 0.9999).ln() + r.ln() + f64::from(self.segments) / 4.0 * (self.decay / 100.0).clamp(0.05, 0.9999).ln() } /// Alt or Ctrl/Cmd in a spiral drag from `start` to `p`: the inner end keeps the distance from @@ -193,12 +193,12 @@ impl FamilyTool { } let Some(end) = self.spiral_end.filter(|e| e.is_finite()) else { return }; if m.cmd { - let ratio = ((end - r.ln()) / f64::from(self.segments)).exp(); + let ratio = ((end - r.ln()) * 4.0 / f64::from(self.segments)).exp(); if ratio.is_finite() { self.decay = (ratio * 100.0).clamp(5.0, 99.99); } } else { - let fitted = (r.ln() - end) / -(self.decay / 100.0).clamp(0.05, 0.9999).ln(); + let fitted = (r.ln() - end) * 4.0 / -(self.decay / 100.0).clamp(0.05, 0.9999).ln(); if fitted.is_finite() { let base = f64::from(self.spiral_alt_base.unwrap_or(self.segments)); self.segments = (base + 4.0 * ((fitted - base) / 4.0).round()).clamp(2.0, 1000.0) as u32; @@ -697,44 +697,44 @@ mod tests { assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 10)); // Alt rounds the fitted count to whole winds from the count when Alt goes down. assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt))), (100.0, 80.0, 10)); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))), (200.0, 80.0, 14)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))), (200.0, 80.0, 22)); // ↑ with Alt held adds one, and the held inner end moves with it. - assert_eq!(spiral(t.key(&cx, ToolKey::Up, alt)).2, 15); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))).2, 15); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))), (50.0, 80.0, 7)); + assert_eq!(spiral(t.key(&cx, ToolKey::Up, alt)).2, 23); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))).2, 23); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))), (50.0, 80.0, 2)); assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.0, 100.0).with_mods(alt))).2, 2, "at least two"); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))).2, 7); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))).2, 2); // The count stays as the radius follows the pointer again. - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 7)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 2)); // Ctrl/Cmd fits the decay to the held inner end. t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); let (r, decay, n) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(ctrl))); - assert!((r, n) == (50.0, 7) && (decay - 80.0 * 2f64.powf(1.0 / 7.0)).abs() < 1e-9, "{r} {decay} {n}"); + assert_eq!((r, decay, n), (50.0, 99.99, 2), "{r} {decay} {n}"); let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 400.0, 100.0).with_mods(ctrl))); - assert!((decay - 80.0 * (1.0f64 / 3.0).powf(1.0 / 7.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 80.0 / 9.0).abs() < 1e-9, "{decay}"); assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.5, 100.0).with_mods(ctrl))).1, 99.99, "at most 99.99 %"); assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 1e20, 100.0).with_mods(ctrl))).1, 5.0, "at least 5 %"); let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(ctrl))); - assert!((decay - 80.0 * 0.5f64.powf(1.0 / 7.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 20.0).abs() < 1e-9, "{decay}"); // Let go: the decay stays, and the spiral drawn next keeps both. - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 100.0))), (150.0, decay, 7)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 100.0))), (150.0, decay, 2)); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 100.0)), vec![Action::Commit]); - assert_eq!((t.options()["decay"].as_f64(), t.options()["segments"].clone()), (Some(decay), json!(7))); + assert_eq!((t.options()["decay"].as_f64(), t.options()["segments"].clone()), (Some(decay), json!(2))); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); let (_, d2, n2) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 140.0).with_mods(alt))); - assert_eq!((d2, n2), (decay, 7), "a new drag holds the end anew"); - // 5 % and 300 segments: an inner radius of 100 × 0.05^300 is below the smallest f64. + assert_eq!((d2, n2), (decay, 2), "a new drag holds the end anew"); + // Low decay over many winds still fits using logarithms. let mut t = FamilyTool::new("spiral"); t.set_option("decay", &json!(5.0)); t.set_option("segments", &json!(300)); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(ctrl))); - assert!((decay - 5.0 * 2f64.powf(1.0 / 300.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 5.0 * 2f64.powf(4.0 / 300.0)).abs() < 1e-9, "{decay}"); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0)); // Alt: a large radius increase adds a whole wind. t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt)); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200100.0, 100.0).with_mods(alt))).2, 304); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200100.0, 100.0).with_mods(alt))).2, 312); } /// Switching between Alt and Ctrl/Cmd keeps the held inner end and uses the current segment @@ -754,14 +754,14 @@ mod tests { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 14); + assert_eq!(t.segments, 22); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(both)); - assert_eq!(t.segments, 14, "Ctrl/Cmd takes precedence"); + assert_eq!(t.segments, 22, "Ctrl/Cmd takes precedence"); let decay = t.decay; assert!(decay > 80.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 18, "Alt resumes from the current count with the held end"); + assert_eq!(t.segments, 50, "Alt resumes from the current count with the held end"); assert_eq!(t.decay, decay); t.key(&cx, ToolKey::Escape, alt); assert_eq!(t.spiral_end, None); @@ -770,7 +770,7 @@ mod tests { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 14, "a new drag holds a new inner end"); + assert_eq!(t.segments, 22, "a new drag holds a new inner end"); } /// Escape during a drag puts back every option the drag's keys changed, for the next drag and @@ -895,7 +895,7 @@ mod tests { let start = Point::new(0.0, 0.0); t.hold_spiral_end(start, Point::new(100.0, 0.0), alt); t.hold_spiral_end(start, Point::new(200.0, 0.0), alt); - assert_eq!(t.segments, 14); + assert_eq!(t.segments, 22); t.hold_spiral_end(start, Point::new(0.01, 0.0), alt); assert_eq!(t.segments, 2); t.hold_spiral_end(start, Point::new(1e150, 0.0), alt); diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 518461343..9921d8f5b 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -79,6 +79,26 @@ fn a_press_on_a_masks_edge_selects_its_clip_group() { assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}"); } +/// #3748: in isolation mode a press on the art that the isolated clip group hides outside its +/// mask selects that art, and a drag from there moves it. Outside isolation mode the same +/// press starts a marquee. +#[test] +fn isolation_drags_the_art_a_clip_group_hides() { + let (d, g, _, art) = doc_with_masked_square(); + let (none, p) = (Selection::default(), paint()); + let mut picked = Selection::default(); + picked.add(art); + let mut t = SelectionTool::default(); + let a = t.pointer(&ToolContext { isolation: Some(g), ..cx(&d, &none, &p) }, &ev(PointerKind::Down, 530.0, 450.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({ "ids": [art.0] }))]); + let a = t.pointer(&ToolContext { isolation: Some(g), ..cx(&d, &picked, &p) }, &ev(PointerKind::Drag, 430.0, 450.0)); + let Some(Action::Preview(c, v)) = a.get(1) else { panic!("{a:?}") }; + assert_eq!((c.as_str(), v["matrix"][4].as_f64(), v["matrix"][5].as_f64()), ("object.transform", Some(-100.0), Some(0.0))); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 530.0, 450.0)).is_empty()); + assert!(matches!(t.state, State::Marquee { .. }), "not isolated"); +} + /// The art of a selected clipped object (picked in the Layers panel), pressed where its mask /// hides it, is grabbed: a drag moves the selection (the art, not its clip group) and a click /// keeps it selected. With nothing of it selected, or with Shift, a press there starts a marquee. diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 50afa31c2..bd0c229dd 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -160,8 +160,11 @@ fn next_edit_session() -> u64 { pub struct TypeTool { vertical: bool, mode: Mode, - /// The tool that was active before type editing began, which Escape and Cmd+Enter return to. - /// Clicks that move editing to other text keep it. + /// The tool whose double-click on type chose this tool, which Escape and Cmd+Enter return to. + /// The canvas sets it; choosing the Type tool another way (the toolbar) leaves it unset, and + /// Escape then goes to the Selection tool. Clicks that end editing or move it to other text + /// keep it. Choosing another tool clears it, and so does deactivating this tool when another + /// document or another window of the document becomes active. prev_tool: Option, /// Text object being edited. editing: Option, @@ -412,8 +415,8 @@ impl TypeTool { out } - /// Stop editing and go back to the tool that was active before type editing began (or to the - /// Selection tool), and leave the type selected. + /// Stop editing and go back to the tool whose double-click on type chose this tool (or to the + /// Selection tool when none did), and leave the type selected. fn end_editing(&mut self, cx: &ToolContext) -> Vec { let target = self.prev_tool.take().unwrap_or_else(|| "selection".into()); let mut out = self.finish(cx); @@ -476,6 +479,43 @@ impl TypeTool { out } + /// Tab (`by` 1) or Shift+Tab (`by` -1) in type `t` (plain text `text`) with the selection + /// `a..b`: with the caret at the start of a list paragraph, the `text.setFormat` that moves the + /// paragraph a level down or up (none at the first or the last level: the key then does + /// nothing). None when the caret is anywhere else, the selection holds characters, or the + /// paragraph is in no list. The start of a threaded frame whose first paragraph continues from + /// the frame before ([`TextObject::first_para_continues`]) is not the start of a paragraph. + fn list_level_step(&self, t: &TextObject, text: &str, a: usize, b: usize, by: i32) -> Option> { + let id = self.editing?; + if a != b || edit::paragraph_at(text, a).start != a || (a == 0 && t.first_para_continues) { + return None; + } + let para = text.get(..a)?.matches('\n').count(); + let list = t.para_at(para).list?; + let level = i32::try_from(list.depth()).ok()? + by; + let max = i32::try_from(vectorcraft_doc::text::LIST_LEVELS).ok()?; + if !(1..=max).contains(&level) { + return Some(vec![]); + } + Some(vec![Action::Exec("text.setFormat".into(), json!({"id": id.0, "start": a, "end": a, "listLevel": level}))]) + } + + /// Tab (Shift+Tab with `shift`) in type `t` (plain text `text`) with the selection `a..b`: with + /// the caret at the start of a list paragraph, it moves the paragraph a level down or up + /// ([`Self::list_level_step`]); anywhere else Tab inserts a tab. + fn tab(&mut self, cx: &ToolContext, t: &TextObject, text: &str, (a, b): (usize, usize), shift: bool) -> Vec { + match self.list_level_step(t, text, a, b, if shift { -1 } else { 1 }) { + // At the first or the last level the key does nothing, and the typing goes on. + Some(step) if step.is_empty() => step, + Some(step) => { + let mut out = self.commit(); + out.extend(step); + out + } + None => self.replace(cx, "\t", None), + } + } + /// The other frames whose whole text is selected along with the edited frame's after Select /// All in threaded text ([`Self::story`]), read from the document's current thread: none once /// the selection is not all of the edited frame's text. diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index 4ae1424f6..a7f302db6 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -535,6 +535,105 @@ fn backspace_deletes_selection_or_word() { assert_eq!(tool.caret, 6); } +/// Tab and Shift+Tab with the caret at the start of a list paragraph move it a level down or up +/// with one `text.setFormat` (one undo step), ending the typing first; at level 1 Shift+Tab and at +/// level 9 Tab do nothing. Anywhere else Tab inserts a tab: in the middle or at the end of a list +/// paragraph, with characters selected, and in a paragraph in no list. +#[test] +fn tab_at_the_start_of_a_list_paragraph_changes_its_level() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let (mut d, id, mut tool) = editing("One\nTwo\nThree"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") }; + let dot = |level| vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot).at_level(level)), ..Default::default() }; + t.set_paragraph_styles(vec![dot(1), dot(9), vectorcraft_doc::ParaStyle::default()]); + let press = |tool: &mut TypeTool, at: (usize, usize), mods: Mods| { + (tool.anchor, tool.caret) = at; + key(tool, &d, ToolKey::Tab, mods) + }; + let level = |acts: &[Action]| match acts { + [Action::Exec(cmd, p)] if cmd == "text.setFormat" => { + assert_eq!((p["id"].as_u64(), p["end"].as_u64()), (Some(id.0), p["start"].as_u64())); + (p["start"].as_u64(), p["listLevel"].as_i64()) + } + other => panic!("not a level change: {other:?}"), + }; + assert_eq!(level(&press(&mut tool, (0, 0), Mods::default())), (Some(0), Some(2))); + assert_eq!(level(&press(&mut tool, (4, 4), SHIFT)), (Some(4), Some(8))); + assert!(press(&mut tool, (0, 0), SHIFT).is_empty(), "Shift+Tab at level 1 does nothing"); + assert!(press(&mut tool, (4, 4), Mods::default()).is_empty(), "Tab at level 9 does nothing"); + let inserts_tab = |acts: &[Action], at: u64| { + acts.iter().any( + |a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["start"].as_u64() == Some(at) && p["runs"][0]["text"] == "\t"), + ) + }; + for (at, why) in [((1, 1), "mid-item"), ((3, 3), "at the item's end"), ((0, 2), "with characters selected"), ((8, 8), "in no list")] { + let mut fresh = TypeTool::new("type"); + fresh.start_editing(id, 0); + let acts = press(&mut fresh, at, Mods::default()); + assert!(inserts_tab(&acts, at.0 as u64), "{why}: {acts:?}"); + } + // While typing, Tab at the start of a list paragraph ends the typing first. + let mut typing = TypeTool::new("type"); + typing.start_editing(id, 3); + let snapshot = d.clone(); + let mut after = d.clone(); + let acts = typing.text_input(&cx(&d, &Selection::default(), &paint()), "!"); + apply(&mut after, &snapshot, &acts); + // Tab at level 9 does nothing, and the typing goes on as one undo step. + (typing.anchor, typing.caret) = (5, 5); + let acts = typing.key(&cx(&after, &Selection::default(), &paint()), ToolKey::Tab, Mods::default()); + assert!(acts.is_empty() && typing.typing.is_some(), "Tab at level 9 while typing: {acts:?}"); + let acts = { + (typing.anchor, typing.caret) = (5, 5); + typing.key(&cx(&after, &Selection::default(), &paint()), ToolKey::Tab, SHIFT) + }; + assert_eq!(acts.first(), Some(&Action::Commit)); + assert!(matches!(&acts[1..], [Action::Exec(cmd, p)] if cmd == "text.setFormat" && p["listLevel"] == 8 && p["start"] == 5), "{acts:?}"); +} + +/// In a threaded frame, the start of the frame is the start of a list paragraph only when the +/// frames before it end with a paragraph break: Tab there in a paragraph that goes on from the +/// frame before inserts a tab, and Tab after the paragraph's end changes the next one's level. +#[test] +fn tab_at_the_start_of_a_threaded_frame_changes_a_level_only_at_a_paragraph_start() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let dot = vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }; + let threaded = |first: &str, second: &str| { + let (mut d, a) = doc_with_text(first); + let l = d.layers[0].id; + let b = d.alloc_id(); + let mut t = TextObject::point(Point::new(100.0, 300.0), second, CharStyle { size: 20.0, ..Default::default() }); + t.set_paragraph_styles(vec![dot.clone(); second.matches('\n').count() + 1]); + let NodeKind::Text(head) = &d.node(a).unwrap().kind else { panic!("text") }; + t.first_para_continues = vectorcraft_text::thread::continued(&[head, &t])[1]; + d.insert(Some(l), 0, Node::new(b, NodeKind::Text(Box::new(t)))).unwrap(); + let NodeKind::Text(t) = &mut d.node_mut(a).unwrap().kind else { panic!("text") }; + t.set_paragraph_styles(vec![dot.clone(); first.matches('\n').count() + 1]); + d.text_threads.push(vec![a, b]); + let mut tool = TypeTool::new("type"); + tool.start_editing(b, 0); + (d, b, tool) + }; + let level_change = |acts: &[Action]| matches!(acts, [Action::Exec(cmd, p)] if cmd == "text.setFormat" && p["listLevel"] == 2); + let inserts_tab = + |acts: &[Action]| acts.iter().any(|a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["runs"][0]["text"] == "\t")); + // "Two" goes on from the first frame: Tab at the frame's start inserts a tab, and Tab at the + // start of "Three" changes its level. + let (d, _, mut tool) = threaded("One\nTw", "o\nThree"); + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(inserts_tab(&acts), "mid-paragraph: {acts:?}"); + let (d, _, mut tool) = threaded("One\nTw", "o\nThree"); + (tool.anchor, tool.caret) = (2, 2); + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(level_change(&acts), "at the next paragraph: {acts:?}"); + // A frame after a paragraph break, or after frames with no text, starts a paragraph. + for first in ["One\n", ""] { + let (d, _, mut tool) = threaded(first, "Two"); + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(level_change(&acts), "after {first:?}: {acts:?}"); + } +} + #[test] fn double_and_triple_click_select_word_and_paragraph() { let (d, id, mut tool) = editing("Alpha beta\ngamma"); @@ -1740,3 +1839,31 @@ fn shift_enter_inserts_soft_return() { let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; assert_eq!(t.plain_text(), "Hello\u{2028}\n World"); } + +#[test] +fn soft_return_in_a_list_item_keeps_tab_within_the_paragraph() { + use vectorcraft_doc::text::{Bullet, ParaList}; + + let (mut d, id, mut tool) = editing("One"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") }; + t.set_all_paras(vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }); + tool.caret = 3; + tool.anchor = 3; + let snapshot = d.clone(); + let acts = key(&mut tool, &d, ToolKey::Enter, SHIFT); + apply(&mut d, &snapshot, &acts); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("text") }; + assert_eq!(t.plain_text(), "One\u{2028}"); + assert_eq!(t.paragraph_count(), 1); + assert_eq!(t.para_at(0).list, Some(ParaList::bulleted(Bullet::Dot))); + + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(acts.iter().any( + |a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["runs"][0]["text"].as_str().is_some_and(|s| s.ends_with('\t'))) + )); + + tool.caret = 0; + tool.anchor = 0; + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.setFormat" && p["listLevel"] == 2))); +} diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 5d79cceb2..dd561bc61 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -25,8 +25,8 @@ impl Tool for TypeTool { fn wants_text(&self) -> bool { self.editing.is_some() } - /// Escape while editing is the tool's: it goes back to the tool that was active before type - /// editing began, or to the Selection tool when there was none (#1686). + /// Escape while editing is the tool's: it goes back to the tool whose double-click on type + /// chose this tool, or to the Selection tool when none did (#1686). fn claims_key(&self, _: &ToolContext, key: ToolKey) -> bool { key == ToolKey::Escape && self.editing.is_some() } @@ -231,7 +231,8 @@ impl Tool for TypeTool { // tool shortcuts again (#1686). ToolKey::Escape => self.end_editing(cx), ToolKey::Enter => self.enter(cx, mods), - ToolKey::Tab => self.replace(cx, "\t", None), + ToolKey::Tab if story => self.replace(cx, "\t", None), + ToolKey::Tab => self.tab(cx, &t, &text, (a, b), mods.shift), ToolKey::Backspace | ToolKey::Delete if a != b || story => self.replace(cx, "", None), ToolKey::Backspace if self.caret > 0 => { self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) }; diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index d9b34cc54..5050f4a2d 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -1,7 +1,7 @@ use super::params::{Method, Mode, TraceParams}; +use super::pipeline::TracedPath; #[cfg(test)] -use super::pipeline::bw_tones; -use super::pipeline::{TracedPath, is_ignored}; +use super::pipeline::{bw_tones, left_out}; use super::raster::Raster; use super::{ AtomicUsize, Crack, Loop, Ordering, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, centerline, contour, fit, logo, shapes, @@ -13,12 +13,15 @@ use super::{denoise, quantize}; /// What tracing one colour layer needs. pub(crate) struct Layers<'a> { pub(crate) q: &'a Quantized, + /// The label each layer traces a pixel as: `q.labels`, or with Ignore Color for the background + /// only, those with the background [`TRANSPARENT`]. Edges are placed by `q.labels`. + pub(crate) traced: &'a [u16], /// Each palette entry's layer, bottom (0) up. pub(crate) rank: &'a [usize], pub(crate) params: &'a TraceParams, pub(crate) img: &'a Raster, - /// The colour Ignore Color leaves out, if not white. - pub(crate) ignore: Option<[u8; 3]>, + /// For each palette entry, whether Ignore Color or Keep Colors leaves it out (see `pipeline::left_out`). + pub(crate) left_out: Vec, /// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded. pub(crate) boxes: Vec>, /// Gradients: the gradient each palette entry is, if it is one. @@ -35,10 +38,12 @@ pub(crate) struct Layers<'a> { } impl Layers<'_> { - /// Ignore White: white is no part of any layer (stacked layers reach under the layers above - /// them, and must not reach under white that is left out). + /// Ignore White, and the colors Keep Colors does not list: such a color is no part of any + /// layer (stacked layers reach under the layers above them, and must not reach under a color + /// that is left out). With `ignore_background` the background is [`TRANSPARENT`] in `traced`, + /// and the color's other areas are traced. fn ignored(&self, label: u16) -> bool { - self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) + self.left_out.get(label as usize).copied().unwrap_or(false) } /// The region across a crack, for cutting outlines at their junctions: the outside pixel's /// colour. Stacked layers end on many colours at once and share no edge: one neighbour. @@ -270,7 +275,7 @@ impl Layers<'_> { let (thin, lines) = if params.strokes { // The ink the lines are read against: the layer's colour; Black and White's own black. let ink = if params.mode == Mode::BlackAndWhite { [self.bw_tones[usize::from(ci == 1)]; 3] } else { color.map(f64::from) }; - centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) + centerline::lines(self.traced, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) } else { (vec![], vec![]) }; @@ -288,7 +293,7 @@ impl Layers<'_> { let (bw, bh) = (x1 - x0, y1 - y0); let mut mask = Vec::with_capacity(bw * bh); for y in y0..y1 { - let row = self.q.labels.get(y * w + x0..y * w + x1)?; + let row = self.traced.get(y * w + x0..y * w + x1)?; mask.extend(row.iter().enumerate().map(|(k, &l)| l != TRANSPARENT && !thin.get(y * w + x0 + k).copied().unwrap_or(false) && part(l))); } // Overlapping: a part of the mask with no pixel of this color holds only colors above it, @@ -433,10 +438,11 @@ pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { let gradients = vec![None; n]; let job = Layers { q: &q, + traced: &q.labels, rank: &rank, params, img, - ignore: None, + left_out: left_out(&q.palette, params, None, &[]), boxes: vec![None; n], gradients: &gradients, bw_tones: bw_tones(img, &q), diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index e68118d63..46c29f4e0 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -81,6 +81,8 @@ pub(crate) use layers::shift_probe; pub use params::{Method, Mode, PRESET_NAMES, Palette, TraceParams, enhanced_preset, preset, presets}; pub use pipeline::{TraceResult, TracedPath, trace, trace_watched, trace_within}; #[cfg(test)] +use pipeline::{clear_background, label_extents}; +#[cfg(test)] use raster::fit_size; pub use raster::{MAX_PIXELS, MAX_SOURCE_PIXELS, Raster, TraceError}; diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index 65e5bb5c0..aed7984d8 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -91,6 +91,15 @@ pub struct TraceParams { /// The colour Ignore Color leaves out (`#rrggbb`): the palette colours within about 30 levels /// of it. `None` is white. (Flat Logo mode leaves out white only.) pub ignore_color: Option, + /// With Ignore Color: leave out only the areas of that color that reach the image's edge (a + /// background), through neighbors above, below and to either side, and trace the areas of it + /// that other colors enclose. (Flat Logo mode leaves out every white area.) + pub ignore_background: bool, + /// Keep Colors: the colors to trace (`#rrggbb`). Only the palette colors within about 30 + /// levels of one of them (as Ignore Color matches a color) are traced, a gradient by its middle + /// color, and their paths are the ones the trace of every color has. Empty traces every color. + /// (Flat Logo mode does not use it: it traces the colors named in `logo_colors`.) + pub keep_colors: Vec, /// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs /// to true circles. pub snap_curves_to_lines: bool, @@ -140,6 +149,8 @@ impl Default for TraceParams { method: Method::Abutting, ignore_white: false, ignore_color: None, + ignore_background: false, + keep_colors: Vec::new(), snap_curves_to_lines: false, shapes: false, auto_grouping: false, diff --git a/crates/trace/src/pipeline.rs b/crates/trace/src/pipeline.rs index 486eb9c0a..aa5c7e25b 100644 --- a/crates/trace/src/pipeline.rs +++ b/crates/trace/src/pipeline.rs @@ -36,6 +36,12 @@ pub struct TraceResult { /// option). Each path drawn together with those inside it, in the order they have here, paints /// what `paths` paints in its order. pub parents: Vec>, + /// Keep Colors: the colors listed in [`TraceParams::keep_colors`] (as written) that no color + /// the trace found matches, so that nothing of them is traced. (Flat Logo mode: empty.) + pub keep_unmatched: Vec, + /// Keep Colors: whether Ignore Color left out every color the trace found that a listed color + /// matches, so that none of them was traced. (Flat Logo mode: false.) + pub keep_ignored: bool, /// Flat Logo: the size in pixels the image was traced at when it was averaged down first; /// `None` when it was traced at its own size. pub worked_at: Option<(u32, u32)>, @@ -59,6 +65,92 @@ pub(crate) fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool { } } +/// Whether Keep Colors traces palette color `c`: every color when `keep` is empty, else those +/// Ignore Color would match to one of `keep`. +fn is_kept(c: [u8; 3], keep: &[[u8; 3]]) -> bool { + keep.is_empty() || keep.iter().any(|&k| is_ignored(c, Some(k))) +} + +/// For each color of `palette`, whether it is left out of every area: Ignore Color's when it +/// leaves out the whole color (`ignore`, `None` for white) and those Keep Colors does not list +/// (`keep`). +pub(crate) fn left_out(palette: &[[u8; 3]], params: &TraceParams, ignore: Option<[u8; 3]>, keep: &[[u8; 3]]) -> Vec { + palette.iter().map(|&c| (drops_color(params) && is_ignored(c, ignore)) || !is_kept(c, keep)).collect() +} + +/// Whether Ignore Color leaves out every area of its color (not only the background). +pub(crate) fn drops_color(params: &TraceParams) -> bool { + params.ignore_white && !params.ignore_background +} + +/// Ignore Color, background only: clears to [`TRANSPARENT`] every area of a label `ignored` leaves +/// out that reaches the image's edge, through neighbors above, below and to either side. It fills +/// one run of a row at a time, and fills from one edge pixel at a time. +pub(crate) fn clear_background(labels: &mut [u16], w: usize, h: usize, ignored: &[bool]) { + let (last_row, last_col) = (h.saturating_sub(1) * w, w.saturating_sub(1)); + let mut seeds = Vec::new(); + for edge in (0..w).flat_map(|x| [x, last_row + x]).chain((0..h).flat_map(|y| [y * w, y * w + last_col])) { + seeds.push(edge); + fill_left_out(labels, w, h, ignored, &mut seeds); + } +} + +/// Clears to [`TRANSPARENT`] the left-out areas that `seeds` reach, and empties `seeds`. +fn fill_left_out(labels: &mut [u16], w: usize, h: usize, ignored: &[bool], seeds: &mut Vec) { + let left_out = |labels: &[u16], i: usize| labels.get(i).is_some_and(|&l| ignored.get(l as usize).copied().unwrap_or(false)); + while let Some(i) = seeds.pop() { + if !left_out(labels, i) { + continue; + } + // The run of left-out pixels along the row through `i`. + let row = i - i % w; + let (mut a, mut b) = (i, i + 1); + while a > row && left_out(labels, a - 1) { + a -= 1; + } + while b < row + w && left_out(labels, b) { + b += 1; + } + if let Some(run) = labels.get_mut(a..b) { + run.fill(TRANSPARENT); + } + // A seed at the start of each run of left-out pixels next to it in the rows above and below. + for next in [row.checked_sub(w), Some(row + w).filter(|&r| r < w * h)].into_iter().flatten() { + let mut inside = false; + for x in a - row..b - row { + let here = left_out(labels, next + x); + if here && !inside { + seeds.push(next + x); + } + inside = here; + } + } + } +} + +/// A box of pixels: `(x0, y0, x1, y1)`, the far sides excluded. +type PixelBox = (usize, usize, usize, usize); + +/// Each of `n` palette entries' pixel count in `labels`, and the box its pixels lie in within +/// `traced` (`labels` with Ignore Color's background [`TRANSPARENT`]). A layer is traced within its +/// box, so a color whose background is left out is traced within the box of its remaining areas. +pub(crate) fn label_extents(labels: &[u16], traced: &[u16], w: usize, n: usize) -> (Vec, Vec>) { + let mut counts = vec![0usize; n]; + for &l in labels { + if let Some(c) = counts.get_mut(l as usize) { + *c += 1; + } + } + let mut boxes: Vec> = vec![None; n]; + for (i, &l) in traced.iter().enumerate() { + if let Some(b) = boxes.get_mut(l as usize) { + let (x, y) = (i % w, i / w); + *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); + } + } + (counts, boxes) +} + /// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use /// [`trace_within`] to see why. pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { @@ -98,6 +190,12 @@ pub fn trace_watched( fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { let (w, h) = (img.width as usize, img.height as usize); + // The colors Keep Colors traces (checked in every mode). + let listed = params + .keep_colors + .iter() + .map(|name| logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))) + .collect::, _>>()?; // After each stage before the colour layers, whose number isn't known yet. let go_on = || if steps(0, 0) { Ok(()) } else { Err(TraceError::Cancelled) }; if params.mode == Mode::Logo { @@ -160,16 +258,23 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( let keep: Vec = q.palette.iter().map(|c| rare.contains(c)).collect(); quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep); go_on()?; - // Pixel count per palette entry, and the box its pixels lie in. - let mut counts = vec![0usize; q.palette.len()]; - let mut boxes: Vec> = vec![None; q.palette.len()]; - for (i, &l) in q.labels.iter().enumerate() { - if let (Some(c), Some(b)) = (counts.get_mut(l as usize), boxes.get_mut(l as usize)) { - *c += 1; - let (x, y) = (i % w, i / w); - *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); + // Ignore Color, background only: the labels traced, with the ignored color's areas that reach + // the edge left out. Its other areas are traced with the rest. Edges are still placed by + // `q.labels`, as when the whole color is left out. + let cleared = (params.ignore_white && params.ignore_background).then(|| { + let ignored: Vec = q.palette.iter().map(|&c| is_ignored(c, ignore)).collect(); + let mut labels = q.labels.clone(); + clear_background(&mut labels, w, h, &ignored); + labels + }); + // Whether any of a palette entry's pixels are left after the background. + let mut remains = vec![false; q.palette.len()]; + for &l in cleared.iter().flatten() { + if let Some(r) = remains.get_mut(l as usize) { + *r = true; } } + let (counts, boxes) = label_extents(&q.labels, cleared.as_deref().unwrap_or(&q.labels), w, q.palette.len()); // Layers bottom → top: largest area first. let mut order: Vec = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect(); order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b))); @@ -191,14 +296,33 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( for (r, &i) in order.iter().enumerate() { rank[i] = r; } - let layers: Vec<(usize, usize)> = - order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_ignored(q.palette[ci], ignore))).collect(); + // Layers left out: Ignore Color's (all of a color's areas, unless only its background is left + // out and some area remains), and those Keep Colors does not list. Abutting, the others are + // traced as they are in the trace of every color; stacked, they do not reach under the colors + // left out (see `Layers::ignored`). + let left_out = left_out(&q.palette, params, ignore, &listed); + let dropped = |ci: usize| { + (params.ignore_white && is_ignored(q.palette[ci], ignore) && !remains.get(ci).copied().unwrap_or(false)) + || left_out.get(ci).copied().unwrap_or(false) + }; + let layers: Vec<(usize, usize)> = order.iter().copied().enumerate().filter(|&(_, ci)| !dropped(ci)).collect(); + // The listed colors that match none of the colors found. + let keep_unmatched = params + .keep_colors + .iter() + .zip(&listed) + .filter(|&(_, &k)| !order.iter().any(|&ci| is_kept(q.palette[ci], &[k]))) + .map(|(name, _)| name.clone()) + .collect(); + // Every layer is left out although a listed color matches one: Ignore Color left those out. + let keep_ignored = !listed.is_empty() && layers.is_empty() && order.iter().any(|&ci| is_kept(q.palette[ci], &listed)); let job = Layers { q: &q, + traced: cleared.as_deref().unwrap_or(&q.labels), rank: &rank, params, img, - ignore, + left_out, boxes, gradients: &gradients, bw_tones: bw_tones(img, &q), @@ -208,7 +332,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( anchors: AtomicUsize::new(0), max_anchors, }; - let mut out = TraceResult::default(); + let mut out = TraceResult { keep_unmatched, keep_ignored, ..TraceResult::default() }; // A layer takes about ten bytes a pixel of its box while it is traced (its mask, the labels // of its components, the stack that fills them, its edges): as many layers at a time as fit // in half a gigabyte, so that a large image is not traced at the cost of gigabytes. diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 93467b352..19f208175 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1444,6 +1444,231 @@ fn ignore_color_leaves_out_the_colour_picked() { assert_eq!(serde_json::to_value(¶ms).unwrap()["ignoreColor"], serde_json::Value::Null); } +/// A navy field with a yellow disc, a green bar and a red square on it, hard-edged. +fn four_color_art() -> Raster { + Raster::from_fn(160, 120, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + if Point::new(fx, fy).distance(Point::new(50.0, 45.0)) <= 30.0 { + [250, 200, 40, 255] + } else if (20.0..140.0).contains(&fx) && (90.0..108.0).contains(&fy) { + [40, 160, 90, 255] + } else if (100.0..140.0).contains(&fx) && (20.0..60.0).contains(&fy) { + [200, 40, 50, 255] + } else { + [20, 40, 90, 255] + } + }) +} + +/// A 600 × 400 field of `edge` with a light panel (40..560 × 40..360) and, on the panel, a ring of +/// `line` 8 px wide and 100 px in radius around (300, 200), filled with `inside`. +fn panel_with_ring(edge: [u8; 4], line: [u8; 4], inside: [u8; 4]) -> Raster { + Raster::from_fn(600, 400, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let r = Point::new(fx, fy).distance(Point::new(300.0, 200.0)); + if !((40.0..560.0).contains(&fx) && (40.0..360.0).contains(&fy)) { + edge + } else if (96.0..=104.0).contains(&r) { + line + } else if r < 96.0 { + inside + } else { + [240, 240, 220, 255] + } + }) +} + +#[test] +fn keep_colors_traces_only_the_colors_listed() { + let near = |c: [u8; 3], want: [u8; 3]| (0..3).all(|k| c[k].abs_diff(want[k]) <= 3); + let (yellow, red, navy) = ([250, 200, 40], [200, 40, 50], [20, 40, 90]); + let img = four_color_art(); + let pixels = |res: &TraceResult, c: [u8; 3]| res.paths.iter().filter(|p| near(p.color, c)).map(|p| p.pixels).sum::(); + let abutting = TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 4, ..TraceParams::default() }; + let abutting_all = trace(&img, &abutting); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { method, ..abutting.clone() }; + let all = trace(&img, ¶ms); + assert_eq!(all.palette.len(), 4, "{method:?}: {:?}", all.palette); + // The yellow and the red, each picked a little off. + let res = trace(&img, &TraceParams { keep_colors: vec!["#f0c832".into(), "#c8283c".into()], ..params.clone() }); + assert_eq!(res.palette.len(), 2, "{method:?}: {:?}", res.palette); + if method == Method::Abutting { + // Their paths as the whole trace has them. + let want: Vec = all.paths.iter().filter(|p| near(p.color, yellow) || near(p.color, red)).cloned().collect(); + assert!(want.iter().any(|p| near(p.color, yellow)) && want.iter().any(|p| near(p.color, red))); + assert_eq!(res.paths, want); + } else { + // Stacked, each kept color covers what it covers in the trace of every color: the red + // square touches neither the yellow disc nor the green left out, so it covers only itself. + assert!(res.paths.iter().all(|p| near(p.color, yellow) || near(p.color, red))); + assert_eq!(pixels(&res, red), pixels(&all, red)); + assert_eq!(pixels(&res, red), pixels(&abutting_all, red)); + assert_eq!(pixels(&res, yellow), pixels(&abutting_all, yellow)); + // The navy field alone, at the bottom of the stack: no part of the colors left out. + let field = trace(&img, &TraceParams { keep_colors: vec!["#14285a".into()], ..params.clone() }); + assert!(!field.paths.is_empty()); + assert_eq!(pixels(&field, navy), pixels(&abutting_all, navy)); + assert!(pixels(&all, navy) > pixels(&abutting_all, navy)); + } + // Every color listed: the trace of every color, as with none listed. + let every = vec!["#fac828".into(), "#28a05a".into(), "#c82832".into(), "#14285a".into()]; + assert_eq!(trace(&img, &TraceParams { keep_colors: every, ..params.clone() }), all, "{method:?}"); + // A color the image does not have: nothing is traced. + assert!(trace(&img, &TraceParams { keep_colors: vec!["#ff00ff".into()], ..params.clone() }).paths.is_empty(), "{method:?}"); + // With Ignore Color as well, a kept color it matches is left out. + let both = TraceParams { + keep_colors: vec!["#fac828".into(), "#14285a".into()], + ignore_white: true, + ignore_color: Some("#14285a".into()), + ..params.clone() + }; + let res = trace(&img, &both); + assert!(!res.paths.is_empty() && res.paths.iter().all(|p| near(p.color, yellow)), "{method:?}"); + } + // A color that is none is refused, in Flat Logo mode too. + for mode in [Mode::Color, Mode::Logo] { + let bad = trace_within(&img, &TraceParams { mode, keep_colors: vec!["#c8283c".into(), "red".into()], ..TraceParams::default() }, usize::MAX); + assert!(matches!(&bad, Err(TraceError::BadPalette(name)) if name == "red"), "{mode:?}"); + } + // Old documents have no such setting: every color. + let old: TraceParams = serde_json::from_str(r#"{"mode":"color"}"#).unwrap(); + assert!(old.keep_colors.is_empty()); + let on: TraceParams = serde_json::from_str(r##"{"keepColors":["#c8283c"]}"##).unwrap(); + assert_eq!(on.keep_colors, ["#c8283c"]); +} + +#[test] +fn keep_colors_names_the_colors_nothing_matches() { + let img = four_color_art(); + let params = TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 4, ..TraceParams::default() }; + let unmatched = |keep: &[&str], params: &TraceParams| { + let res = trace(&img, &TraceParams { keep_colors: keep.iter().map(|k| k.to_string()).collect(), ..params.clone() }); + (res.keep_unmatched, res.paths.len()) + }; + // Every color listed matches one the trace found. + assert_eq!(unmatched(&["#f0c832", "#c8283c"], ¶ms).0, Vec::::new()); + // A color the image does not have is named, as written, beside the ones traced. + let (names, paths) = unmatched(&["#f0c832", "#FF00FF", "#c8283c"], ¶ms); + assert_eq!(names, ["#FF00FF"]); + assert!(paths > 0); + // None matches: every one is named, and nothing is traced. + assert_eq!(unmatched(&["#ff00ff", "#00ffff"], ¶ms), (vec!["#ff00ff".to_string(), "#00ffff".to_string()], 0)); + // A color Ignore Color leaves out still matches one the trace found. + let ignoring = TraceParams { ignore_white: true, ignore_color: Some("#14285a".into()), ..params.clone() }; + assert_eq!(unmatched(&["#14285a"], &ignoring), (Vec::new(), 0)); + // The result says that Ignore Color left out every color that matches, and only then. + let keep = |keep: &[&str], params: &TraceParams| { + trace(&img, &TraceParams { keep_colors: keep.iter().map(|k| k.to_string()).collect(), ..params.clone() }) + }; + assert!(keep(&["#14285a"], &ignoring).keep_ignored); + assert!(!keep(&["#14285a", "#f0c832"], &ignoring).keep_ignored); + assert!(!keep(&["#14285a"], ¶ms).keep_ignored); + assert!(!keep(&["#ff00ff"], &ignoring).keep_ignored); + // With none listed, none is named. + assert!(trace(&img, ¶ms).keep_unmatched.is_empty()); + // Flat Logo mode does not use the list. + assert!(trace(&img, &TraceParams { mode: Mode::Logo, keep_colors: vec!["#ff00ff".into()], ..params }).keep_unmatched.is_empty()); +} + +#[test] +fn ignore_background_leaves_out_only_the_areas_of_the_color_that_reach_the_edge() { + use kurbo::Shape; + let covers = |res: &TraceResult, color: [u8; 3], x: f64, y: f64| { + res.paths.iter().any(|t| t.color == color && t.path.to_bezpath().winding(kurbo::Point::new(x, y)) != 0) + }; + let covered = |res: &TraceResult, x: f64, y: f64| res.paths.iter().any(|t| t.path.to_bezpath().winding(kurbo::Point::new(x, y)) != 0); + // A black field, a light panel and a black ring on it. + let light = [240, 240, 220, 255]; + let img = panel_with_ring(BLACK, BLACK, light); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { + mode: Mode::Color, + palette: Palette::Limited, + colors: 2, + method, + ignore_white: true, + ignore_color: Some("#000000".into()), + ..TraceParams::default() + }; + // The whole color: the ring goes with the field. + let all = trace(&img, ¶ms); + assert!(all.paths.iter().all(|p| p.color != [0, 0, 0]), "{method:?}"); + assert!(!covered(&all, 300.0, 100.0) && !covered(&all, 10.0, 10.0), "{method:?}"); + // The background only: the field goes, the ring stays. + let res = trace(&img, &TraceParams { ignore_background: true, ..params.clone() }); + assert!(covers(&res, [0, 0, 0], 300.0, 100.0) && covers(&res, [0, 0, 0], 300.0, 300.0), "{method:?}"); + assert!(covers(&res, [240, 240, 220], 100.0, 200.0) && covers(&res, [240, 240, 220], 300.0, 200.0), "{method:?}"); + assert!(!covered(&res, 10.0, 10.0) && !covered(&res, 590.0, 390.0) && !covered(&res, 300.0, 20.0), "{method:?}"); + // Without Ignore Color, the setting has no effect. + let kept = |p: &TraceParams| trace(&img, &TraceParams { ignore_white: false, ..p.clone() }); + assert_eq!(kept(&TraceParams { ignore_background: true, ..params.clone() }), kept(¶ms), "{method:?}"); + } + // White, the default: a white field goes, the white inside a ring stays. + let img = panel_with_ring(WHITE, BLACK, WHITE); + let params = TraceParams { mode: Mode::Color, colors: 3, ignore_white: true, ignore_background: true, ..TraceParams::default() }; + let res = trace(&img, ¶ms); + assert!(covers(&res, [255, 255, 255], 300.0, 200.0) && !covered(&res, 10.0, 10.0)); + assert!(!covered(&trace(&img, &TraceParams { ignore_background: false, ..params }), 300.0, 200.0)); + // With no area of the color enclosed, the trace is the one that leaves out the whole color, + // its anti-aliased edges placed the same. + let img = three_colour_art(0.0); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { colors: 3, method, ignore_white: true, ignore_color: Some("#14285a".into()), ..sixteen_colors() }; + assert_eq!(trace(&img, &TraceParams { ignore_background: true, ..params.clone() }), trace(&img, ¶ms), "{method:?}"); + } + // Old documents have no such setting: the whole color. + let old: TraceParams = serde_json::from_str(r#"{"mode":"color","ignoreWhite":true}"#).unwrap(); + assert!(!old.ignore_background); + let on: TraceParams = serde_json::from_str(r#"{"ignoreWhite":true,"ignoreBackground":true}"#).unwrap(); + assert!(on.ignore_background); +} + +#[test] +fn ignore_background_traces_a_color_within_the_box_of_its_remaining_areas() { + // A 40 × 30 field of color 0 with a ring of color 1 around a 2 × 2 area of color 0. + let (w, h) = (40, 30); + let mut labels = vec![0u16; w * h]; + for y in 10..16 { + for x in 20..26 { + labels[y * w + x] = 1; + } + } + for y in 12..14 { + for x in 22..24 { + labels[y * w + x] = 0; + } + } + let mut cleared = labels.clone(); + clear_background(&mut cleared, w, h, &[true, false]); + let (counts, boxes) = label_extents(&labels, &cleared, w, 2); + // The counts stay those of the whole image (they order the layers); the boxes are those of + // what is traced. + assert_eq!(counts, vec![w * h - 32, 32]); + assert_eq!(boxes, vec![Some((22, 12, 24, 14)), Some((20, 10, 26, 16))]); + // Without Ignore Color, the boxes are those of every pixel. + assert_eq!(label_extents(&labels, &labels, w, 2).1, vec![Some((0, 0, w, h)), Some((20, 10, 26, 16))]); +} + +#[test] +fn ignore_background_clears_a_tall_narrow_image_from_its_edges() { + // A 1 × 200,000 column of color 0 with color 1 at rows 1,000 to 1,009: every pixel is on the + // edge, so all of color 0 is background. + let (w, h) = (1, 200_000); + let mut labels = vec![0u16; w * h]; + for l in &mut labels[1_000..1_010] { + *l = 1; + } + clear_background(&mut labels, w, h, &[true, false]); + assert_eq!(labels.iter().filter(|&&l| l == TRANSPARENT).count(), h - 10); + assert!(labels[1_000..1_010].iter().all(|&l| l == 1)); + // In a 2 × 100,000 column of color 0, every pixel is on the edge too. + let (w, h) = (2, 100_000); + let mut labels = vec![0u16; w * h]; + clear_background(&mut labels, w, h, &[true, false]); + assert!(labels.iter().all(|&l| l == TRANSPARENT)); +} + #[test] fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() { // 120 × 60, turned 1.5° about its centre. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index d139efe64..a75fecde2 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -539,8 +539,13 @@ impl VectorcraftApp { } if self.ui.status_bar && self.ui.screen_mode < 3 { chrome::status_bar(self, ui); + } + if self.ui.help_bar && self.ui.screen_mode < 3 { chrome::hint_bar(self, ui); - } else if !(self.ui.control_bar && self.ui.control_bar_bottom && self.ui.screen_mode < 2) { + } + if (self.ui.screen_mode >= 3 || (!self.ui.status_bar && !self.ui.help_bar)) + && !(self.ui.control_bar && self.ui.control_bar_bottom && self.ui.screen_mode < 2) + { // Keeps the previous frame's value valid for menus drawn above, before the bars. A // Control bar docked at the bottom has set it to the bar's top. widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 0094dc07d..6eb161a26 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -202,7 +202,9 @@ fn wheel_view( ) -> (egui::PointerState, egui::Modifiers, bool, Option) { let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; - let animate = animated_zoom(&app.session.prefs); + // A docked field may be drawn later and has not consumed its wheel event yet. Only canvas + // hover may start a zoom glide; existing glides still decay outside the canvas in `wheel`. + let animate = animated_zoom(&app.session.prefs) && resp.hovered(); let turn_id = egui::Id::unique("canvas-wheel-turn"); let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); let mut turn = turn_before; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 9ef8fe597..6e1947ee1 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4477,6 +4477,80 @@ fn zoom_with_mouse_wheel_preference() { assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); } +/// A numeric panel drawn after the canvas owns its wheel turn even when the pointer enters the +/// canvas on the next frame (#5949). Only a subsequent turn over the canvas starts a zoom glide. +#[test] +fn wheel_over_a_numeric_panel_field_does_not_start_a_canvas_zoom_glide() { + use std::cell::Cell; + for focused in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for key in ["zoomWithMouseWheel", "animatedZoom", "gpuPerformance"] { + app.run("prefs.set", json!({"key": key, "value": true})).unwrap(); + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let value = Cell::new(10.0); + let field_rect = Cell::new(egui::Rect::NOTHING); + let field_id = Cell::new(egui::Id::NULL); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp, events| { + time += 0.016; + let raw = egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + // Match dock rendering order: canvas first, the numeric panel afterward. + let mut canvas_ui = ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(Pos2::ZERO, vec2(550.0, 500.0)))); + show(app, &mut canvas_ui); + let mut panel_ui = ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(pos2(600.0, 20.0), vec2(150.0, 100.0)))); + field_id.set(panel_ui.scope_id().with("number")); + if let Some(v) = crate::widgets::plain_field(&mut panel_ui, "number", value.get(), "", 0, 80.0) { + value.set(v); + } + field_rect.set(panel_ui.min_rect()); + }); + out.textures_delta.clear(); + }; + run(&mut app, vec![]); + if focused { + ctx.memory_mut(|m| m.request_focus(field_id.get())); + } + let over_field = field_rect.get().center(); + run(&mut app, vec![egui::Event::PointerMoved(over_field)]); + let before = *app.view().unwrap(); + let turn = || egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }; + run(&mut app, vec![turn()]); + assert_eq!(value.get(), 11.0, "the hovered field steps, focused: {focused}"); + assert_eq!(ctx.memory(|m| m.has_focus(field_id.get())), focused, "hover does not take focus"); + assert_eq!(app.view().unwrap().zoom, before.zoom); + let over_canvas = app.canvas_rect.unwrap().center(); + run(&mut app, vec![egui::Event::PointerMoved(over_canvas)]); + for _ in 0..20 { + run(&mut app, vec![]); + assert_eq!(app.view().unwrap().zoom, before.zoom, "a panel turn cannot leave a canvas glide, focused: {focused}"); + assert_eq!(app.view().unwrap().center, before.center); + } + run(&mut app, vec![turn()]); + let first = app.view().unwrap().zoom; + assert!(first > before.zoom, "a turn over the canvas still zooms"); + for _ in 0..20 { + run(&mut app, vec![]); + } + assert!(app.view().unwrap().zoom > first, "the canvas turn still glides over subsequent frames"); + assert_eq!(value.get(), 11.0, "the canvas turn does not step the field"); + } +} + /// General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default), through the control channel's /// `ui.wheel`: with Zoom with Mouse Wheel off, Cmd/Ctrl-wheel zooms about the pointer while the /// option is off and scrolls sideways while it is on, and a trackpad pinch, two fingers spread on a touch diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 9a1ffbfbb..9fbd02327 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -4,7 +4,7 @@ use super::{Arc, ROTATE_VIEW_ANGLES, Sense, Stroke, Tokens, Ui, VectorcraftApp, /// Draw the status message, bounded readouts and artboard navigation controls. pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::bottom("status_bar") + let panel = egui::Panel::bottom("status_bar") .exact_size(24.0) .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))) .show(ui, |ui| { @@ -84,6 +84,7 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { status_readouts(app, ui, &t); }); }); + widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); } /// The most recently measured status widths, scoped to this UI and display settings. diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 55650c46e..93888a4d6 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -64,6 +64,68 @@ use crate::theme::Tokens; use serde_json::json; use vectorcraft_engine::Session; +#[test] +fn help_bar_and_status_bar_render_independently() { + for (help, status, screen_mode) in [(true, true, 0), (false, true, 0), (true, false, 0), (false, false, 0), (true, true, 3)] { + let (mut app, ctx) = status_readout_app(); + app.ui.help_bar = help; + app.ui.status_bar = status; + app.ui.screen_mode = screen_mode; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + let mut parent = None; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + parent = Some(ui.scope_id()); + app.ui(ui); + }); + output.textures_delta.clear(); + for _ in 0..2 { + output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| app.ui(ui)); + output.textures_delta.clear(); + } + fn text(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text(s, out)), + _ => {} + } + } + let mut shown = Vec::new(); + for shape in &output.shapes { + text(&shape.shape, &mut shown); + } + assert_eq!(shown.iter().any(|t| t == "?"), help && screen_mode < 3, "{help}/{status}/{screen_mode}: {shown:?}"); + assert_eq!(shown.iter().any(|t| t.contains("Saved recovery data")), status && screen_mode < 3, "{help}/{status}/{screen_mode}: {shown:?}"); + let floor = ctx.data(|data| data.get_temp::(egui::Id::unique("menu_floor").with(ctx.viewport_id()))).unwrap(); + let expected = if screen_mode < 3 && (help || status) { + let id = parent.unwrap().with(if help { "hint_bar" } else { "status_bar" }); + egui::containers::panel::PanelState::load(&ctx, id).unwrap().outer_rect.top() + } else { + screen.bottom() + }; + assert!((floor - expected).abs() < 1.0, "menu floor {floor}, expected {expected}"); + } +} + +#[test] +#[ignore = "Renders the Help Bar visibility examples; set HELP_BAR_SCREENSHOTS to an output directory"] +fn help_bar_visibility_screenshots() { + let dir = std::path::PathBuf::from(std::env::var_os("HELP_BAR_SCREENSHOTS").expect("output directory")); + for shown in [true, false] { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 500, "height": 400})).unwrap(); + if !shown { + crate::menus::run_ui_command(&mut app, "window.helpBar", &serde_json::Value::Null).unwrap().unwrap(); + } + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1000.0, 700.0)) + .with_accessibility_check(false) + .wgpu() + .build_ui_state(|ui, app: &mut crate::VectorcraftApp| app.ui(ui), app); + h.run_steps(6); + h.render().unwrap().save(dir.join(if shown { "help-visible.png" } else { "help-hidden.png" })).unwrap(); + } +} + #[test] fn hints_name_keys_as_the_menus_do() { let text = |tool: &str| super::hint_segments(tool).into_iter().map(|(s, _)| s).collect::(); @@ -1946,7 +2008,8 @@ fn menus_stop_above_a_control_bar_docked_at_the_bottom() { let ctx = egui::Context::default(); let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); let floor = |app: &mut crate::VectorcraftApp, status: bool, bottom: bool, screen_mode: u8| { - (app.ui.status_bar, app.ui.control_bar, app.ui.control_bar_bottom, app.ui.screen_mode) = (status, true, bottom, screen_mode); + (app.ui.status_bar, app.ui.help_bar, app.ui.control_bar, app.ui.control_bar_bottom, app.ui.screen_mode) = + (status, status, true, bottom, screen_mode); for _ in 0..3 { let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { app.logic(ui.ctx()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fccfbedab..71b1aacdc 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -456,6 +456,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques o.remove("wait"); } let method = req.method.as_str(); + // The key check of `document.export` runs before the file is written. + if method == "app.export" + && let Err(e) = crate::menus::check_params(&app.session, "document.export", &p) + { + return err(e); + } wrap(if wait { crate::background::in_foreground(app, |app| save_or_export(app, method, &p)) } else { save_or_export(app, method, &p) }) } // As the menu's Quit: `{"pending": "saveChanges"}` while a modified document is asked diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 48b9895a6..d5e2a30c6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -17,6 +17,7 @@ pub mod blend_options; mod brush_art_options; pub mod brush_delete; pub mod brush_options; +pub mod bullets_numbering; pub mod char_style_options; pub mod color_balance; pub mod color_guide_options; @@ -348,6 +349,7 @@ registry! { Corners: [corners::KIND] => corners::SPEC, Hyphenation: [hyphenation::KIND] => hyphenation::SPEC, Justification: [justification::KIND] => justification::SPEC, + BulletsAndNumbering: [bullets_numbering::KIND] => bullets_numbering::SPEC, TateChuYokoSettings: [tate_chu_yoko::KIND] => tate_chu_yoko::SPEC, CompositeFonts: [composite_fonts::KIND] => composite_fonts::SPEC, SavePdf: [save_pdf::KIND] => save_pdf::SPEC, diff --git a/crates/ui-egui/src/dialogs/bullets_numbering.rs b/crates/ui-egui/src/dialogs/bullets_numbering.rs new file mode 100644 index 000000000..43f15edbb --- /dev/null +++ b/crates/ui-egui/src/dialogs/bullets_numbering.rs @@ -0,0 +1,802 @@ +//! More Options at the end of the Paragraph panel's Bullets and Numbered List presets (or `ui.bulletsAndNumbering {tab?}`): a +//! Bullets tab and a Numbers tab, each with the Level field and the presets, and the Position group +//! (Alignment, Left Indent and First Line Indent) of the paragraphs the Paragraph panel acts on. +//! The dialog opens on the values the Paragraph panel shows, with no preset picked and a field +//! blank where the paragraphs differ. Each change shows on the type while the dialog is open. OK +//! runs `text.setFormat` with the values that changed, and with the indents it shows when a preset +//! puts the paragraphs in a list, as one undo step; Cancel puts the type back as it was. A preset +//! picked for paragraphs that are in no list and have no indents fills in the 18 pt and -18 pt +//! indents `text.setFormat` gives them, and None takes them out again. +//! +//! Fields: `tab` (`bullets` or `numbers`), `list` (`none`, `bullet` or `number`: the kind of the +//! preset picked), `bullet` (`dot`, `dash`, `circle` or `square`), `number` (`arabic`, +//! `upperLetters`, `lowerLetters`, `upperRoman`, `lowerRoman`, `twoDigits` or `threeDigits`), +//! `listAlign` (`left` or `right`), `listLevel` (1 to 9; 1 for paragraphs in no list), +//! `leftIndent` and `firstLineIndent` (pt), as `text.setFormat` takes them, with `__initial` (the +//! values it opened with) and `__targets` (the type and range it acts on). +//! +//! Behavior reference (public documentation): +//! . + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::text::{LIST_HANGING_INDENT, LIST_LEVELS, NumberStyle, ParaList}; + +use super::swatch_options::label; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Bullets and Numbering. +pub const KIND: &str = "bulletsAndNumbering"; + +/// The field holding the values the dialog opened with. +const INITIAL: &str = "__initial"; +/// The field holding the `ids`, `start` and `end` the dialog's `text.setFormat` acts on. +const TARGETS: &str = "__targets"; +/// Set while the indent fields hold the hanging indent a preset filled in ([`pick`]). +const HANGING: &str = "__hanging"; +const CMD: &str = "text.setFormat"; + +/// The `tab` values, in the order the tabs are shown. +const TABS: [&str; 2] = ["bullets", "numbers"]; +/// The `listAlign` values, in the order Alignment lists them. +const ALIGNS: [&str; 2] = ["left", "right"]; +/// The indents `text.setFormat` keeps (points): a value typed outside them becomes the nearer end. +const INDENTS: std::ops::RangeInclusive = -1296.0..=1296.0; +/// The side of a preset button (points). +const PRESET: f32 = 34.0; +/// The keys a preset button with the keyboard focus keeps from egui's focus moves. +const ARROWS: egui::EventFilter = egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: false, escape: false }; + +/// The fields `ui.dialog.set` can set while the dialog does not hold them: the dialog leaves a +/// field out where the paragraphs differ, and holds no `bullet` or `number` for paragraphs in no +/// list or in another kind of list. +const INPUTS: [&str; 7] = ["list", "bullet", "number", "listAlign", "listLevel", "leftIndent", "firstLineIndent"]; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Bullets and Numbering").into(), + body, + confirm, + preview: true, + min_width: 380.0, + inputs: |_| &INPUTS, + ..DialogSpec::FORM +}; + +/// The kind of list `list` is, as the `list` field names it. +fn kind(list: Option) -> &'static str { + match list { + None => "none", + Some(l) if l.number.is_some() => "number", + Some(_) => "bullet", + } +} + +/// Open Bullets and Numbering on the paragraph attributes the Paragraph panel shows, on tab `tab` +/// (`bullets` or `numbers`; without it, Numbers when the paragraphs are a numbered list). +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + use crate::panels::paragraph::{shared_para, shared_para_field}; + let key = "tab"; + let tab = match p.get(key) { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().filter(|t| TABS.contains(t)).ok_or_else(|| format!("`{key}` must be one of {}", TABS.join(", ")))?), + }; + crate::panels::character::text_style(app).ok_or("select some type first")?; + // While typing, the dialog acts on the paragraphs of the text being edited. + let mut targets = json!({}); + if let Some((id, a, b)) = crate::panels::character::text_editing(app) { + crate::panels::character::end_typing(app); + targets = json!({"ids": [id.0], "start": a, "end": b}); + } + let mut values = Map::new(); + let list = shared_para(app, |p| kind(p.list)); + if let Some(k) = list { + values.insert("list".into(), json!(k)); + } + if let Some(Some(b)) = shared_para(app, |p| p.list.filter(|l| l.number.is_none()).map(|l| l.bullet)) { + values.insert("bullet".into(), json!(b)); + } + if let Some(Some(n)) = shared_para(app, |p| p.list.and_then(|l| l.number)) { + values.insert("number".into(), json!(n)); + } + // A paragraph in no list counts as aligned left, the alignment a list starts with. + if let Some(a) = shared_para(app, |p| p.list.map(|l| l.align).unwrap_or_default()) { + values.insert("listAlign".into(), json!(a)); + } + // …and the paragraphs' list level, 1 for a paragraph in no list (the level a list starts at). + if let Some(l) = shared_para(app, |p| p.list.map_or(1, |l| l.depth())) { + values.insert("listLevel".into(), json!(l)); + } + if let Some(v) = shared_para_field(app, |p| p.left_indent) { + values.insert("leftIndent".into(), json!(v)); + } + if let Some(v) = shared_para_field(app, |p| p.first_line_indent) { + values.insert("firstLineIndent".into(), json!(v)); + } + let tab = tab.unwrap_or(if list == Some("number") { "numbers" } else { "bullets" }); + let mut fields = Value::Object(values.clone()); + fields[INITIAL] = Value::Object(values); + fields[TARGETS] = targets; + fields["tab"] = json!(tab); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let unit = app.session.general_unit(); + let numbers = d.str("tab") == "numbers"; + if let Some(tab) = widgets::tab_bar(ui, &[tl!("Bullets"), tl!("Numbers")], usize::from(numbers)).and_then(|i| TABS.get(i)) { + d.fields.insert("tab".into(), json!(tab)); + } + ui.add_space(8.0); + level(ui, d); + ui.add_space(8.0); + if d.str("tab") == "numbers" { + number_tab(ui, d); + } else { + bullet_tab(ui, d); + } + ui.add_space(10.0); + position(ui, d, unit); + // The type shows the dialog's values; with nothing changed it is as it was. + let p = params(d); + if changes(&p) { + form::preview_now(app, d, tl!("Bullets and Numbering"), CMD, Value::Object(p)); + } else if d.fields.remove(form::PREVIEWED).is_some() { + let _ = app.session.cancel_interaction(); + } + false +} + +/// The Level field: the paragraphs' list level, 1 to 9, blank where they differ. +fn level(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + label(ui, tl!("Level:")); + let value = d.fields.get("listLevel").and_then(Value::as_f64); + if let Some(v) = widgets::range_field(ui, ("bullets-numbering", "listLevel"), value, 1.0..=LIST_LEVELS as f64, "", 0, 60.0) { + d.fields.insert("listLevel".into(), json!(v)); + } + }); +} + +/// The Bullets tab: the bullet presets in a row, None first, the one the dialog applies selected. +fn bullet_tab(ui: &mut egui::Ui, d: &mut Dialog) { + let presets = crate::panels::paragraph::bullet_presets(); + let shown = d.fields.get("bullet").and_then(Value::as_str); + let current = match d.fields.get("list").and_then(Value::as_str) { + Some("none") => Some(0), + Some("bullet") => presets.iter().position(|(_, key, _)| shown == Some(*key)), + _ => None, + }; + label(ui, tl!("Bullet Character:")); + let names = presets.map(|(name, _, _)| name); + let picked = preset_row(ui, "bullets", &names, current, |painter, rect, i, color| { + crate::panels::paragraph::paint_bullet(painter, rect.center(), presets.get(i).and_then(|p| p.2), color); + }); + match picked.and_then(|i| presets.get(i)) { + Some((_, _, None)) => pick(d, "none", None), + Some((_, key, Some(_))) => pick(d, "bullet", Some(*key)), + None => {} + } +} + +/// The Numbers tab: None and the number styles in a row, each shown by its first two numbers, the +/// one the dialog applies selected. +fn number_tab(ui: &mut egui::Ui, d: &mut Dialog) { + let shown = d.fields.get("number").and_then(|v| serde_json::from_value::(v.clone()).ok()); + let current = match d.fields.get("list").and_then(Value::as_str) { + Some("none") => Some(0), + Some("number") => shown.and_then(|s| NumberStyle::ALL.iter().position(|&n| n == s)).map(|i| i + 1), + _ => None, + }; + label(ui, tl!("Number Style:")); + // Screen readers name each style by its first three numbers, as the Numbered List presets do. + let samples: Vec = NumberStyle::ALL.iter().map(|&s| crate::panels::paragraph::number_sample(s, 3)).collect(); + let names: Vec<&str> = std::iter::once(tl!("None")).chain(samples.iter().map(String::as_str)).collect(); + let picked = preset_row(ui, "numbers", &names, current, |painter, rect, i, color| match i.checked_sub(1).and_then(|k| NumberStyle::ALL.get(k)) { + None => crate::panels::paragraph::paint_bullet(painter, rect.center(), None, color), + Some(&style) => { + let first = format!("{} {}", style.format(1), style.format(2)); + painter.text(rect.center(), egui::Align2::CENTER_CENTER, first, egui::FontId::proportional(11.0), color); + } + }); + match picked { + Some(0) => pick(d, "none", None), + Some(i) => { + if let Some(key) = NumberStyle::ALL.get(i - 1).and_then(|s| serde_json::to_value(s).ok()).and_then(|v| v.as_str().map(str::to_string)) { + pick(d, "number", Some(&key)); + } + } + None => {} + } +} + +/// A row of preset buttons named `names`, `current` selected, each drawn by `paint` (painter, +/// button, index, color). ← and → move the choice along the row from the button with the keyboard +/// focus, and the focus with it. Returns the button clicked or moved to. +fn preset_row( + ui: &mut egui::Ui, + salt: &str, + names: &[&str], + current: Option, + paint: impl Fn(&egui::Painter, egui::Rect, usize, egui::Color32), +) -> Option { + let t = Tokens::get(ui.ctx()); + let ids: Vec = (0..names.len()).map(|i| egui::Id::unique(("bullets-numbering-preset", salt, i))).collect(); + let mut picked = None; + let mut focused = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (&name, &id)) in names.iter().zip(&ids).enumerate() { + let (rect, _) = ui.allocate_exact_size(egui::vec2(PRESET, PRESET), egui::Sense::hover()); + let resp = ui.interact(rect, id, egui::Sense::click()); + let selected = current == Some(i); + let fill = if selected { + t.row_selected + } else if resp.hovered() { + t.hover + } else { + t.input + }; + ui.painter().rect_filled(rect, egui::CornerRadius::same(3), fill); + let (width, border) = if selected || resp.has_focus() { (1.5, t.accent) } else { (1.0, t.input_border) }; + ui.painter().rect_stroke(rect, egui::CornerRadius::same(3), egui::Stroke::new(width, border), egui::StrokeKind::Inside); + paint(ui.painter(), rect, i, t.text_strong); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), selected, name)); + if resp.has_focus() { + focused = Some(i); + // ← and → move the choice, not egui's focus. + ui.memory_mut(|m| m.set_focus_lock_filter(id, ARROWS)); + } + if resp.on_hover_text(name).clicked() { + picked = Some(i); + } + } + }); + if let Some(i) = focused { + let (back, on) = ui.input_mut(|inp| { + (inp.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft), inp.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight)) + }); + let to = if back { + i.checked_sub(1) + } else if on { + Some(i + 1).filter(|&j| j < names.len()) + } else { + None + }; + if back || on { + // egui moves the focus with an arrow pressed on a button that has just taken the + // focus, before its focus-lock filter applies: the row moves it instead. + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + if let Some(j) = to { + ui.memory_mut(|m| m.request_focus(ids[j])); + picked = Some(j); + } + } + picked +} + +/// Pick a preset of kind `list` (`none`, `bullet` or `number`) with key `key` (for `bullet` or +/// `number`). For paragraphs that were in no list and had no indents, the indent fields take the +/// hanging indent `text.setFormat` gives a paragraph that joins a list; None puts them back while +/// they still hold it. +fn pick(d: &mut Dialog, list: &str, key: Option<&str>) { + d.fields.insert("list".into(), json!(list)); + if let Some(key) = key { + d.fields.insert(list.into(), json!(key)); + } + let initial = d.fields.get(INITIAL).cloned().unwrap_or_default(); + let was = |k: &str| initial.get(k).and_then(Value::as_f64); + let now = |d: &Dialog| (d.fields.get("leftIndent").and_then(Value::as_f64), d.fields.get("firstLineIndent").and_then(Value::as_f64)); + let hanging = (Some(LIST_HANGING_INDENT), Some(-LIST_HANGING_INDENT)); + if list != "none" { + let unlisted = initial.get("list").and_then(Value::as_str) == Some("none"); + if unlisted && (was("leftIndent"), was("firstLineIndent")) == (Some(0.0), Some(0.0)) && now(d) == (Some(0.0), Some(0.0)) { + d.fields.insert("leftIndent".into(), json!(LIST_HANGING_INDENT)); + d.fields.insert("firstLineIndent".into(), json!(-LIST_HANGING_INDENT)); + d.fields.insert(HANGING.into(), json!(true)); + } + } else if d.fields.remove(HANGING).is_some() && now(d) == hanging { + d.fields.insert("leftIndent".into(), json!(0.0)); + d.fields.insert("firstLineIndent".into(), json!(0.0)); + } +} + +/// The Position group: Alignment, Left Indent and First Line Indent. +fn position(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) { + widgets::section_header(ui, tl!("Position")); + egui::Grid::new("bullets-numbering-position").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + label(ui, tl!("Alignment:")); + let names = [tl!("Left"), tl!("Right")]; + let current = d.fields.get("listAlign").and_then(Value::as_str).and_then(|a| ALIGNS.iter().position(|x| *x == a)); + // Already translated: shown as they are. + if let Some(i) = widgets::dropdown_names(ui, ("bullets-numbering", "listAlign"), current.map_or("", |i| names[i]), &names, 100.0) + && let Some(a) = ALIGNS.get(i) + { + d.fields.insert("listAlign".into(), json!(a)); + } + ui.end_row(); + label(ui, tl!("Left Indent:")); + form::length_within(ui, d, "leftIndent", unit, 90.0, Some(INDENTS)); + ui.end_row(); + label(ui, tl!("First Line Indent:")); + form::length_within(ui, d, "firstLineIndent", unit, 90.0, Some(INDENTS)); + ui.end_row(); + }); +} + +/// `text.setFormat` parameters: the fields that differ from the values the dialog opened with, +/// on the dialog's type and range. With a preset that puts the paragraphs in a list, the indents +/// the dialog shows go too, so that the hanging indent `text.setFormat` gives a paragraph joining a +/// list does not replace them. Alignment and Level go only when the paragraphs are in a list or +/// the preset puts them in one. +fn params(d: &Dialog) -> Map { + let initial = d.fields.get(INITIAL).and_then(Value::as_object); + let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_str); + let field = |key: &str| d.fields.get(key).and_then(Value::as_str); + let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default(); + match field("list") { + Some("none") if was("list") != Some("none") => { + p.insert("list".into(), json!("none")); + } + Some(kind @ ("bullet" | "number")) => match field(kind) { + Some(key) if was("list") != Some(kind) || was(kind) != Some(key) => { + p.insert(kind.into(), json!(key)); + } + None if was("list") != Some(kind) => { + p.insert("list".into(), json!(kind)); + } + _ => {} + }, + _ => {} + } + let listing = ["bullet", "number"].iter().any(|k| p.contains_key(*k)) || p.get("list").is_some_and(|l| l.as_str() != Some("none")); + // `text.setFormat` sets `listAlign` only on paragraphs in a list. + if let Some(a) = field("listAlign") + && was("listAlign") != Some(a) + && (listing || was("list") != Some("none")) + { + p.insert("listAlign".into(), json!(a)); + } + // …and `listLevel` too. + if let Some(l) = d.fields.get("listLevel").and_then(Value::as_f64) + && initial.and_then(|m| m.get("listLevel")).and_then(Value::as_f64) != Some(l) + && (listing || was("list") != Some("none")) + { + p.insert("listLevel".into(), json!(l)); + } + // For paragraphs in a list and paragraphs in none, an indent left as it was is not sent: + // `text.setFormat` then gives each paragraph that joins the list its hanging indent. + let mixed = initial.is_some_and(|m| !m.contains_key("list")); + for key in ["leftIndent", "firstLineIndent"] { + if !d.fields.contains_key(key) { + continue; + } + // A length typed with its unit ("6 mm") is sent in points. + let v = d.f64(key, 0.0); + let changed = initial.and_then(|m| m.get(key)).and_then(Value::as_f64) != Some(v); + if changed || (listing && !mixed) { + p.insert(key.into(), json!(v)); + } + } + p +} + +/// Does `p` change anything (more than the targets)? +fn changes(p: &Map) -> bool { + p.keys().any(|k| !matches!(k.as_str(), "ids" | "start" | "end")) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let p = params(d); + if !changes(&p) { + if app.session.in_interaction() { + let _ = app.session.cancel_interaction(); + } + app.ui.dialog = None; + return Ok(Value::Null); + } + form::commit_preview(app, CMD, Value::Object(p)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::text::{Bullet, ListAlign}; + use vectorcraft_engine::Session; + use vectorcraft_engine::doc::{NodeId, NodeKind, ParaStyle}; + + /// A selected area type object with two paragraphs. + fn app() -> (VectorcraftApp, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + (app, id) + } + + fn paras(app: &VectorcraftApp, id: u64) -> (ParaStyle, ParaStyle) { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), + _ => panic!("not text"), + } + } + + /// The first paragraph's list and indents. + fn first(app: &VectorcraftApp, id: u64) -> (Option, f64, f64) { + let p = paras(app, id).0; + (p.list, p.left_indent, p.first_line_indent) + } + + 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 set(app: &mut VectorcraftApp, key: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v); + } + + fn pick_preset(app: &mut VectorcraftApp, list: &str, key: Option<&str>) { + pick(app.ui.dialog.as_mut().unwrap(), list, key); + } + + /// `ui.bulletsAndNumbering` opens on the selected type's values; a preset, Alignment and the + /// indents show on the type at once, and OK keeps them as one `text.setFormat`, which one Undo + /// takes back. + #[test] + fn ok_applies_the_marker_alignment_and_indents_as_one_undo_step() { + let (mut app, id) = app(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let d = app.ui.dialog.clone().expect("Bullets and Numbering opened"); + assert_eq!((d.kind.as_str(), d.str("tab"), d.str("list"), d.str("listAlign")), (KIND, "bullets".into(), "none".into(), "left".into())); + assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0)); + pick_preset(&mut app, "bullet", Some("square")); + set(&mut app, "listAlign", json!("right")); + set(&mut app, "leftIndent", json!(24)); + set(&mut app, "firstLineIndent", json!(-12)); + frame(&mut app); + assert!(app.session.in_interaction(), "the preview runs"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "the preview adds no undo step"); + let want = Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right)); + assert_eq!(first(&app, id), (want, 24.0, -12.0), "previewed on the type"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert_eq!((first(&app, id), paras(&app, id).1.list), ((want, 24.0, -12.0), want)); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(first(&app, id), (None, 0.0, 0.0)); + // The Numbers tab's presets make a numbered list, keeping the alignment the dialog shows. + app.run("text.setFormat", json!({"list": "bullet", "listAlign": "right"})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({"tab": "numbers"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("tab"), "numbers"); + pick_preset(&mut app, "number", Some("upperRoman")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::UpperRoman).aligned(ListAlign::Right))); + // OK with nothing changed adds no undo step, and an unknown tab is an error. + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("tab"), "numbers", "a numbered list opens on Numbers"); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + assert!(app.run("ui.bulletsAndNumbering", json!({"tab": "levels"})).is_err()); + assert!(app.ui.dialog.is_none()); + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(app.run("ui.bulletsAndNumbering", json!({})).is_err(), "no type selected"); + } + + /// Cancel puts back what the preview changed: the list, its alignment and the indents. + #[test] + fn cancel_puts_the_type_back_as_it_was() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"number": "arabic"})).unwrap(); + let before = first(&app, id); + assert_eq!(before, (Some(ParaList::numbered(NumberStyle::Arabic)), 18.0, -18.0)); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "number", Some("lowerLetters")); + set(&mut app, "listAlign", json!("right")); + set(&mut app, "leftIndent", json!(30)); + frame(&mut app); + assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::LowerLetters).aligned(ListAlign::Right)), 30.0, -18.0)); + // Another change previews again, still without an undo step. + set(&mut app, "leftIndent", json!(36)); + frame(&mut app); + assert_eq!(first(&app, id).1, 36.0); + set(&mut app, "leftIndent", json!(30)); + frame(&mut app); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "the preview adds no undo step"); + // Changed back in the dialog, the type is as it was. + set(&mut app, "listAlign", json!("left")); + set(&mut app, "leftIndent", json!(18)); + pick_preset(&mut app, "number", Some("arabic")); + frame(&mut app); + assert!(!app.session.in_interaction(), "no preview while nothing changes"); + assert_eq!(first(&app, id), before); + pick_preset(&mut app, "none", None); + frame(&mut app); + assert_eq!(first(&app, id).0, None, "None previews the list off"); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!(first(&app, id), before); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "Cancel leaves no undo step"); + } + + /// A preset picked for paragraphs in no list and without indents fills in the 18 pt and + /// -18 pt hanging indent, and None takes it out again; indents that are set stay, and so do + /// indents set back to 0 with the preset. + #[test] + fn a_preset_fills_in_the_hanging_indent_a_new_list_gets() { + let (mut app, id) = app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "bullet", Some("dot")); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.f64("leftIndent", 0.0), d.f64("firstLineIndent", 0.0)), (18.0, -18.0)); + pick_preset(&mut app, "number", Some("arabic")); + pick_preset(&mut app, "none", None); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0)); + assert!(!changes(¶ms(&d))); + super::super::cancel(&mut app); + app.run("text.setFormat", json!({"leftIndent": 10})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "bullet", Some("dash")); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("leftIndent", 0.0), 10.0); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id), (Some(ParaList::bulleted(Bullet::Dash)), 10.0, 0.0)); + // Indents set back to 0 with the preset are kept: the hanging indent does not replace them. + let (mut app, id) = self::app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "number", Some("arabic")); + set(&mut app, "leftIndent", json!(0)); + set(&mut app, "firstLineIndent", json!(0)); + frame(&mut app); + assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::Arabic)), 0.0, 0.0), "previewed as shown"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::Arabic)), 0.0, 0.0)); + } + + /// Paragraphs that differ leave the preset unpicked and the fields blank, and OK sends only + /// what was changed: each paragraph keeps its own indents and list. + #[test] + fn differing_paragraphs_show_blank_fields_and_keep_what_is_left_alone() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 30, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + for key in ["list", "bullet", "leftIndent"] { + assert!(!d.fields.contains_key(key), "{key} is blank"); + } + assert_eq!((d.str("listAlign"), d.f64("firstLineIndent", 1.0)), ("left".into(), 0.0)); + set(&mut app, "listAlign", json!("right")); + super::super::confirm(&mut app).unwrap(); + let (one, two) = paras(&app, id); + assert_eq!((one.list, one.left_indent), (Some(ParaList::bulleted(Bullet::Circle).aligned(ListAlign::Right)), 30.0)); + assert_eq!((two.list, two.left_indent), (None, 0.0), "the paragraph in no list stays out of one"); + } + + /// A preset picked for a list paragraph and a paragraph in no list, both without indents, gives + /// the paragraph that joins the list the hanging indent, and the list paragraph keeps its indents. + #[test] + fn a_preset_for_listed_and_unlisted_paragraphs_indents_the_one_joining_the_list() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 0, "firstLineIndent": 0, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert!(!d.fields.contains_key("list"), "the list kinds differ"); + assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0)); + pick_preset(&mut app, "bullet", Some("dot")); + frame(&mut app); + assert_eq!(paras(&app, id).1.left_indent, 18.0, "previewed with the hanging indent"); + super::super::confirm(&mut app).unwrap(); + let (one, two) = paras(&app, id); + let dot = Some(ParaList::bulleted(Bullet::Dot)); + assert_eq!((one.list, one.left_indent, one.first_line_indent), (dot, 0.0, 0.0)); + assert_eq!((two.list, two.left_indent, two.first_line_indent), (dot, 18.0, -18.0)); + } + + /// Alignment changed for paragraphs that stay in no list sends nothing: there is no preview, + /// and OK adds no undo step. A preset picked with it takes the alignment. + #[test] + fn alignment_alone_on_paragraphs_in_no_list_adds_no_undo_step() { + let (mut app, id) = app(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listAlign", json!("right")); + assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap()))); + frame(&mut app); + assert!(!app.session.in_interaction(), "no preview"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "no undo step"); + assert_eq!(first(&app, id), (None, 0.0, 0.0)); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listAlign", json!("right")); + pick_preset(&mut app, "bullet", Some("dot")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))); + } + + /// An indent typed past the 1296 pt `text.setFormat` keeps becomes 1296 pt (or -1296 pt) in the + /// field, so the field shows the indent the preview and OK give the type. + #[test] + fn an_indent_typed_out_of_range_shows_the_indent_the_type_gets() { + let (mut app, id) = app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + // Each frame returns where the label `find` was drawn. + let frame = |app: &mut VectorcraftApp, events: Vec, find: &str| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + out.shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == find => Some(t.galley.rect.translate(t.pos.to_vec2())), + _ => None, + }) + }; + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + frame(&mut app, vec![], ""); + // The fields' column starts after the longest label, First Line Indent's. + let x = frame(&mut app, vec![], "First Line Indent:").expect("First Line Indent is drawn").right() + 30.0; + // Click the field beside the label `name` (unless `click` is false, as for a field the Tab + // before it reached, which has the keyboard focus with its text selected), type `text` over + // it and Tab to commit it. + let type_into = |app: &mut VectorcraftApp, name: &str, text: &str, click: bool| { + let label = frame(app, vec![], name).unwrap_or_else(|| panic!("{name} is drawn")); + let at = egui::pos2(x, label.center().y); + let press = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + if click { + frame(app, vec![egui::Event::PointerMoved(at)], ""); + frame(app, vec![press(true), press(false)], ""); + } + frame(app, vec![], ""); + frame(app, vec![egui::Event::Text(text.into())], ""); + frame(app, vec![key(egui::Key::Tab)], ""); + frame(app, vec![], ""); + }; + type_into(&mut app, "Left Indent:", "2000 pt", true); + type_into(&mut app, "First Line Indent:", "-5000 pt", false); + let d = app.ui.dialog.clone().expect("the dialog stays open"); + assert_eq!((d.f64("leftIndent", 0.0), d.f64("firstLineIndent", 0.0)), (1296.0, -1296.0)); + assert_eq!(first(&app, id), (None, 1296.0, -1296.0), "previewed as shown"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id), (None, 1296.0, -1296.0)); + } + + /// `ui.dialog.set` sets the fields the dialog does not hold: a bullet or a number for type in + /// no list, and the fields left blank where the paragraphs differ. + #[test] + fn the_control_channel_sets_fields_the_dialog_does_not_hold() { + let ctx = egui::Context::default(); + let (mut app, id) = app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + for key in ["bullet", "number"] { + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key(key), "{key} is not held"); + } + let fields = json!([{"field": "list", "value": "number"}, {"field": "number", "value": "upperRoman"}]); + crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"fields": fields})); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::UpperRoman))); + app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 30, "firstLineIndent": -6, "start": 0, "end": 0})).unwrap(); + app.run("text.setFormat", json!({"ids": [id], "listAlign": "right", "listLevel": 2, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let held = app.ui.dialog.as_ref().unwrap().fields.clone(); + for key in INPUTS { + assert!(!held.contains_key(key), "{key} is blank"); + } + let fields = json!([ + {"field": "list", "value": "bullet"}, + {"field": "bullet", "value": "square"}, + {"field": "listAlign", "value": "left"}, + {"field": "listLevel", "value": 3}, + {"field": "leftIndent", "value": 24}, + {"field": "firstLineIndent", "value": -12}, + ]); + crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"fields": fields})); + super::super::confirm(&mut app).unwrap(); + let (one, two) = paras(&app, id); + let square = Some(ParaList::bulleted(Bullet::Square).at_level(3)); + assert_eq!((one.list, one.left_indent, one.first_line_indent), (square, 24.0, -12.0)); + assert_eq!((two.list, two.left_indent, two.first_line_indent), (square, 24.0, -12.0)); + } + + /// The Level field opens on the paragraphs' list level (1 for paragraphs in no list, blank + /// where they differ), shows a change on the type at once, and OK sends it, with a preset or + /// alone. + #[test] + fn the_level_field_sets_the_list_level() { + let (mut app, id) = app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("listLevel", 0.0), 1.0, "in no list: level 1"); + pick_preset(&mut app, "number", Some("arabic")); + set(&mut app, "listLevel", json!(3)); + frame(&mut app); + assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::Arabic).at_level(3)), "previewed on the type"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(paras(&app, id).1.list, Some(ParaList::numbered(NumberStyle::Arabic).at_level(3))); + app.run("text.setFormat", json!({"ids": [id], "listLevel": 5, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("listLevel"), "levels 5 and 3 differ: blank"); + super::super::cancel(&mut app); + app.run("text.setFormat", json!({"ids": [id], "listLevel": 5})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("listLevel", 0.0), 5.0); + assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap())), "nothing changed yet"); + set(&mut app, "listLevel", json!(2)); + assert_eq!(params(app.ui.dialog.as_ref().unwrap()).get("listLevel"), Some(&json!(2.0))); + super::super::confirm(&mut app).unwrap(); + assert_eq!((first(&app, id).0.map(|l| l.level), paras(&app, id).1.list.map(|l| l.level)), (Some(2), Some(2))); + } + + /// `text.setFormat` sets `listLevel` only on paragraphs in a list, so a Level change alone on + /// paragraphs in no list sends nothing and adds no undo step; with a preset it applies. + #[test] + fn level_alone_on_paragraphs_in_no_list_adds_no_undo_step() { + let (mut app, id) = app(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listLevel", json!(3)); + assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap()))); + frame(&mut app); + assert!(!app.session.in_interaction(), "no preview"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "no undo step"); + assert_eq!(first(&app, id), (None, 0.0, 0.0)); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listLevel", json!(3)); + pick_preset(&mut app, "bullet", Some("dot")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::bulleted(Bullet::Dot).at_level(3))); + } + + /// The presets are radio buttons named for screen readers, the one the dialog applies chosen; + /// a click picks one, and ← and → move the choice from the focused one. + #[test] + fn the_presets_are_named_radio_buttons_that_the_arrow_keys_move_along() { + use egui::accesskit::{Role, Toggled}; + let names = ["None", "Filled Dot", "Dash"]; + let tree = crate::widgets::accessibility_tests::tree(|ui| { + let _ = preset_row(ui, "test", &names, Some(1), |_, _, _, _| {}); + }); + assert_eq!(crate::widgets::accessibility_tests::role_and_state(&tree, "Filled Dot"), (Role::RadioButton, Some(Toggled::True))); + assert_eq!(crate::widgets::accessibility_tests::role_and_state(&tree, "Dash"), (Role::RadioButton, Some(Toggled::False))); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let origin = std::cell::Cell::new(egui::Pos2::ZERO); + let run = |events: Vec| { + let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + let mut picked = None; + let mut out = ctx.run_ui(input, |ui| { + origin.set(ui.cursor().min); + picked = preset_row(ui, "test", &names, Some(1), |_, _, _, _| {}); + }); + out.textures_delta.clear(); + picked + }; + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + assert_eq!(run(vec![]), None); + assert_eq!(run(vec![key(egui::Key::ArrowRight)]), None, "no preset has the focus"); + ctx.memory_mut(|m| m.request_focus(egui::Id::unique(("bullets-numbering-preset", "test", 1usize)))); + run(vec![]); + assert_eq!(run(vec![key(egui::Key::ArrowRight)]), Some(2)); + assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), Some(1), "the focus moved to Dash"); + assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), Some(0)); + assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), None, "None is the first"); + // The middle of the third button, two buttons and their 4 pt gaps along. + let at = origin.get() + egui::vec2(PRESET * 2.5 + 8.0, PRESET * 0.5); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + run(vec![egui::Event::PointerMoved(at)]); + assert_eq!(run(vec![click(true), click(false)]), Some(2)); + } +} diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index eef4787ee..eebb29018 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -6,14 +6,16 @@ //! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live //! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs //! at once. A heavy filter (its manifest's `budget`) has no Preview, since a run may take minutes: -//! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export › +//! it runs in the background ([`crate::plugin_runs`]). A filter or live effect with commands of +//! its own is a submenu named after it, holding its item and then its commands; a command with +//! parameters opens the same dialog for them ([`open_command`]). Exporters are formats of File › Export › //! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its //! Export button writes the file. use std::sync::Arc; use serde_json::{Map, Value, json}; -use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, registry}; +use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, permissions, registry}; use super::{DialogSpec, form}; use crate::VectorcraftApp; @@ -38,12 +40,30 @@ fn label(name: &str, has_params: bool) -> &'static str { registry::intern(&format!("{name}{dots}")) } -/// Object › Plug-ins: the installed object filters, then Install Plug-in… and Reload Plug-ins. +/// A filter or live effect `plugin`'s item `own`, as the menu lists it: alone, or for a plug-in +/// with commands of its own, a submenu named after it holding `own` and then its commands. Each +/// command runs its command id (`plugin..`), with an ellipsis when it has parameters, +/// whose dialog [`open_command`] opens. +fn with_commands(plugin: &Plugin, own: Item) -> Item { + let m = plugin.manifest(); + if m.commands.is_empty() { + return own; + } + let mut items = vec![own, Item::Sep]; + items.extend(m.commands.iter().map(|c| { + let id = registry::intern(&vectorcraft_engine::cmd::plugin::command_id(&m.id, &c.id)); + Item::Cmd(label(&c.shown_label(), !c.params.is_empty()), id, Value::Null) + })); + Item::Sub(registry::intern(&permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME)), items) +} + +/// Object › Plug-ins: the installed object filters (each with its commands, if it has any), then +/// Install Plug-in… and Reload Plug-ins. pub fn object_menu() -> Vec { let mut items: Vec = registry::list() .iter() .filter(|p| p.manifest().kind == Kind::Filter) - .map(|p| Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() }))) + .map(|p| with_commands(p, Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() })))) .collect(); if items.is_empty() { items.push(Item::Header(NO_FILTERS)); @@ -56,14 +76,20 @@ pub fn object_menu() -> Vec { items } -/// Effect › Plug-ins: the live-effect plug-ins (`None` while none is installed). Effects without -/// options apply at once, as built-in ones do. +/// Effect › Plug-ins: the live-effect plug-ins, each with its commands if it has any (`None` +/// while none is installed). Effects without options apply at once, as built-in ones do. pub fn effect_menu() -> Option { let items: Vec = vectorcraft_effects::plugin_effects() .into_iter() - .map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { - true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), - false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + .map(|e| { + let own = match e.defaults.as_object().is_some_and(|o| o.is_empty()) { + true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), + false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + }; + match vectorcraft_plugins::effect::plugin_id(e.id).and_then(registry::get) { + Some(plugin) => with_commands(&plugin, own), + None => own, + } }) .collect(); (!items.is_empty()).then_some(Item::Sub("Plug-ins", items)) @@ -113,8 +139,44 @@ fn described(ui: &mut egui::Ui, d: &Dialog) -> Option> { Some(plugin) } +/// Opens the dialog of plug-in command `id` (`plugin..`), given no params, +/// when it has parameters and a document is open: its fields at their defaults (`__command` is the +/// command id) with a live Preview, and OK keeps the run as one undo step. False for any other +/// command, which then runs as it was given. +pub fn open_command(app: &mut VectorcraftApp, id: &str) -> bool { + let Some((plugin, c)) = vectorcraft_engine::cmd::plugin::command_of(id) else { return false }; + if c.params.is_empty() || app.session.active().is_none() { + return false; + } + let mut fields = fields_for(&plugin.manifest().for_command(&c)); + fields.insert("__command".into(), json!(id)); + fields.insert("__label".into(), json!(c.shown_label())); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + true +} + +/// The plug-in command dialog `d` is for (`__command`), and the manifest it runs with; `None` for +/// a filter's own dialog. +fn command_of(d: &Dialog) -> Option { + let (plugin, c) = vectorcraft_engine::cmd::plugin::command_of(&d.str("__command"))?; + Some(plugin.manifest().for_command(&c)) +} + +/// The command dialog `d` runs: its plug-in command, or `plugin.run`. +fn command(d: &Dialog) -> String { + match d.str("__command") { + c if c.is_empty() => CMD.to_string(), + c => c, + } +} + +/// The params of the command dialog `d` runs ([`command`]). fn params(d: &Dialog) -> Value { - json!({"id": d.str("__plugin"), "params": form::params(d)}) + match d.str("__command").is_empty() { + true => json!({"id": d.str("__plugin"), "params": form::params(d)}), + false => json!({"params": form::params(d)}), + } } /// Runs filter `plugin` with `plugin.run`'s params `p`: at once, or in the background when it is @@ -126,15 +188,20 @@ fn run(app: &mut VectorcraftApp, plugin: &Plugin, p: Value) -> Result bool { let Some(plugin) = described(ui, d) else { return true }; - form::schema_fields(app, ui, d, plugin.manifest()); + let run_with = command_of(d); + if run_with.is_none() && !d.str("__command").is_empty() { + return true; + } + form::schema_fields(app, ui, d, run_with.as_ref().unwrap_or(plugin.manifest())); match plugin.manifest().budget { Budget::Light => { let (label, p) = (d.str("__label"), params(d)); - form::preview(app, ui, d, &label, CMD, p); + form::preview(app, ui, d, &label, &command(d), p); } // No Preview: each run may take minutes. Budget::Heavy => { @@ -157,7 +224,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } r } - _ => form::commit_preview(app, CMD, params(d)), + _ => form::commit_preview(app, &command(d), params(d)), } } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 5794ae7e4..410dd4930 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -657,6 +657,7 @@ impl PluginIntField { params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), + commands: Vec::new(), }; let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO }; fn number_rect(shape: &egui::Shape, text: &str) -> Option { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 75f2637a4..d58c52014 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -184,6 +184,10 @@ Anti-Alias Заглаждане Anti-alias Rasters Заглаждане на растери Anti-aliased Artwork Загладено произведение + Anti-aliasing Method Метод на заглаждане +antiAlias Sharp Остро + Crisp Четко + Strong Силно Anti-aliasing: Заглаждане: Any Kind Всякакъв вид Font Technology Технология на шрифта @@ -354,6 +358,13 @@ font-technology Variable Променливи Apply Bullets Прилагане на булети Numbered List Номериран списък Apply Numbers Прилагане на номериране + Bullets and Numbering… Булети и номериране… + Numbers Номера + Level: Ниво: + Bullet Character: Знак за булет: + Number Style: Стил на номерата: + Left Indent: Ляв отстъп: + First Line Indent: Отстъп на първия ред: Convert to Text Конвертиране в текст Filled Dot Плътна точка Dash Тире @@ -3987,6 +3998,7 @@ axis Both И двете @msg Targa files are RGB: colorModel rgb Файловете Targa са RGB: colorModel rgb @msg Text Wrap: select a wrap object Обгръщане на текст: изберете обгръщащ обект @msg This shortcut file is in a form VectorCraft can't read. Този файл с преки пътища е във формат, който VectorCraft не може да прочете. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` трябва да е "none", "sharp", "crisp" или "strong" @msg `aspect` must be a number (%) `aspect` трябва да бъде число (%) @msg `at` must be [x, y] `at` трябва да бъде [x, y] @msg `color` must be a colour `color` трябва да бъде цвят @@ -6259,6 +6271,16 @@ breakText Glyphs Глифи @msg path must be a list of child indices path трябва да е списък с индекси на дъщерни обекти @msg no object at path {shown} in the symbol's art в графиката на символа няма обект на пътя {shown} @msg give fill, stroke, opacity and/or blend задайте fill, stroke, opacity и/или blend +@msg {key} cannot be set on a guide {key} не може да се задава за водач +@msg {key} cannot be set on a blend {key} не може да се задава за преливане +@msg {key} cannot be set on an envelope {key} не може да се задава за плик +@msg {key} cannot be set on a gradient mesh {key} не може да се задава за мрежа с градиент +@msg {key} cannot be set on a Repeat {key} не може да се задава за повтаряне +@msg {key} cannot be set on a placed document {key} не може да се задава за вмъкнат документ +@msg {key} cannot be set on an Intertwine group {key} не може да се задава за група с преплитане +@msg stroke cannot be set on a symbol instance stroke не може да се задава за екземпляр на символ +@msg fill on a symbol instance must be a solid color or none fill на екземпляр на символ трябва да е плътен цвят или none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} не може да се задава за изображение без ефект „Конвертиране в фигура“ или „Преобразуване на обекта в контур“ @msg pass newName, registration, exportType, nineSlice and/or type задайте newName, registration, exportType, nineSlice и/или type Text Flow Поток на текста By Rows По редове @@ -6288,6 +6310,7 @@ breakText Glyphs Глифи Create Swatch Legend Създаване на легенда на образците Create Swatch Legend… Създаване на легенда на образците… HEX HEX + Help Bar Лента за помощ # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Инструменти за текст Text Tweaker… Настройка на текста… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 4eadaff15..b172afb94 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -332,6 +332,12 @@ Apply Bullets Primijeni oznake Numbered List Numerisana lista Apply Numbers Primijeni brojeve + Bullets and Numbering… Oznake i numerisanje… + Numbers Brojevi + Bullet Character: Znak oznake: + Number Style: Stil broja: + Left Indent: Lijeva uvlaka: + First Line Indent: Uvlaka prvog reda: Convert to Text Pretvori u tekst Filled Dot Ispunjena tačka Dash Crtica @@ -2732,6 +2738,10 @@ digits Malayalam Malajalam < 1 sec < 1 sek. [Unnamed] [Bez naziva] Amount: Količina: + Anti-aliasing Method Metoda zaglađivanja ivica +antiAlias Sharp Oštro + Crisp Jasno + Strong Snažno Anti-aliasing: Zaglađivanje ivica: Artboard: Radna ploča: Color Table Tabela boja @@ -4354,6 +4364,7 @@ imageTrace Outline Obris @msg Split Into Grid: select paths large enough for the grid Podijeli u mrežu: odaberite putanje dovoljno velike za mrežu @msg Targa files are RGB: colorModel rgb Targa datoteke su RGB: colorModel rgb @msg Text Wrap: select a wrap object Omotavanje teksta: odaberite objekat omotavanja +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` mora biti "none", "sharp", "crisp" ili "strong" @msg `aspect` must be a number (%) `aspect` mora biti broj (%) @msg `at` must be [x, y] `at` mora biti [x, y] @msg `color` must be a colour `color` mora biti boja @@ -6309,6 +6320,16 @@ shape Open Otvoren @msg path must be a list of child indices path mora biti lista indeksa podređenih objekata @msg no object at path {shown} in the symbol's art nema objekta na putanji {shown} u grafici simbola @msg give fill, stroke, opacity and/or blend navedite fill, stroke, opacity i/ili blend +@msg {key} cannot be set on a guide {key} se ne može postaviti na vodilicu +@msg {key} cannot be set on a blend {key} se ne može postaviti na stapanje +@msg {key} cannot be set on an envelope {key} se ne može postaviti na omotnicu +@msg {key} cannot be set on a gradient mesh {key} se ne može postaviti na gradijentnu mrežu +@msg {key} cannot be set on a Repeat {key} se ne može postaviti na ponavljanje +@msg {key} cannot be set on a placed document {key} se ne može postaviti na postavljeni dokument +@msg {key} cannot be set on an Intertwine group {key} se ne može postaviti na Intertwine grupu +@msg stroke cannot be set on a symbol instance stroke se ne može postaviti na instancu simbola +@msg fill on a symbol instance must be a solid color or none fill instance simbola mora biti puna boja ili none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} se ne može postaviti na sliku bez efekta „Pretvori u oblik“ ili „Objekat u obrisu“ @msg pass newName, registration, exportType, nineSlice and/or type navedite newName, registration, exportType, nineSlice i/ili type Text Flow Tok teksta By Rows Po redovima @@ -6338,6 +6359,7 @@ shape Open Otvoren Create Swatch Legend Kreiraj legendu uzoraka boja Create Swatch Legend… Kreiraj legendu uzoraka boja… HEX HEX + Help Bar Traka pomoći # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Alati za tekst Text Tweaker… Podešavanje teksta… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index d598de47b..a0baae367 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -299,6 +299,10 @@ Anti-alias Rasters Antialiàsing de ràsters Anti-aliased Artwork Art amb antialiàsing Anti-aliasing: Antialiàsing: + Anti-aliasing Method Mètode d'antialiàsing +antiAlias Sharp Nítid + Crisp Definit + Strong Intens Any except extracting pages Qualsevol excepte extreure pàgines Any Kind Qualsevol tipus Appearance Aparença @@ -551,8 +555,10 @@ Built-in presets don't change: New… starts an editable copy. Els preajustos integrats no canvien: Nou… inicia una còpia editable. Bulge… Abombament… Bullet Punt de llista + Bullet Character: Caràcter del punt de llista: Bullets Punts de llista Bullets and Numbering Punts i numeració + Bullets and Numbering… Punts i numeració… Burasagari Burasagari Burlap Arpillera Business Card 3.5 × 2 in Targeta de visita 3,5 × 2 in @@ -1597,6 +1603,7 @@ digits Malayalam Malaiàlam First Artboard Primer tauler First Baseline Primera línia de base First commit Primera confirmació + First Line Indent: Sagnia de primera línia: First Page Primera pàgina First-line Left Indent Sagnia esquerra de primera línia Fisheye… Ull de peix… @@ -2170,6 +2177,7 @@ digits Malayalam Malaiàlam Left Grid: Quadrícula esquerra: Left in Front Esquerra al davant Left Indent Sagnia esquerra + Left Indent: Sagnia esquerra: Left Plane Pla esquerre Left to Right D’esquerra a dreta Left-Justified Tab Tabulació alineada a l’esquerra @@ -2697,8 +2705,10 @@ digits Malayalam Malaiàlam Number of Decimals: Nombre de decimals: Number of Recent Files to Display Nombre de fitxers recents que es mostraran Number of Recent Fonts Nombre de tipus de lletra recents + Number Style: Estil de número: Number: Número: Numbered List Llista numerada + Numbers Números Numbers Without Units Are Points Els nombres sense unitats són punts Object Objecte Object Highlighting Ressaltat d’objectes @@ -4895,6 +4905,7 @@ digits Malayalam Malaiàlam 3d Solid Sòlid 3d Space Espai @msg "{0}" is not a colour: write it as #rrggbb "{0}" no és un color: escriviu-lo com a #rrggbb +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` ha de ser "none", "sharp", "crisp" o "strong" @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un interval com ara 1, 3-5, no {v} @msg `aspect` must be a number (%) `aspect` ha de ser un nombre (%) @msg `at` must be [x, y] `at` ha de ser [x, y] @@ -5695,6 +5706,11 @@ digits Malayalam Malaiàlam @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imatge fa {_1} × {_2} píxels, més de {LARGE_TRACE_SIDE} per costat: es traça, però més lentament. Un preajust de menys fidelitat o una còpia més petita de la imatge es traça més de pressa. @msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) la imatge fa {width} × {height} píxels, més del que admet Image Trace ({max} megapíxels) @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imatge s’ha traçat a {_1} × {_2} píxels, reduïda des de {_3} × {_4} ({_5} megapíxels com a màxim). +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. El mode Logotip pla no fa servir keepColors: traça els colors indicats a logoColors o els colors que troba. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). No s’ha traçat res: cap dels colors que ha trobat el traçat coincideix amb keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. No s’ha traçat res: Ignora el color omet totes les àrees dels colors que llista keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. No s’ha traçat res: els colors que coincideixen amb keepColors no produeixen cap traçat. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: cap dels colors que ha trobat el traçat coincideix amb {c}, per tant no se’n traça res. @msg the image's pixels can't be read no es poden llegir els píxels de la imatge @msg the image's pixels don't match its size els píxels de la imatge no coincideixen amb la seva mida @msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set L’últim inici de VectorCraft va acabar a {name} abans que aparegués la seva finestra, així que s’ha iniciat amb {summary}. Per tornar a provar aquell processador gràfic, canvia Preferències › Rendiment › Processador gràfic o inicia amb WGPU_BACKEND definit @@ -5914,6 +5930,14 @@ digits Malayalam Malaiàlam @msg unknown permission `{shown}` permís desconegut `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a «{name}» no se li ha concedit `{p}`, que la seva sortida necessita: no s’ha canviat res +@msg a heavy filter runs in the background, so it can't declare `commands` un filtre pesat s’executa en segon pla, de manera que no pot declarar `commands` +@msg at most {MAX_COMMANDS} `commands` com a màxim {MAX_COMMANDS} `commands` +@msg command {name} is declared twice l’ordre {name} està declarada dues vegades +@msg command {name}: `label` must be 1-64 printable characters ordre {name}: `label` ha de tenir 1-64 caràcters imprimibles +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ordre {name}: fes servir un id d’1-{MAX_COMMAND_ID} caràcters d’a-z 0-9 _ que comenci per una lletra +@msg command {name}: {e} ordre {name}: {e} +@msg only a filter or a live effect declares `commands` només un filtre o un efecte dinàmic declara `commands` +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res @msg “{name}” is not a file «{name}» no és un fitxer axis Both Tots dos @@ -6425,6 +6449,16 @@ breakText Glyphs Glifs @msg path must be a list of child indices path ha de ser una llista d’índexs de fills @msg no object at path {shown} in the symbol's art no hi ha cap objecte al camí {shown} de l’art del símbol @msg give fill, stroke, opacity and/or blend indiqueu fill, stroke, opacity i/o blend +@msg {key} cannot be set on a guide {key} no es pot definir en una guia +@msg {key} cannot be set on a blend {key} no es pot definir en una fusió +@msg {key} cannot be set on an envelope {key} no es pot definir en un embolcall +@msg {key} cannot be set on a gradient mesh {key} no es pot definir en una malla de degradat +@msg {key} cannot be set on a Repeat {key} no es pot definir en una repetició +@msg {key} cannot be set on a placed document {key} no es pot definir en un document col·locat +@msg {key} cannot be set on an Intertwine group {key} no es pot definir en un grup entrelligat +@msg stroke cannot be set on a symbol instance stroke no es pot definir en una instància de símbol +@msg fill on a symbol instance must be a solid color or none el fill d’una instància de símbol ha de ser un color sòlid o none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} no es pot definir en una imatge sense l’efecte Converteix en forma o Contorneja l’objecte @msg pass newName, registration, exportType, nineSlice and/or type indiqueu newName, registration, exportType, nineSlice i/o type Text Flow Flux del text By Rows Per files @@ -6454,6 +6488,7 @@ breakText Glyphs Glifs Create Swatch Legend Crea una llegenda de mostres Create Swatch Legend… Crea una llegenda de mostres… HEX HEX + Help Bar Barra d’ajuda # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Eines de text Text Tweaker… Ajustador de text… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e24e74929..e0ee01066 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -340,6 +340,13 @@ Apply Bullets Použít odrážky Numbered List Číslovaný seznam Apply Numbers Použít číslování + Bullets and Numbering… Odrážky a číslování… + Numbers Čísla + Level: Úroveň: + Bullet Character: Znak odrážky: + Number Style: Styl číslování: + Left Indent: Odsazení zleva: + First Line Indent: Odsazení prvního řádku: Convert to Text Převést na text Filled Dot Plná tečka Dash Pomlčka @@ -2762,6 +2769,10 @@ digits Malayalam Malajálamské < 1 sec < 1 s [Unnamed] [Bez názvu] Amount: Míra: + Anti-aliasing Method Metoda vyhlazování +antiAlias Sharp Ostré + Crisp Jasné + Strong Silné Anti-aliasing: Vyhlazování: Artboard: Pracovní plocha: Color Table Tabulka barev @@ -4412,6 +4423,7 @@ imageTrace Outline Obrys @msg Split Into Grid: select paths large enough for the grid Rozdělit do mřížky: vyberte cesty dost velké pro mřížku @msg Targa files are RGB: colorModel rgb soubory Targa jsou RGB: colorModel rgb @msg Text Wrap: select a wrap object Obtékání textu: vyberte objekt obtékání +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` musí být "none", "sharp", "crisp" nebo "strong" @msg `aspect` must be a number (%) `aspect` musí být číslo (%) @msg `at` must be [x, y] `at` musí být [x, y] @msg `color` must be a colour `color` musí být barva @@ -5756,6 +5768,11 @@ mark-style Japanese Japonské Snap to Perpendicular Přichytávat ke kolmici @msg unknown snap option `{key}` (use {options}) neznámá možnost přichytávání `{key}` (použijte {options}) Gap Length: Délka mezery: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Režim Ploché logo nepoužívá keepColors: vektorizuje barvy uvedené v logoColors nebo barvy, které najde. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nic nebylo vektorizováno: žádná z barev, které vektorizace našla, neodpovídá keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nic nebylo vektorizováno: Ignorovat barvu vynechává všechny oblasti barev uvedených v keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nic nebylo vektorizováno: z barev odpovídajících keepColors nevznikly žádné cesty. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: žádná z barev, které vektorizace našla, neodpovídá {c}, takže z ní není nic vektorizováno. @msg `colorGroup` needs `library: "document"` `colorGroup` vyžaduje `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` vyžaduje `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` vyžaduje `mode: "color"` @@ -5900,6 +5917,14 @@ axis Y Axis osa Y @msg unknown permission `{shown}` neznámé oprávnění `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ nedostal `{p}`, které jeho výstup potřebuje: nic se nezměnilo +@msg a heavy filter runs in the background, so it can't declare `commands` těžký filtr běží na pozadí, takže nemůže deklarovat `commands` +@msg at most {MAX_COMMANDS} `commands` nejvýše {MAX_COMMANDS} `commands` +@msg command {name} is declared twice příkaz {name} je deklarován dvakrát +@msg command {name}: `label` must be 1-64 printable characters příkaz {name}: `label` musí mít 1-64 tisknutelných znaků +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter příkaz {name}: použijte id o 1-{MAX_COMMAND_ID} znacích z a-z 0-9 _, které začíná písmenem +@msg command {name}: {e} příkaz {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` deklaruje jen filtr nebo živý efekt +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno # Actions panel: action sets (#3712). New Set… Nová sada… @@ -6442,6 +6467,16 @@ breakText Glyphs Glyfy @msg path must be a list of child indices path musí být seznam indexů potomků @msg no object at path {shown} in the symbol's art v kresbě symbolu není na cestě {shown} žádný objekt @msg give fill, stroke, opacity and/or blend zadejte fill, stroke, opacity a/nebo blend +@msg {key} cannot be set on a guide {key} nelze nastavit u vodítka +@msg {key} cannot be set on a blend {key} nelze nastavit u prolnutí +@msg {key} cannot be set on an envelope {key} nelze nastavit u obálky +@msg {key} cannot be set on a gradient mesh {key} nelze nastavit u přechodové sítě +@msg {key} cannot be set on a Repeat {key} nelze nastavit u opakování +@msg {key} cannot be set on a placed document {key} nelze nastavit u umístěného dokumentu +@msg {key} cannot be set on an Intertwine group {key} nelze nastavit u propletené skupiny +@msg stroke cannot be set on a symbol instance stroke nelze nastavit u instance symbolu +@msg fill on a symbol instance must be a solid color or none fill instance symbolu musí být plná barva nebo none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} nelze nastavit u obrazu bez efektu Převést na tvar nebo Převést objekt na obrys @msg pass newName, registration, exportType, nineSlice and/or type zadejte newName, registration, exportType, nineSlice a/nebo type Text Flow Tok textu By Rows Po řádcích @@ -6471,6 +6506,7 @@ breakText Glyphs Glyfy Create Swatch Legend Vytvořit legendu vzorníku Create Swatch Legend… Vytvořit legendu vzorníku… HEX HEX + Help Bar Panel nápovědy # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Nástroje textu Text Tweaker… Ladění textu… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index c3fb0ffbe..e5f51f4d5 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -299,6 +299,13 @@ Apply Bullets Anvend kugletegn Numbered List Nummereret liste Apply Numbers Anvend nummerering + Bullets and Numbering… Kugletegn og nummerering… + Numbers Tal + Level: Niveau: + Bullet Character: Kugletegn: + Number Style: Talformat: + Left Indent: Venstre indrykning: + First Line Indent: Indrykning af første linje: Convert to Text Konvertér til tekst Filled Dot Udfyldt prik Dash Tankestreg @@ -2550,6 +2557,10 @@ digits Malayalam Malayalam < 1 sec < 1 sek [Unnamed] [Unavngiven] Amount: Mængde: + Anti-aliasing Method Antialiaseringsmetode +antiAlias Sharp Skarp + Crisp Klar + Strong Kraftig Anti-aliasing: Antialiasering: Artboard: Tegnebræt: Color Table Farvetabel @@ -4104,6 +4115,7 @@ imageTrace Outline Kontur @msg Split Into Grid: select paths large enough for the grid Opdel i gitter: markér kurver, der er store nok til gitteret @msg Targa files are RGB: colorModel rgb Targa-filer er RGB: colorModel rgb @msg Text Wrap: select a wrap object Tekstombrydning: markér et ombrydningsobjekt +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` skal være "none", "sharp", "crisp" eller "strong" @msg `aspect` must be a number (%) `aspect` skal være et tal (%) @msg `at` must be [x, y] `at` skal være [x, y] @msg `color` must be a colour `color` skal være en farve @@ -6273,6 +6285,16 @@ breakText Glyphs Glyffer @msg path must be a list of child indices path skal være en liste over underordnede indekser @msg no object at path {shown} in the symbol's art intet objekt ved stien {shown} i symbolets grafik @msg give fill, stroke, opacity and/or blend angiv fill, stroke, opacity og/eller blend +@msg {key} cannot be set on a guide {key} kan ikke angives for en hjælpelinje +@msg {key} cannot be set on a blend {key} kan ikke angives for en blanding +@msg {key} cannot be set on an envelope {key} kan ikke angives for en kuvert +@msg {key} cannot be set on a gradient mesh {key} kan ikke angives for et gradientnet +@msg {key} cannot be set on a Repeat {key} kan ikke angives for en gentagelse +@msg {key} cannot be set on a placed document {key} kan ikke angives for et placeret dokument +@msg {key} cannot be set on an Intertwine group {key} kan ikke angives for en sammenflettet gruppe +@msg stroke cannot be set on a symbol instance stroke kan ikke angives for en symbolforekomst +@msg fill on a symbol instance must be a solid color or none fill på en symbolforekomst skal være en ensfarvet farve eller none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kan ikke angives for et billede uden effekten Konvertér til form eller Objekt til kontur @msg pass newName, registration, exportType, nineSlice and/or type angiv newName, registration, exportType, nineSlice og/eller type Text Flow Tekstflow By Rows Efter rækker @@ -6302,6 +6324,7 @@ breakText Glyphs Glyffer Create Swatch Legend Opret farveprøveforklaring Create Swatch Legend… Opret farveprøveforklaring… HEX HEX + Help Bar Hjælpelinje # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Tekstværktøjer Text Tweaker… Tekstjustering… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f83f7f74a..ab642eeb9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -173,6 +173,10 @@ Anti-Alias Glätten Anti-alias Rasters Pixelbilder glätten Anti-aliased Artwork Geglättete Grafik + Anti-aliasing Method Glättungsmethode +antiAlias Sharp Scharf + Crisp Klar + Strong Stark Anti-aliasing: Glättung: Any Kind Beliebige Art Font Technology Schrifttechnologie @@ -342,6 +346,13 @@ font-technology Variable Variabel Apply Bullets Aufzählungszeichen anwenden Numbered List Nummerierte Liste Apply Numbers Nummerierung anwenden + Bullets and Numbering… Aufzählungszeichen und Nummerierung… + Numbers Nummern + Level: Listenebene: + Bullet Character: Aufzählungszeichen: + Number Style: Nummernformat: + Left Indent: Einzug links: + First Line Indent: Einzug erste Zeile: Convert to Text In Text umwandeln Filled Dot Gefüllter Punkt Dash Strich @@ -4017,6 +4028,7 @@ theme Appearance Erscheinungsbild @msg Targa files are RGB: colorModel rgb Targa-Dateien sind RGB: colorModel rgb @msg Text Wrap: select a wrap object Textumfluss: Wählen Sie ein Umflussobjekt aus @msg This shortcut file is in a form VectorCraft can't read. Diese Tastaturbefehlsdatei liegt in einem Format vor, das VectorCraft nicht lesen kann. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` muss "none", "sharp", "crisp" oder "strong" sein @msg `aspect` must be a number (%) `aspect` muss eine Zahl sein (%) @msg `at` must be [x, y] `at` muss [x, y] sein @msg `color` must be a colour `color` muss eine Farbe sein @@ -5335,6 +5347,11 @@ effect Soft Weich Snap to Perpendicular An Senkrechter ausrichten @msg unknown snap option `{key}` (use {options}) unbekannte Einrastoption `{key}` (möglich: {options}) Gap Length: Lückenlänge: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Der Modus „Flaches Logo“ verwendet keepColors nicht: Er zeichnet die in logoColors genannten Farben nach oder die Farben, die er findet. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nichts wurde nachgezeichnet: Keine der beim Nachzeichnen gefundenen Farben entspricht keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nichts wurde nachgezeichnet: „Farbe ignorieren“ lässt jeden Bereich der in keepColors aufgeführten Farben aus. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nichts wurde nachgezeichnet: Die Farben, die keepColors entsprechen, ergeben keine Pfade. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: Keine der beim Nachzeichnen gefundenen Farben entspricht {c}, daher wird davon nichts nachgezeichnet. @msg `colorGroup` needs `library: "document"` `colorGroup` erfordert `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` erfordert `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` erfordert `mode: "color"` @@ -5482,6 +5499,14 @@ axis Y Axis Y-Achse @msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ wurde `{p}` nicht gewährt, das seine Ausgabe braucht: Es wurde nichts geändert +@msg a heavy filter runs in the background, so it can't declare `commands` ein schwerer Filter läuft im Hintergrund und kann daher keine `commands` deklarieren +@msg at most {MAX_COMMANDS} `commands` höchstens {MAX_COMMANDS} `commands` +@msg command {name} is declared twice der Befehl {name} ist doppelt deklariert +@msg command {name}: `label` must be 1-64 printable characters Befehl {name}: `label` muss 1-64 druckbare Zeichen haben +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter Befehl {name}: Verwenden Sie eine ID aus 1-{MAX_COMMAND_ID} Zeichen aus a-z 0-9 _, die mit einem Buchstaben beginnt +@msg command {name}: {e} Befehl {name}: {e} +@msg only a filter or a live effect declares `commands` nur ein Filter oder ein Live-Effekt deklariert `commands` +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert # Actions panel: action sets (#3712). New Set… Neuer Satz… @@ -6028,6 +6053,16 @@ breakText Glyphs Glyphen @msg path must be a list of child indices path muss eine Liste von Kindindizes sein @msg no object at path {shown} in the symbol's art kein Objekt am Pfad {shown} in der Grafik des Symbols @msg give fill, stroke, opacity and/or blend geben Sie fill, stroke, opacity und/oder blend an +@msg {key} cannot be set on a guide {key} kann nicht für eine Hilfslinie gesetzt werden +@msg {key} cannot be set on a blend {key} kann nicht für eine Angleichung gesetzt werden +@msg {key} cannot be set on an envelope {key} kann nicht für eine Verzerrungshülle gesetzt werden +@msg {key} cannot be set on a gradient mesh {key} kann nicht für ein Verlaufsgitter gesetzt werden +@msg {key} cannot be set on a Repeat {key} kann nicht für eine Wiederholung gesetzt werden +@msg {key} cannot be set on a placed document {key} kann nicht für ein platziertes Dokument gesetzt werden +@msg {key} cannot be set on an Intertwine group {key} kann nicht für eine Verflechten-Gruppe gesetzt werden +@msg stroke cannot be set on a symbol instance stroke kann nicht für eine Symbolinstanz gesetzt werden +@msg fill on a symbol instance must be a solid color or none fill einer Symbolinstanz muss eine Vollfarbe oder none sein +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kann nicht für ein Bild ohne den Effekt „In Form umwandeln“ oder „Objekt in Pfade umwandeln“ gesetzt werden @msg pass newName, registration, exportType, nineSlice and/or type geben Sie newName, registration, exportType, nineSlice und/oder type an Text Flow Textfluss By Rows Nach Zeilen @@ -6057,6 +6092,7 @@ breakText Glyphs Glyphen Create Swatch Legend Farbfeldlegende erstellen Create Swatch Legend… Farbfeldlegende erstellen… HEX HEX + Help Bar Hilfeleiste # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Textwerkzeuge Text Tweaker… Text-Tweaker… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9e30d25f9..bc3ee2966 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -178,6 +178,10 @@ Anti-Alias Εξομάλυνση ακμών Anti-alias Rasters Εξομάλυνση ακμών εικόνων ράστερ Anti-aliased Artwork Γραφικά με εξομαλυμένες ακμές + Anti-aliasing Method Μέθοδος εξομάλυνσης ακμών +antiAlias Sharp Οξεία + Crisp Καθαρή + Strong Έντονη Anti-aliasing: Εξομάλυνση ακμών: Any Kind Οποιοσδήποτε τύπος Font Technology Τεχνολογία γραμματοσειράς @@ -342,6 +346,13 @@ font-technology Variable Μεταβλητές Apply Bullets Εφαρμογή κουκκίδων Numbered List Αριθμημένη λίστα Apply Numbers Εφαρμογή αρίθμησης + Bullets and Numbering… Κουκκίδες και αρίθμηση… + Numbers Αριθμοί + Level: Επίπεδο: + Bullet Character: Χαρακτήρας κουκκίδας: + Number Style: Στυλ αρίθμησης: + Left Indent: Αριστερή εσοχή: + First Line Indent: Εσοχή πρώτης γραμμής: Convert to Text Μετατροπή σε κείμενο Filled Dot Γεμάτη κουκκίδα Dash Παύλα @@ -2273,6 +2284,7 @@ axis Both Και τα δύο @msg Targa files are RGB: colorModel rgb Τα αρχεία Targa είναι RGB: colorModel rgb @msg Text Wrap: select a wrap object Αναδίπλωση κειμένου: επιλέξτε αντικείμενο αναδίπλωσης @msg This shortcut file is in a form VectorCraft can't read. Αυτό το αρχείο συντομεύσεων έχει μορφή που δεν μπορεί να διαβάσει το VectorCraft. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" Το `antiAlias` πρέπει να είναι "none", "sharp", "crisp" ή "strong" @msg `aspect` must be a number (%) Το `aspect` πρέπει να είναι αριθμός (%) @msg `at` must be [x, y] Το `at` πρέπει να είναι [x, y] @msg `color` must be a colour Το `color` πρέπει να είναι χρώμα @@ -5707,6 +5719,11 @@ effect Points Σημεία Snap to Perpendicular Προσκόλληση σε κάθετο @msg unknown snap option `{key}` (use {options}) άγνωστη επιλογή προσκόλλησης `{key}` (χρησιμοποιήστε {options}) Gap Length: Μήκος κενού: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Η λειτουργία Επίπεδο λογότυπο δεν χρησιμοποιεί το keepColors: ανιχνεύει τα χρώματα που ορίζονται στο logoColors ή τα χρώματα που βρίσκει. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Δεν ανιχνεύτηκε τίποτα: κανένα από τα χρώματα που βρήκε η ανίχνευση δεν αντιστοιχεί στο keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Δεν ανιχνεύτηκε τίποτα: η Παράβλεψη χρώματος παραλείπει κάθε περιοχή των χρωμάτων που ορίζει το keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Δεν ανιχνεύτηκε τίποτα: τα χρώματα που ταιριάζουν με το keepColors δεν δίνουν καμία διαδρομή. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: κανένα από τα χρώματα που βρήκε η ανίχνευση δεν αντιστοιχεί στο {c}, οπότε τίποτα από αυτό δεν ανιχνεύεται. @msg `colorGroup` needs `library: "document"` το `colorGroup` απαιτεί `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` το `colorGroup` απαιτεί `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` το `colorGroup` απαιτεί `mode: "color"` @@ -5851,6 +5868,14 @@ axis Y Axis Άξονας Y @msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed στο «{name}» δεν παραχωρήθηκε το `{p}`, που χρειάζεται η έξοδός του: δεν άλλαξε τίποτα +@msg a heavy filter runs in the background, so it can't declare `commands` ένα βαρύ φίλτρο εκτελείται στο παρασκήνιο, οπότε δεν μπορεί να δηλώνει `commands` +@msg at most {MAX_COMMANDS} `commands` το πολύ {MAX_COMMANDS} `commands` +@msg command {name} is declared twice η εντολή {name} δηλώνεται δύο φορές +@msg command {name}: `label` must be 1-64 printable characters εντολή {name}: το `label` πρέπει να έχει 1-64 εκτυπώσιμους χαρακτήρες +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter εντολή {name}: χρησιμοποιήστε αναγνωριστικό 1-{MAX_COMMAND_ID} χαρακτήρων από a-z 0-9 _ που αρχίζει με γράμμα +@msg command {name}: {e} εντολή {name}: {e} +@msg only a filter or a live effect declares `commands` μόνο ένα φίλτρο ή ένα δυναμικό εφέ δηλώνει `commands` +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα # Actions panel: action sets (#3712). New Set… Νέο σύνολο… @@ -6394,6 +6419,16 @@ breakText Glyphs Γλύφοι @msg path must be a list of child indices το path πρέπει να είναι λίστα δεικτών θυγατρικών αντικειμένων @msg no object at path {shown} in the symbol's art δεν υπάρχει αντικείμενο στη διαδρομή {shown} στο γραφικό του συμβόλου @msg give fill, stroke, opacity and/or blend δώστε fill, stroke, opacity ή/και blend +@msg {key} cannot be set on a guide το {key} δεν μπορεί να οριστεί σε οδηγό +@msg {key} cannot be set on a blend το {key} δεν μπορεί να οριστεί σε ανάμειξη +@msg {key} cannot be set on an envelope το {key} δεν μπορεί να οριστεί σε περίβλημα +@msg {key} cannot be set on a gradient mesh το {key} δεν μπορεί να οριστεί σε πλέγμα ντεγκραντέ +@msg {key} cannot be set on a Repeat το {key} δεν μπορεί να οριστεί σε επανάληψη +@msg {key} cannot be set on a placed document το {key} δεν μπορεί να οριστεί σε τοποθετημένο έγγραφο +@msg {key} cannot be set on an Intertwine group το {key} δεν μπορεί να οριστεί σε ομάδα διαπλοκής +@msg stroke cannot be set on a symbol instance το stroke δεν μπορεί να οριστεί σε στιγμιότυπο συμβόλου +@msg fill on a symbol instance must be a solid color or none το fill ενός στιγμιότυπου συμβόλου πρέπει να είναι συμπαγές χρώμα ή none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it το {key} δεν μπορεί να οριστεί σε εικόνα χωρίς το εφέ «Μετατροπή σε σχήμα» ή «Μετατροπή αντικειμένου σε περιγράμματα» @msg pass newName, registration, exportType, nineSlice and/or type δώστε newName, registration, exportType, nineSlice ή/και type Text Flow Ροή κειμένου By Rows Κατά γραμμές @@ -6423,6 +6458,7 @@ breakText Glyphs Γλύφοι Create Swatch Legend Δημιουργία υπομνήματος δειγμάτων χρωμάτων Create Swatch Legend… Δημιουργία υπομνήματος δειγμάτων χρωμάτων… HEX HEX + Help Bar Γραμμή βοήθειας # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Εργαλεία κειμένου Text Tweaker… Ρύθμιση κειμένου… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5122c5acb..036722cd8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -198,6 +198,10 @@ Anti-Alias Suavizado Anti-alias Rasters Suavizar imágenes ráster Anti-aliased Artwork Ilustración suavizada + Anti-aliasing Method Método de suavizado +antiAlias Sharp Nítido + Crisp Definido + Strong Fuerte Anti-aliasing: Suavizado: Any Kind Cualquier tipo Font Technology Tecnología de fuente @@ -367,6 +371,13 @@ font-technology Variable Variables Apply Bullets Aplicar viñetas Numbered List Lista numerada Apply Numbers Aplicar numeración + Bullets and Numbering… Viñetas y numeración… + Numbers Números + Level: Nivel: + Bullet Character: Carácter de viñeta: + Number Style: Estilo de número: + Left Indent: Sangría izquierda: + First Line Indent: Sangría de primera línea: Convert to Text Convertir en texto Filled Dot Punto relleno Dash Guion @@ -4102,6 +4113,7 @@ axis Both Ambos @msg Targa files are RGB: colorModel rgb los archivos Targa son RGB: colorModel rgb @msg Text Wrap: select a wrap object Ceñir texto: selecciona un objeto para ceñir @msg This shortcut file is in a form VectorCraft can't read. Este archivo de métodos abreviados tiene un formato que VectorCraft no puede leer. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` debe ser "none", "sharp", "crisp" o "strong" @msg `aspect` must be a number (%) `aspect` debe ser un número (%) @msg `at` must be [x, y] `at` debe ser [x, y] @msg `color` must be a colour `color` debe ser un color @@ -5697,6 +5709,11 @@ mark-style Japanese Japonesas Snap to Perpendicular Ajustar a perpendicular @msg unknown snap option `{key}` (use {options}) opción de ajuste desconocida `{key}` (use {options}) Gap Length: Longitud del hueco: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. El modo Logotipo plano no usa keepColors: calca los colores indicados en logoColors o los colores que encuentra. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). No se calcó nada: ninguno de los colores que encontró el calco coincide con keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. No se calcó nada: Ignorar color omite todas las áreas de los colores que indica keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. No se calcó nada: los colores que coinciden con keepColors no producen ningún trazado. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ninguno de los colores que encontró el calco coincide con {c}, así que no se calca nada de él. @msg `colorGroup` needs `library: "document"` `colorGroup` requiere `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` requiere `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` requiere `mode: "color"` @@ -5841,6 +5858,14 @@ axis Y Axis Eje Y @msg unknown permission `{shown}` permiso desconocido `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” no se le concedió `{p}`, que su resultado necesita: no se cambió nada +@msg a heavy filter runs in the background, so it can't declare `commands` un filtro pesado se ejecuta en segundo plano, así que no puede declarar `commands` +@msg at most {MAX_COMMANDS} `commands` como máximo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice el comando {name} está declarado dos veces +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` debe tener 1-64 caracteres imprimibles +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que empiece por una letra +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` solo un filtro o un efecto interactivo declara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada # Actions panel: action sets (#3712). New Set… Nuevo conjunto… @@ -6384,6 +6409,16 @@ breakText Glyphs Glifos @msg path must be a list of child indices path debe ser una lista de índices de hijos @msg no object at path {shown} in the symbol's art no hay ningún objeto en la ruta {shown} del arte del símbolo @msg give fill, stroke, opacity and/or blend indique fill, stroke, opacity y/o blend +@msg {key} cannot be set on a guide {key} no se puede definir en una guía +@msg {key} cannot be set on a blend {key} no se puede definir en una fusión +@msg {key} cannot be set on an envelope {key} no se puede definir en una envolvente +@msg {key} cannot be set on a gradient mesh {key} no se puede definir en una malla de degradado +@msg {key} cannot be set on a Repeat {key} no se puede definir en una repetición +@msg {key} cannot be set on a placed document {key} no se puede definir en un documento colocado +@msg {key} cannot be set on an Intertwine group {key} no se puede definir en un grupo entrelazado +@msg stroke cannot be set on a symbol instance stroke no se puede definir en una instancia de símbolo +@msg fill on a symbol instance must be a solid color or none el fill de una instancia de símbolo debe ser un color sólido o none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} no se puede definir en una imagen sin el efecto Convertir en forma o Contornear objeto @msg pass newName, registration, exportType, nineSlice and/or type indique newName, registration, exportType, nineSlice y/o type Text Flow Flujo de texto By Rows Por filas @@ -6413,6 +6448,7 @@ breakText Glyphs Glifos Create Swatch Legend Crear leyenda de muestras Create Swatch Legend… Crear leyenda de muestras… HEX HEX + Help Bar Barra de ayuda # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Herramientas de texto Text Tweaker… Ajustador de texto… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 4265fcbde..d3ff3303c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -337,6 +337,13 @@ Apply Bullets Käytä luettelomerkkejä Numbered List Numeroitu luettelo Apply Numbers Käytä numerointia + Bullets and Numbering… Luettelomerkit ja numerointi… + Numbers Numerot + Level: Taso: + Bullet Character: Luettelomerkki: + Number Style: Numerotyyli: + Left Indent: Vasen sisennys: + First Line Indent: Ensimmäisen rivin sisennys: Convert to Text Muunna tekstiksi Filled Dot Täytetty piste Dash Ajatusviiva @@ -2706,6 +2713,10 @@ digits Malayalam Malajalam < 1 sec < 1 s [Unnamed] [Nimetön] Amount: Määrä: + Anti-aliasing Method Reunanpehmennystapa +antiAlias Sharp Terävä + Crisp Selkeä + Strong Vahva Anti-aliasing: Reunanpehmennys: Artboard: Piirtoalusta: Color Table Väritaulukko @@ -4326,6 +4337,7 @@ imageTrace Outline Ääriviiva @msg Split Into Grid: select paths large enough for the grid Jaa ruudukoksi: valitse ruudukolle riittävän suuret polut @msg Targa files are RGB: colorModel rgb Targa-tiedostot ovat RGB-muotoisia: colorModel rgb @msg Text Wrap: select a wrap object Tekstin rivitys: valitse rivitysobjekti +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` on oltava "none", "sharp", "crisp" tai "strong" @msg `aspect` must be a number (%) `aspect` on oltava luku (%) @msg `at` must be [x, y] `at` on oltava [x, y] @msg `color` must be a colour `color` on oltava väri @@ -5719,6 +5731,11 @@ mark-style Japanese Japanilainen Snap to Perpendicular Kohdista kohtisuoraan @msg unknown snap option `{key}` (use {options}) tuntematon tartuntavalinta `{key}` (käytä: {options}) Gap Length: Aukon pituus: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Tasainen logo -tila ei käytä keepColors-asetusta: se jäljittää logoColors-asetuksessa nimetyt värit tai löytämänsä värit. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Mitään ei jäljitetty: mikään jäljityksen löytämistä väreistä ei vastaa keepColors-asetusta ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Mitään ei jäljitetty: Ohita väri jättää pois kaikki keepColors-asetuksen värien alueet. +@msg Nothing was traced: the colors that match keepColors produce no paths. Mitään ei jäljitetty: keepColors-asetusta vastaavista väreistä ei synny polkuja. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: mikään jäljityksen löytämistä väreistä ei vastaa väriä {c}, joten siitä ei jäljitetä mitään. @msg `colorGroup` needs `library: "document"` `colorGroup` edellyttää arvoa `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` edellyttää arvoa `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` edellyttää arvoa `mode: "color"` @@ -5930,6 +5947,14 @@ axis Y Axis Y-akseli @msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” ei saanut käyttöoikeutta `{p}`, jota sen tulos tarvitsee: mitään ei muutettu +@msg a heavy filter runs in the background, so it can't declare `commands` raskas suodin suoritetaan taustalla, joten se ei voi määritellä `commands`-kohtaa +@msg at most {MAX_COMMANDS} `commands` enintään {MAX_COMMANDS} `commands` +@msg command {name} is declared twice komento {name} on määritelty kahdesti +@msg command {name}: `label` must be 1-64 printable characters komento {name}: `label`-kentässä on oltava 1-64 tulostettavaa merkkiä +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter komento {name}: käytä 1-{MAX_COMMAND_ID} merkin tunnusta merkeistä a-z 0-9 _, joka alkaa kirjaimella +@msg command {name}: {e} komento {name}: {e} +@msg only a filter or a live effect declares `commands` vain suodin tai live-tehoste määrittelee `commands`-kohdan +@msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu # Actions panel: action sets (#3712). New Set… Uusi sarja… @@ -6472,6 +6497,16 @@ breakText Glyphs Merkit @msg path must be a list of child indices path-arvon on oltava luettelo alikohteiden indeksejä @msg no object at path {shown} in the symbol's art symbolin kuvituksessa ei ole objektia polussa {shown} @msg give fill, stroke, opacity and/or blend anna fill, stroke, opacity ja/tai blend +@msg {key} cannot be set on a guide {key}-arvoa ei voi asettaa apuviivalle +@msg {key} cannot be set on a blend {key}-arvoa ei voi asettaa sulautukselle +@msg {key} cannot be set on an envelope {key}-arvoa ei voi asettaa kuorelle +@msg {key} cannot be set on a gradient mesh {key}-arvoa ei voi asettaa liukuväriverkolle +@msg {key} cannot be set on a Repeat {key}-arvoa ei voi asettaa toistolle +@msg {key} cannot be set on a placed document {key}-arvoa ei voi asettaa sijoitetulle dokumentille +@msg {key} cannot be set on an Intertwine group {key}-arvoa ei voi asettaa lomitetulle ryhmälle +@msg stroke cannot be set on a symbol instance stroke-arvoa ei voi asettaa symbolin ilmentymälle +@msg fill on a symbol instance must be a solid color or none symbolin ilmentymän fill-arvon on oltava tasainen väri tai none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key}-arvoa ei voi asettaa kuvalle, jolla ei ole tehostetta Muunna muodoksi tai Muunna objekti ääriviivoiksi @msg pass newName, registration, exportType, nineSlice and/or type anna newName, registration, exportType, nineSlice ja/tai type Text Flow Tekstin kulku By Rows Riveittäin @@ -6501,6 +6536,7 @@ breakText Glyphs Merkit Create Swatch Legend Luo värimallien selite Create Swatch Legend… Luo värimallien selite… HEX HEX + Help Bar Ohjepalkki # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Tekstityökalut Text Tweaker… Tekstin säätö… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 107f49a8b..8aa6d7312 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -199,6 +199,10 @@ Anti-Alias Lissage Anti-alias Rasters Lisser les images pixellisées Anti-aliased Artwork Illustration lissée + Anti-aliasing Method Méthode de lissage +antiAlias Sharp Net + Crisp Précis + Strong Fort Anti-aliasing: Lissage : Any Kind Tout type Font Technology Technologie de police @@ -368,6 +372,13 @@ font-technology Variable Variables Apply Bullets Appliquer les puces Numbered List Liste numérotée Apply Numbers Appliquer la numérotation + Bullets and Numbering… Puces et numérotation… + Numbers Numéros + Level: Niveau : + Bullet Character: Caractère de puce : + Number Style: Style de numéro : + Left Indent: Retrait gauche : + First Line Indent: Retrait de première ligne : Convert to Text Convertir en texte Filled Dot Point plein Dash Tiret @@ -4092,6 +4103,7 @@ axis Both Les deux @msg Targa files are RGB: colorModel rgb Les fichiers Targa sont en RVB : colorModel rgb @msg Text Wrap: select a wrap object Habillage de texte : sélectionnez un objet d’habillage @msg This shortcut file is in a form VectorCraft can't read. Ce fichier de raccourcis est dans un format que VectorCraft ne sait pas lire. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` doit être "none", "sharp", "crisp" ou "strong" @msg `aspect` must be a number (%) `aspect` doit être un nombre (%) @msg `at` must be [x, y] `at` doit être [x, y] @msg `color` must be a colour `color` doit être une couleur @@ -5751,6 +5763,11 @@ theme Appearance Apparence Snap to Perpendicular Magnétisme de la perpendiculaire @msg unknown snap option `{key}` (use {options}) option de magnétisme inconnue `{key}` (utilisez {options}) Gap Length: Longueur de lacune : +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Le mode Logo en aplats n’utilise pas keepColors : il vectorise les couleurs indiquées dans logoColors ou celles qu’il trouve. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Rien n’a été vectorisé : aucune des couleurs trouvées par la vectorisation ne correspond à keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Rien n’a été vectorisé : Ignorer la couleur omet toutes les zones des couleurs indiquées dans keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Rien n’a été vectorisé : les couleurs correspondant à keepColors ne produisent aucun tracé. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors : aucune des couleurs trouvées par la vectorisation ne correspond à {c}, donc rien n’en est vectorisé. @msg `colorGroup` needs `library: "document"` `colorGroup` nécessite `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` nécessite `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` nécessite `mode: "color"` @@ -5895,6 +5912,14 @@ axis Y Axis Axe Y @msg unknown permission `{shown}` autorisation inconnue `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié +@msg a heavy filter runs in the background, so it can't declare `commands` un filtre lourd s’exécute en arrière-plan, il ne peut donc pas déclarer `commands` +@msg at most {MAX_COMMANDS} `commands` au plus {MAX_COMMANDS} `commands` +@msg command {name} is declared twice la commande {name} est déclarée deux fois +@msg command {name}: `label` must be 1-64 printable characters commande {name} : `label` doit compter 1-64 caractères imprimables +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter commande {name} : utilisez un id de 1-{MAX_COMMAND_ID} caractères parmi a-z 0-9 _ commençant par une lettre +@msg command {name}: {e} commande {name} : {e} +@msg only a filter or a live effect declares `commands` seul un filtre ou un effet dynamique déclare `commands` +@msg “{name}” › {label}: {e} « {name} » › {label} : {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé # Actions panel: action sets (#3712). New Set… Nouveau jeu… @@ -6437,6 +6462,16 @@ breakText Glyphs Glyphes @msg path must be a list of child indices path doit être une liste d'indices d'enfants @msg no object at path {shown} in the symbol's art aucun objet au chemin {shown} dans l'illustration du symbole @msg give fill, stroke, opacity and/or blend indiquez fill, stroke, opacity et/ou blend +@msg {key} cannot be set on a guide {key} ne peut pas être défini sur un repère +@msg {key} cannot be set on a blend {key} ne peut pas être défini sur un dégradé de formes +@msg {key} cannot be set on an envelope {key} ne peut pas être défini sur une enveloppe +@msg {key} cannot be set on a gradient mesh {key} ne peut pas être défini sur un filet de dégradé +@msg {key} cannot be set on a Repeat {key} ne peut pas être défini sur une répétition +@msg {key} cannot be set on a placed document {key} ne peut pas être défini sur un document importé +@msg {key} cannot be set on an Intertwine group {key} ne peut pas être défini sur un groupe entrelacé +@msg stroke cannot be set on a symbol instance stroke ne peut pas être défini sur une instance de symbole +@msg fill on a symbol instance must be a solid color or none le fill d’une instance de symbole doit être une couleur unie ou none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} ne peut pas être défini sur une image sans l’effet Convertir en forme ou Vectoriser l’objet @msg pass newName, registration, exportType, nineSlice and/or type indiquez newName, registration, exportType, nineSlice et/ou type Text Flow Flux du texte By Rows Par lignes @@ -6466,6 +6501,7 @@ breakText Glyphs Glyphes Create Swatch Legend Créer une légende des nuances Create Swatch Legend… Créer une légende des nuances… HEX HEX + Help Bar Barre d’aide # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Outils de texte Text Tweaker… Ajusteur de texte… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 5db92a70e..5d95e6262 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -343,6 +343,10 @@ Anti-Alias एंटी-एलियास Anti-alias Rasters रास्टर एंटी-एलियास करें Anti-aliased Artwork एंटी-एलियास किया आर्टवर्क + Anti-aliasing Method एंटी-एलियासिंग विधि +antiAlias Sharp शार्प + Crisp क्रिस्प + Strong स्ट्रॉन्ग Anti-aliasing: एंटी-एलियासिंग: Any except extracting pages पेज निकालने के अलावा सभी Any Kind किसी भी प्रकार का @@ -2049,6 +2053,7 @@ Help सहायता Help › Search Commands… मदद › खोज कमांड… HEX HEX + Help Bar मदद बार Hex by Column कॉलम द्वारा हेक्स Hex by Row पंक्ति द्वारा हेक्स Hexagon षट्भुज @@ -5267,6 +5272,14 @@ @msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” को `{p}` नहीं दी गई, जो उसके आउटपुट को चाहिए: कुछ नहीं बदला +@msg a heavy filter runs in the background, so it can't declare `commands` भारी फ़िल्टर पृष्ठभूमि में चलता है, इसलिए वह `commands` घोषित नहीं कर सकता +@msg at most {MAX_COMMANDS} `commands` अधिकतम {MAX_COMMANDS} `commands` +@msg command {name} is declared twice कमांड {name} दो बार घोषित है +@msg command {name}: `label` must be 1-64 printable characters कमांड {name}: `label` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter कमांड {name}: a-z 0-9 _ के 1-{MAX_COMMAND_ID} वर्णों वाली ऐसी id इस्तेमाल करें जो अक्षर से शुरू हो +@msg command {name}: {e} कमांड {name}: {e} +@msg only a filter or a live effect declares `commands` केवल फ़िल्टर या लाइव इफ़ेक्ट ही `commands` घोषित करता है +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ @msg “{name}” is not a file "{name}" एक फ़ाइल नहीं है @msg ({note}) ({more}) ({note}) ({more}) @@ -5530,6 +5543,7 @@ @msg `anchors` must be an array of [subpath, anchor] pairs `anchors` को [subpath, एंकर] जोड़े की एक सारणी होना चाहिए @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {k} नहीं @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {v} नहीं +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` होना चाहिए "none", "sharp", "crisp" या "strong" @msg `artboards` must be a list of artboard indices `artboards` को आर्टबोर्ड इंडेक्स की सूची होना चाहिए @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all या 1, 3-5 जैसी सीमा दें, {v} नहीं @msg `aspect` must be a number (%) `aspect` को संख्या (%) होना चाहिए @@ -6294,6 +6308,16 @@ @msg give dataBase64 dataBase64 दें @msg give either at (a click) or area (a path drawn around the overlaps) या तो (एक क्लिक) या क्षेत्र (ओवरलैप के चारों ओर खींचा गया एक पाथ) देना @msg give fill, stroke, opacity and/or blend भरने, स्ट्रोक, अपारदर्शिता और/या मिश्रण देते हैं +@msg {key} cannot be set on a guide गाइड पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a blend ब्लेंड पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on an envelope एन्वेलप पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a gradient mesh ग्रेडिएंट मेश पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a Repeat रिपीट पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a placed document प्लेस्ड डॉक्यूमेंट पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on an Intertwine group इंटरट्वाइन समूह पर {key} सेट नहीं किया जा सकता +@msg stroke cannot be set on a symbol instance सिंबल इंस्टेंस पर stroke सेट नहीं किया जा सकता +@msg fill on a symbol instance must be a solid color or none सिंबल इंस्टेंस का fill ठोस रंग या none होना चाहिए +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it शेप में बदलें या रूपरेखा ऑब्जेक्ट इफ़ेक्ट के बिना इमेज पर {key} सेट नहीं किया जा सकता @msg give formats or a preset, not both फ़ॉर्मैट या एक पूर्व निर्धारित देना, दोनों ही नहीं @msg give horizontal or vertical क्षैतिज या ऊर्ध्वाधर देना @msg give newName or delete newName या delete दें @@ -7419,6 +7443,11 @@ @msg the image is fully transparent इमेज पूरी तरह से पारदर्शी है @msg the image is not on an artboard; pass rect इमेज एक आर्टबोर्ड पर नहीं है; सही होकर गुजरना @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). इमेज को {_1} × {_2} पिक्सेल पर पता लगाया गया था, जिसे {_3} × {_4} ({_5} मेगापिक्सेल) से नीचे बढ़ाया गया था। +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Flat लोगो मोड keepColors का उपयोग नहीं करता: यह logoColors में बताए गए रंगों को या अपने पाए गए रंगों को ट्रेस करता है। +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). कुछ भी ट्रेस नहीं हुआ: ट्रेस में मिला कोई भी रंग keepColors ({_1}) से मेल नहीं खाता। +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. कुछ भी ट्रेस नहीं हुआ: Ignore रंग keepColors में सूचीबद्ध रंगों के सभी क्षेत्र छोड़ देता है। +@msg Nothing was traced: the colors that match keepColors produce no paths. कुछ भी ट्रेस नहीं हुआ: keepColors से मेल खाने वाले रंग कोई पाथ नहीं बनाते। +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ट्रेस में मिला कोई भी रंग {c} से मेल नहीं खाता, इसलिए इसका कुछ भी ट्रेस नहीं होता। @msg the image's pixels are missing इमेज के पिक्सेल लापता हैं @msg the image's pixels aren't in the document इमेज के पिक्सेल डॉक्यूमेंट में नहीं हैं @msg the image's pixels can't be read इमेज के पिक्सेल को पढ़ा नहीं जा सकता है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 43ca6a06d..e3bc02191 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -280,6 +280,10 @@ Anti-Alias Élsimítás Anti-alias Rasters Raszterek élsimítása Anti-aliased Artwork Élsimított grafika + Anti-aliasing Method Élsimítási módszer +antiAlias Sharp Éles + Crisp Tiszta + Strong Erős Anti-aliasing: Élsimítás: Any except extracting pages Bármi, kivéve az oldalak kinyerését Any Kind Bármilyen típus @@ -504,6 +508,13 @@ font-technology Variable Változó Apply Bullets Felsorolásjelek alkalmazása Numbered List Számozott lista Apply Numbers Számozás alkalmazása + Bullets and Numbering… Felsorolásjelek és számozás… + Numbers Számok + Level: Szint: + Bullet Character: Felsorolásjel: + Number Style: Számozás stílusa: + Left Indent: Bal behúzás: + First Line Indent: Első sor behúzása: Convert to Text Átalakítás szöveggé Filled Dot Kitöltött pont Dash Gondolatjel @@ -4632,6 +4643,7 @@ imageTrace Outline Körvonal # Status and error messages @msg "{0}" is not a colour: write it as #rrggbb nem szín: "{0}"; #rrggbb alakban add meg +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias`: "none", "sharp", "crisp" vagy "strong" kell @msg `aspect` must be a number (%) `aspect`: szám kell (%) @msg `at` must be [x, y] `at`: [x, y] kell @msg `color` must be a colour `color`: szín kell @@ -5739,6 +5751,11 @@ imageTrace Outline Körvonal Snap to Perpendicular Illesztés merőlegesre @msg unknown snap option `{key}` (use {options}) ismeretlen illesztési beállítás: `{key}` (használható: {options}) Gap Length: Rés hossza: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Az Egyszínű logó mód nem használja a keepColors beállítást: a logoColors beállításban megadott színeket vagy az általa talált színeket vektorizálja. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Semmi sem lett vektorizálva: a vektorizálás által talált színek egyike sem felel meg a keepColors beállításnak ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Semmi sem lett vektorizálva: a Szín figyelmen kívül hagyása kihagyja a keepColors beállításban felsorolt színek minden területét. +@msg Nothing was traced: the colors that match keepColors produce no paths. Semmi sem lett vektorizálva: a keepColors beállításnak megfelelő színekből nem jön létre útvonal. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: a vektorizálás által talált színek egyike sem felel meg ennek: {c}, így belőle semmi sem lesz vektorizálva. @msg `colorGroup` needs `library: "document"` a `colorGroup` használatához `library: "document"` szükséges @msg `colorGroup` needs `palette: "documentLibrary"` a `colorGroup` használatához `palette: "documentLibrary"` szükséges @msg `colorGroup` needs `mode: "color"` a `colorGroup` használatához `mode: "color"` szükséges @@ -5883,6 +5900,14 @@ axis Y Axis Y tengely @msg unknown permission `{shown}` ismeretlen engedély: `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a(z) „{name}” nem kapta meg a(z) `{p}` engedélyt, amelyre a kimenetének szüksége van: semmi sem változott +@msg a heavy filter runs in the background, so it can't declare `commands` a nehéz szűrő a háttérben fut, ezért nem deklarálhat `commands` értéket +@msg at most {MAX_COMMANDS} `commands` legfeljebb {MAX_COMMANDS} `commands` +@msg command {name} is declared twice a(z) {name} parancs kétszer van deklarálva +@msg command {name}: `label` must be 1-64 printable characters {name} parancs: a `label` 1-64 nyomtatható karakter legyen +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} parancs: használj 1-{MAX_COMMAND_ID} karakteres, a-z 0-9 _ karakterekből álló, betűvel kezdődő azonosítót +@msg command {name}: {e} {name} parancs: {e} +@msg only a filter or a live effect declares `commands` `commands` értéket csak szűrő vagy élő effektus deklarálhat +@msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve # Actions panel: action sets (#3712). New Set… Új készlet… @@ -6426,6 +6451,16 @@ breakText Glyphs Glifák @msg path must be a list of child indices a path gyermekindexek listája kell legyen @msg no object at path {shown} in the symbol's art a szimbólum grafikájában nincs objektum a(z) {shown} útvonalon @msg give fill, stroke, opacity and/or blend adja meg a fill, stroke, opacity és/vagy blend értékét +@msg {key} cannot be set on a guide a {key} nem állítható be segédvonalon +@msg {key} cannot be set on a blend a {key} nem állítható be összemosáson +@msg {key} cannot be set on an envelope a {key} nem állítható be burkolón +@msg {key} cannot be set on a gradient mesh a {key} nem állítható be színátmenetes hálón +@msg {key} cannot be set on a Repeat a {key} nem állítható be ismétlésen +@msg {key} cannot be set on a placed document a {key} nem állítható be elhelyezett dokumentumon +@msg {key} cannot be set on an Intertwine group a {key} nem állítható be összefonási csoporton +@msg stroke cannot be set on a symbol instance a stroke nem állítható be szimbólumpéldányon +@msg fill on a symbol instance must be a solid color or none a szimbólumpéldány fill értéke csak egyszínű szín vagy none lehet +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it a {key} nem állítható be olyan képen, amelyen nincs Átalakítás alakzattá vagy Objektum útvonallá alakítása effektus @msg pass newName, registration, exportType, nineSlice and/or type adja meg a newName, registration, exportType, nineSlice és/vagy type értékét Text Flow Szövegfolyás By Rows Soronként @@ -6455,6 +6490,7 @@ breakText Glyphs Glifák Create Swatch Legend Színmintajelmagyarázat létrehozása Create Swatch Legend… Színmintajelmagyarázat létrehozása… HEX HEX + Help Bar Súgósáv # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Szövegeszközök Text Tweaker… Szöveghangoló… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 22b8db56b..5fb92d1b0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -299,6 +299,13 @@ Apply Bullets Terapkan poin Numbered List Daftar bernomor Apply Numbers Terapkan penomoran + Bullets and Numbering… Poin dan penomoran… + Numbers Nomor + Level: Tingkat: + Bullet Character: Karakter poin: + Number Style: Gaya nomor: + Left Indent: Indentasi kiri: + First Line Indent: Indentasi baris pertama: Convert to Text Konversi ke teks Filled Dot Titik penuh Dash Tanda pisah @@ -2550,6 +2557,10 @@ digits Malayalam Malayalam < 1 sec < 1 detik [Unnamed] [Tanpa nama] Amount: Jumlah: + Anti-aliasing Method Metode anti-aliasing +antiAlias Sharp Tajam + Crisp Jelas + Strong Kuat Anti-aliasing: Anti-aliasing: Artboard: Artboard: Color Table Tabel warna @@ -4104,6 +4115,7 @@ imageTrace Outline Kontur @msg Split Into Grid: select paths large enough for the grid Bagi menjadi kisi: pilih jalur yang cukup besar untuk kisi @msg Targa files are RGB: colorModel rgb File Targa adalah RGB: colorModel rgb @msg Text Wrap: select a wrap object Pembungkusan teks: pilih objek pembungkus +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` harus "none", "sharp", "crisp" atau "strong" @msg `aspect` must be a number (%) `aspect` harus berupa angka (%) @msg `at` must be [x, y] `at` harus [x, y] @msg `color` must be a colour `color` harus berupa warna @@ -6273,6 +6285,16 @@ breakText Glyphs Glif @msg path must be a list of child indices path harus berupa daftar indeks anak @msg no object at path {shown} in the symbol's art tidak ada objek di jalur {shown} dalam karya seni simbol @msg give fill, stroke, opacity and/or blend berikan fill, stroke, opacity dan/atau blend +@msg {key} cannot be set on a guide {key} tidak dapat diatur pada panduan +@msg {key} cannot be set on a blend {key} tidak dapat diatur pada blend +@msg {key} cannot be set on an envelope {key} tidak dapat diatur pada amplop +@msg {key} cannot be set on a gradient mesh {key} tidak dapat diatur pada mesh gradien +@msg {key} cannot be set on a Repeat {key} tidak dapat diatur pada perulangan +@msg {key} cannot be set on a placed document {key} tidak dapat diatur pada dokumen yang ditempatkan +@msg {key} cannot be set on an Intertwine group {key} tidak dapat diatur pada grup jalinan +@msg stroke cannot be set on a symbol instance stroke tidak dapat diatur pada instans simbol +@msg fill on a symbol instance must be a solid color or none fill pada instans simbol harus berupa warna solid atau none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} tidak dapat diatur pada gambar tanpa efek Konversi ke bentuk atau Kerangka objek @msg pass newName, registration, exportType, nineSlice and/or type berikan newName, registration, exportType, nineSlice dan/atau type Text Flow Alur teks By Rows Per baris @@ -6302,6 +6324,7 @@ breakText Glyphs Glif Create Swatch Legend Buat Legenda Swatch Create Swatch Legend… Buat Legenda Swatch… HEX HEX + Help Bar Bilah Bantuan # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Alat Teks Text Tweaker… Penyetel Teks… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4f6375c4f..5522e1990 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -198,6 +198,10 @@ Anti-Alias Anti-aliasing Anti-alias Rasters Anti-aliasing per immagini raster Anti-aliased Artwork Grafica con anti-aliasing + Anti-aliasing Method Metodo di anti-aliasing +antiAlias Sharp Nitido + Crisp Definito + Strong Forte Anti-aliasing: Anti-aliasing: Any Kind Qualsiasi tipo Font Technology Tecnologia font @@ -367,6 +371,13 @@ font-technology Variable Variabili Apply Bullets Applica punti elenco Numbered List Elenco numerato Apply Numbers Applica numerazione + Bullets and Numbering… Punti elenco e numerazione… + Numbers Numeri + Level: Livello: + Bullet Character: Carattere punto elenco: + Number Style: Stile numero: + Left Indent: Rientro sinistro: + First Line Indent: Rientro prima riga: Convert to Text Converti in testo Filled Dot Punto pieno Dash Trattino @@ -4144,6 +4155,7 @@ axis Both Entrambi @msg Targa files are RGB: colorModel rgb I file Targa sono RGB: colorModel rgb @msg Text Wrap: select a wrap object Testo attorno: seleziona un oggetto di avvolgimento @msg This shortcut file is in a form VectorCraft can't read. Questo file di scelte rapide è in un formato che VectorCraft non può leggere. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` deve essere "none", "sharp", "crisp" o "strong" @msg `aspect` must be a number (%) `aspect` deve essere un numero (%) @msg `at` must be [x, y] `at` deve essere [x, y] @msg `color` must be a colour `color` deve essere un colore @@ -5369,6 +5381,11 @@ lighting Light Luce Snap to Perpendicular Aggancia alla perpendicolare @msg unknown snap option `{key}` (use {options}) opzione di aggancio sconosciuta `{key}` (usare {options}) Gap Length: Lunghezza lacuna: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. La modalità Logo piatto non usa keepColors: vettorializza i colori indicati in logoColors o i colori che trova. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Non è stato vettorializzato nulla: nessuno dei colori trovati dalla vettorializzazione corrisponde a keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Non è stato vettorializzato nulla: Ignora colore esclude ogni area dei colori elencati in keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Non è stato vettorializzato nulla: i colori che corrispondono a keepColors non producono tracciati. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: nessuno dei colori trovati dalla vettorializzazione corrisponde a {c}, quindi non ne viene vettorializzato nulla. @msg `colorGroup` needs `library: "document"` `colorGroup` richiede `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` richiede `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` richiede `mode: "color"` @@ -5516,6 +5533,14 @@ axis Y Axis Asse Y @msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” non è stata concessa `{p}`, che serve al suo risultato: non è stato modificato nulla +@msg a heavy filter runs in the background, so it can't declare `commands` un filtro pesante viene eseguito in background, quindi non può dichiarare `commands` +@msg at most {MAX_COMMANDS} `commands` al massimo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice il comando {name} è dichiarato due volte +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve avere 1-64 caratteri stampabili +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id di 1-{MAX_COMMAND_ID} caratteri tra a-z 0-9 _ che inizi con una lettera +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` solo un filtro o un effetto dinamico dichiara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla # Actions panel: action sets (#3712). New Set… Nuovo set… @@ -6062,6 +6087,16 @@ breakText Glyphs Glifi @msg path must be a list of child indices path deve essere un elenco di indici figlio @msg no object at path {shown} in the symbol's art nessun oggetto al percorso {shown} nella grafica del simbolo @msg give fill, stroke, opacity and/or blend specifica fill, stroke, opacity e/o blend +@msg {key} cannot be set on a guide {key} non può essere impostato su una guida +@msg {key} cannot be set on a blend {key} non può essere impostato su una fusione +@msg {key} cannot be set on an envelope {key} non può essere impostato su un involucro +@msg {key} cannot be set on a gradient mesh {key} non può essere impostato su una trama sfumatura +@msg {key} cannot be set on a Repeat {key} non può essere impostato su una ripetizione +@msg {key} cannot be set on a placed document {key} non può essere impostato su un documento inserito +@msg {key} cannot be set on an Intertwine group {key} non può essere impostato su un gruppo intrecciato +@msg stroke cannot be set on a symbol instance stroke non può essere impostato su un’istanza di simbolo +@msg fill on a symbol instance must be a solid color or none il fill di un’istanza di simbolo deve essere un colore pieno o none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} non può essere impostato su un’immagine senza l’effetto Converti in forma o Contorna oggetto @msg pass newName, registration, exportType, nineSlice and/or type specifica newName, registration, exportType, nineSlice e/o type Text Flow Flusso del testo By Rows Per righe @@ -6091,6 +6126,7 @@ breakText Glyphs Glifi Create Swatch Legend Crea legenda campioni Create Swatch Legend… Crea legenda campioni… HEX HEX + Help Bar Barra della guida # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Strumenti testo Text Tweaker… Regolatore testo… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8c73a1ae0..63d6cc9ac 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -355,6 +355,13 @@ digits Malayalam マラヤーラム数字 Apply Bullets 箇条書きを適用 Numbered List 番号付きリスト Apply Numbers 番号付けを適用 + Bullets and Numbering… 箇条書きと番号付け… + Numbers 番号 + Level: レベル: + Bullet Character: 箇条書き記号: + Number Style: 番号の形式: + Left Indent: 左インデント: + First Line Indent: 1 行目インデント: Convert to Text テキストに変換 Filled Dot 黒丸 Dash ダッシュ @@ -986,6 +993,10 @@ digits Malayalam マラヤーラム数字 Anti-alias アンチエイリアス Anti-alias Rasters ラスターをアンチエイリアス Anti-aliased Artwork アートワークをアンチエイリアス + Anti-aliasing Method アンチエイリアスの方式 +antiAlias Sharp シャープ + Crisp くっきり + Strong 太め Anti-aliasing: アンチエイリアス: Appearance Item アピアランス項目 Appearance Panel アピアランスパネル @@ -4275,6 +4286,7 @@ swatch Bright Violet ビビッドバイオレット @msg no point {i} (the gradient has {_1}) ポイント {i} はありません (このグラデーションのポイントは {_1} 個です) @msg no segment {segment} on line {line} ライン {line} にセグメント {segment} はありません @msg unknown freeform mode `{m}` (points, lines) `{m}` は不明なフリーフォームのモードです (points、lines) +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` には "none"、"sharp"、"crisp" または "strong" を指定してください @msg `aspect` must be a number (%) `aspect` には数値 (%) を指定してください @msg `gradient` must be an object `gradient` にはオブジェクトを指定してください @msg `groupColors` must be an array `groupColors` には配列を指定してください @@ -5412,6 +5424,11 @@ lighting Light ライト Snap to Perpendicular 垂線にスナップ @msg unknown snap option `{key}` (use {options}) 不明なスナップオプション `{key}` ({options} を使用してください) Gap Length: ギャップの長さ: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. フラットロゴモードは keepColors を使いません。logoColors で指定したカラー、または検出したカラーをトレースします。 +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 何もトレースされませんでした。トレースで検出したカラーのどれも keepColors ({_1}) に一致しません。 +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 何もトレースされませんでした。「カラーを無視」が keepColors に指定したカラーの領域をすべて除外します。 +@msg Nothing was traced: the colors that match keepColors produce no paths. 何もトレースされませんでした。keepColors に一致するカラーからパスができません。 +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: トレースで検出したカラーのどれも {c} に一致しないため、このカラーは何もトレースされません。 @msg `colorGroup` needs `library: "document"` `colorGroup` には `library: "document"` が必要です @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` には `palette: "documentLibrary"` が必要です @msg `colorGroup` needs `mode: "color"` `colorGroup` には `mode: "color"` が必要です @@ -5581,6 +5598,14 @@ axis Y Axis Y 軸 @msg unknown permission `{shown}` 不明な権限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」には出力に必要な `{p}` が付与されていません。何も変更されませんでした +@msg a heavy filter runs in the background, so it can't declare `commands` 重いフィルターはバックグラウンドで実行されるため、`commands` を宣言できません +@msg at most {MAX_COMMANDS} `commands` `commands` は最大 {MAX_COMMANDS} 個です +@msg command {name} is declared twice コマンド {name} が 2 回宣言されています +@msg command {name}: `label` must be 1-64 printable characters コマンド {name}: `label` は印刷可能な 1-64 文字にしてください +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter コマンド {name}: a-z 0-9 _ からなり英字で始まる 1-{MAX_COMMAND_ID} 文字の ID を使用してください +@msg command {name}: {e} コマンド {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` を宣言できるのはフィルターかライブ効果だけです +@msg “{name}” › {label}: {e} 「{name}」 › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした # Actions panel: action sets (#3712). New Set… 新規セット… @@ -6127,6 +6152,16 @@ breakText Glyphs 字形 @msg path must be a list of child indices path は子のインデックスのリストにしてください @msg no object at path {shown} in the symbol's art シンボルのアートのパス {shown} にオブジェクトがありません @msg give fill, stroke, opacity and/or blend fill、stroke、opacity、blend のいずれかを指定してください +@msg {key} cannot be set on a guide ガイドには {key} を設定できません +@msg {key} cannot be set on a blend ブレンドには {key} を設定できません +@msg {key} cannot be set on an envelope エンベロープには {key} を設定できません +@msg {key} cannot be set on a gradient mesh グラデーションメッシュには {key} を設定できません +@msg {key} cannot be set on a Repeat リピートには {key} を設定できません +@msg {key} cannot be set on a placed document 配置したドキュメントには {key} を設定できません +@msg {key} cannot be set on an Intertwine group 織り交ぜグループには {key} を設定できません +@msg stroke cannot be set on a symbol instance シンボルインスタンスには stroke を設定できません +@msg fill on a symbol instance must be a solid color or none シンボルインスタンスの fill は単色または none にしてください +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 「シェイプに変換」または「オブジェクトのアウトライン」効果のない画像には {key} を設定できません @msg pass newName, registration, exportType, nineSlice and/or type newName、registration、exportType、nineSlice、type のいずれかを指定してください Text Flow テキストの流れ By Rows 行方向 @@ -6156,6 +6191,7 @@ breakText Glyphs 字形 Create Swatch Legend スウォッチの凡例を作成 Create Swatch Legend… スウォッチの凡例を作成… HEX HEX + Help Bar ヘルプバー # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools テキストツール Text Tweaker… テキストの調整… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5d40de893..23619e907 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -74,7 +74,7 @@ CMYK Color CMYK ფერი RGB Color RGB ფერი File Info… ფაილის ინფორმაცია… - Print… დაბეჭდვა… + Print… ბეჭდვა… Edit რედაქტირება Undo დაბრუნება Redo ხელახლა შესრულება @@ -266,6 +266,13 @@ Apply Bullets ბურთულების გამოყენება Numbered List დანომრილი სია Apply Numbers დანომრვის გამოყენება + Bullets and Numbering… ბურთულები და დანომრვა… + Numbers ნომრები + Level: დონე: + Bullet Character: ბურთულის სიმბოლო: + Number Style: ნომრის სტილი: + Left Indent: მარცხენა შეწევა: + First Line Indent: პირველი სტრიქონის შეწევა: Convert to Text ტექსტად გარდაქმნა Filled Dot სავსე წერტილი Dash ტირე @@ -1252,7 +1259,7 @@ lighting Light სინათლე Dashed ტირეებიანი Delete Selected Item მონიშნული ელემენტის წაშლა Double-click to edit the characters' paint ორჯერ დაწკაპეთ სიმბოლოების შეღებვის რედაქტირებისთვის - Click to edit დააწკაპეთ რედაქტირებისთვის + Click to edit დაწკაპეთ დასარედაქტირებლად Double-click to edit ორჯერ დაწკაპეთ რედაქტირებისთვის Double-click to target the contents ორჯერ დაწკაპეთ შიგთავსის ასარჩევად Drag onto art to apply this appearance ჩაავლეთ ნახატზე ამ იერსახის გამოსაყენებლად @@ -2446,6 +2453,10 @@ digits Malayalam მალაიალამური < 1 sec < 1 წმ [Unnamed] [უსახელო] Amount: რაოდენობა: + Anti-aliasing Method ანტიალიასინგის მეთოდი +antiAlias Sharp მკვეთრი + Crisp მკაფიო + Strong ძლიერი Anti-aliasing: ანტიალიასინგი: Artboard: სამუშაო დაფა: Color Table ფერების ცხრილი @@ -2999,7 +3010,7 @@ theme Appearance იერსახე Reverse Order რიგის შებრუნება Show All Layers ყველა შრის ჩვენება Unlock All Layers ყველა შრის განბლოკვა - English: USA English: USA + English: USA ინგლისური: აშშ Move გადატანა Character Style სიმბოლოს სტილი Paragraph Style აბზაცის სტილი @@ -3416,7 +3427,7 @@ effect Points წერტილები Discard Empty Type ცარიელი ტექსტის მოცილება Copyright საავტორო უფლება Other სხვა - Print დაბეჭდვა + Print ბეჭდვა Solid მთლიანი Difference სხვაობა Flatten Transparency გამჭვირვალობის გაბრტყელება @@ -3652,7 +3663,7 @@ font-technology Variable ცვლადი Flip: არეკვლა: Spacing: ინტერვალი: Output გამოტანა - Export… ექსპორტი… + Export… ექსპორტირება… Import… იმპორტი… Load Profile… პროფილის ჩატვირთვა… Embedded ჩაშენებული @@ -3887,6 +3898,7 @@ font-technology Variable ცვლადი @msg Split Into Grid: select paths large enough for the grid ბადედ გაყოფა: მონიშნეთ ბადისთვის საკმარისად დიდი ტრაექტორიები @msg Targa files are RGB: colorModel rgb Targa ფაილები RGB-ა: colorModel rgb @msg Text Wrap: select a wrap object ტექსტის შემოვლება: მონიშნეთ შემოსავლელი ობიექტი +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` უნდა იყოს "none", "sharp", "crisp" ან "strong" @msg `aspect` must be a number (%) `aspect` უნდა იყოს რიცხვი (%) @msg `at` must be [x, y] `at` უნდა იყოს [x, y] @msg `color` must be a colour `color` უნდა იყოს ფერი @@ -4665,7 +4677,7 @@ font-technology Variable ცვლადი Bicubic ბიკუბური Minimum მინიმალური Run Length Run Length - Roman Roman + Roman რომაული No conversion გარდაქმნის გარეშე Convert to destination სამიზნეში გარდაქმნა Convert to destination (preserve numbers) სამიზნეში გარდაქმნა (რიცხვების შენარჩუნებით) @@ -5590,6 +5602,11 @@ imageTrace Outline კონტური @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it „შენახვა“ წერს ფორმატს {_1} და არა {_2}: ამისთვის გამოიყენეთ file.saveAsTemplate @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. გამოსახულება არის {_1} × {_2} პიქსელი, ანუ ერთ მხარეზე {LARGE_TRACE_SIDE}-ზე მეტი: ის ვექტორიზდება, მაგრამ უფრო ნელა. ნაკლები სიზუსტის პრესეტი ან გამოსახულების უფრო პატარა ასლი უფრო სწრაფად ვექტორიზდება. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). გამოსახულება ვექტორიზდა {_1} × {_2} პიქსელზე, შემცირდა {_3} × {_4}-დან (მაქსიმუმ {_5} მეგაპიქსელი). +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. ბრტყელი ლოგოს რეჟიმი keepColors-ს არ იყენებს: ის ვექტორიზებს logoColors-ში მითითებულ ფერებს ან მის მიერ ნაპოვნ ფერებს. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). არაფერი ვექტორიზდა: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება keepColors-ს ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. არაფერი ვექტორიზდა: ფერის იგნორირება გამოტოვებს keepColors-ში ჩამოთვლილი ფერების ყველა არეს. +@msg Nothing was traced: the colors that match keepColors produce no paths. არაფერი ვექტორიზდა: keepColors-ის შესაბამისი ფერებიდან არცერთი კონტური არ მიიღება. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება {c}-ს, ამიტომ მისგან არაფერი ვექტორიზდება. @msg `colorGroup` needs `library: "document"` `colorGroup`-ს სჭირდება `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup`-ს სჭირდება `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup`-ს სჭირდება `mode: "color"` @@ -5824,6 +5841,14 @@ axis Y Axis Y ღერძი @msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“-ს არ მიეცა `{p}`, რომელიც მის შედეგს სჭირდება: არაფერი შეცვლილა +@msg a heavy filter runs in the background, so it can't declare `commands` მძიმე ფილტრი ფონურ რეჟიმში მუშაობს, ამიტომ მას არ შეუძლია `commands`-ის გამოცხადება +@msg at most {MAX_COMMANDS} `commands` მაქსიმუმ {MAX_COMMANDS} `commands` +@msg command {name} is declared twice ბრძანება {name} ორჯერაა გამოცხადებული +@msg command {name}: `label` must be 1-64 printable characters ბრძანება {name}: `label` უნდა შედგებოდეს 1-64 დასაბეჭდი სიმბოლოსგან +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ბრძანება {name}: გამოიყენეთ 1-{MAX_COMMAND_ID} სიმბოლოიანი id a-z 0-9 _ სიმბოლოებიდან, რომელიც ასოთი იწყება +@msg command {name}: {e} ბრძანება {name}: {e} +@msg only a filter or a live effect declares `commands` `commands`-ს მხოლოდ ფილტრი ან ცოცხალი ეფექტი აცხადებს +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა # Actions panel: action sets (#3712). New Set… ახალი ნაკრები… @@ -6127,7 +6152,7 @@ anchor Points: წერტილები: Close Path: ტრაექტორიის შეკვრა: Keep Handles სახელურების შენარჩუნება Straight სწორი - Stretch Curve მრუდის გაჭიმვა + Stretch Curve მრუდის გაწელვა @msg direct-select the anchor points to remove პირდაპირ მონიშნეთ მოსაცილებელი საყრდენი წერტილები @msg select segments, or paths, to add points to მონიშნეთ სეგმენტები ან ტრაექტორიები წერტილების დასამატებლად @msg select open paths to close მონიშნეთ შესაკრავი ღია ტრაექტორიები @@ -6272,7 +6297,7 @@ anchor Points: წერტილები: Join Point Text წერტილის ტექსტის შეერთება Remove Breaks წყვეტების წაშლა Split Text at Cursor ტექსტის დაყოფა კურსორთან - Set Alignment, Keep Position სწორების დაყენება მდებარეობის შენარჩუნებით + Set Alignment, Keep Position სწორების დაყენება პოზიციის შენარჩუნებით Reset Text Transform ტექსტის გარდაქმნის ჩამოყრა Replace Missing Fonts… დაკარგული შრიფტების ჩანაცვლება… Replace Missing Fonts დაკარგული შრიფტების ჩანაცვლება @@ -6283,7 +6308,7 @@ anchor Points: წერტილები: Break Text into Words ტექსტის დაყოფა სიტყვებად Break Text into Glyphs ტექსტის დაყოფა გლიფებად Reset Text Transform and Scale ტექსტის გარდაქმნისა და მასშტაბის ჩამოყრა - Set Alignment, Keep Position: სწორების დაყენება მდებარეობის შენარჩუნებით: + Set Alignment, Keep Position: სწორების დაყენება პოზიციის შენარჩუნებით: Break Text into: ტექსტის დაყოფა: Split at Cursor დაყოფა კურსორთან Reset Transform გარდაქმნის ჩამოყრა @@ -6292,7 +6317,7 @@ anchor Points: წერტილები: breakText Lines სტრიქონები breakText Words სიტყვები breakText Glyphs გლიფები -@msg Click in type with a Type tool to split it დასაყოფად დააწკაპუნეთ ტექსტში ტექსტის ხელსაწყოთი +@msg Click in type with a Type tool to split it დასაყოფად დაწკაპეთ ტექსტში ტექსტის ხელსაწყოთი @msg Too many type objects to change at once ერთდროულად შესაცვლელად ძალიან ბევრი ტექსტური ობიექტია @msg Too many pieces to make at once ერთდროულად შესაქმნელად ძალიან ბევრი ნაწილია @msg Select horizontal point or area type whose text fits აირჩიეთ ჰორიზონტალური წერტილის ან არის ტექსტი, რომლის ტექსტიც ეტევა @@ -6392,9 +6417,9 @@ breakText Glyphs გლიფები Fit to Corners and Ends კუთხეებსა და ბოლოებზე მორგება Remove Gaps ღრეჩოების მოცილება @msg that makes too many pieces (at most 100000): make the dashes longer ეს ძალიან ბევრ ნაწილს იძლევა (მაქსიმუმ 100000): გაზარდეთ ტირეების სიგრძე -@msg select paths, or segments, to divide აირჩიეთ დასაყოფი კონტურები ან სეგმენტები +@msg select paths, or segments, to divide აირჩიეთ დასაყოფი ტრაექტორიები ან სეგმენტები # Path Precision Tools add-on: Expand Dashes into Paths. - Expand Dashes into Paths ტირეების კონტურებად გაშლა + Expand Dashes into Paths ტირეების ტრაექტორიებად გაშლა @msg select paths with dashed strokes to expand აირჩიეთ გასაშლელი ტირეებიანი ხაზის კონტურები # Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy. Document Tools დოკუმენტის ხელსაწყოები @@ -6440,6 +6465,16 @@ breakText Glyphs გლიფები @msg path must be a list of child indices path უნდა იყოს შვილობილი ინდექსების სია @msg no object at path {shown} in the symbol's art სიმბოლოს გრაფიკაში გზაზე {shown} ობიექტი არ არის @msg give fill, stroke, opacity and/or blend მიუთითეთ fill, stroke, opacity და/ან blend +@msg {key} cannot be set on a guide {key} ვერ დაყენდება დამხმარე ხაზზე +@msg {key} cannot be set on a blend {key} ვერ დაყენდება გადასვლაზე +@msg {key} cannot be set on an envelope {key} ვერ დაყენდება გარსზე +@msg {key} cannot be set on a gradient mesh {key} ვერ დაყენდება გრადიენტულ ბადეზე +@msg {key} cannot be set on a Repeat {key} ვერ დაყენდება გამეორებაზე +@msg {key} cannot be set on a placed document {key} ვერ დაყენდება განთავსებულ დოკუმენტზე +@msg {key} cannot be set on an Intertwine group {key} ვერ დაყენდება გადახლართვის ჯგუფზე +@msg stroke cannot be set on a symbol instance stroke ვერ დაყენდება სიმბოლოს ეგზემპლარზე +@msg fill on a symbol instance must be a solid color or none სიმბოლოს ეგზემპლარის fill უნდა იყოს ერთფეროვანი ფერი ან none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} ვერ დაყენდება გამოსახულებაზე „ფიგურად გარდაქმნის“ ან „ობიექტის კონტურებად გარდაქმნის“ ეფექტის გარეშე @msg pass newName, registration, exportType, nineSlice and/or type მიუთითეთ newName, registration, exportType, nineSlice და/ან type Text Flow ტექსტის დინება By Rows სტრიქონების მიხედვით @@ -6469,6 +6504,7 @@ breakText Glyphs გლიფები Create Swatch Legend ფერის ნიმუშების ლეგენდის შექმნა Create Swatch Legend… ფერის ნიმუშების ლეგენდის შექმნა… HEX HEX + Help Bar დახმარების ზოლი Check for Updates… განახლებების შემოწმება… VectorCraft Updates VectorCraft-ის განახლებები Preparing the image… გამოსახულების მომზადება… @@ -6483,6 +6519,7 @@ font-script Japanese იაპონური Japanese Double Postcard იაპონური ორმაგი ღია ბარათი Japanese Envelope #3 იაპონური კონვერტი #3 Japanese Envelope #4 იაპონური კონვერტი #4 +@msg image '{key}' has no pixels and was left out გამოსახულებას '{key}' პიქსელები არ აქვს და გამოტოვდა @msg not an RTF file: it doesn't start with {\\rtf RTF ფაილი არ არის: არ იწყება {\\rtf-ით # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools ტექსტის ხელსაწყოები @@ -6494,6 +6531,54 @@ font-script Japanese იაპონური Bow რკალი Spike პიკი Skip Spaces ჰარების გამოტოვება + +# The strings the report listed as missing for ka. + 1 Layer შრე: 1 + Art and Bright Colors მხატვრული და კაშკაშა ფერები + Automate ავტომატიზაცია + Color Table (4 colors) ფერების ცხრილი (ფერი: 4) + Current Colors (0) მიმდინარე ფერები (0) + Differences from VectorCraft Default: განსხვავებები VectorCraft-ის ნაგულისხმევთან: + Differences from [Default]: განსხვავებები [ნაგულისხმევი]-თან: + Dot Gain 15% Dot Gain 15% + Expand 1 Selected 1 მონიშნული ობიექტის გაშლა + Graphic Styles (4) გრაფიკული სტილები (4) + Gray Gamma 2.2 Gray Gamma 2.2 + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Icons: Lucide (ISC) + VectorCraft. ArtCraft-ის ნაწილი. MIT OR Apache-2.0. შრიფტები: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). ხატულები: Lucide (ISC) + VectorCraft. + Phone 375×812 ტელეფონი 375×812 + Photo Low Fidelity დაბალი სიზუსტის ფოტო + Print & Proof ბეჭდვა და კორექტურა + Printing & Proofing ბეჭდვა და კორექტურა + RECENT (1) ბოლოდროინდელი (1) + Reset Automation Automation-ის ჩამოყრა + Reset Essentials Classic Essentials Classic-ის ჩამოყრა + Reset Layout Layout-ის ჩამოყრა + Reset Painting Painting-ის ჩამოყრა + Reset Printing and Proofing Printing and Proofing-ის ჩამოყრა + Reset Tracing Tracing-ის ჩამოყრა + Reset Typography Typography-ის ჩამოყრა + Reset Web Web-ის ჩამოყრა + Restricted Colors შეზღუდული ფერები + Right-click an item to move it into this group. ამ ჯგუფში გადასატანად დაწკაპეთ ელემენტზე მარჯვენა ღილაკით. + Scale X მასშტაბი X + Scale X: მასშტაბი X: + Scale Y მასშტაბი Y + Scale Y: მასშტაბი Y: + Selected Styles Only (0) მხოლოდ მონიშნული სტილები (0) + Selected Swatches Only (0) მხოლოდ მონიშნული ფერის ნიმუშები (0) + Spot Colors (0) სპოტ-ფერები (0) + Tablet 1024×768 პლანშეტი 1024×768 + Video 1920×1080 ვიდეო 1920×1080 + Video 3840×2160 ვიდეო 3840×2160 + Web 1280×720 ვები 1280×720 + [High Resolution] [მაღალი გარჩევადობა] + [Low Resolution] [დაბალი გარჩევადობა] + [Medium Resolution] [საშუალო გარჩევადობა] + [PDF/X-1a:2001] [PDF/X-1a:2001] + [PDF/X-3:2002] [PDF/X-3:2002] + [PDF/X-4:2010] [PDF/X-4:2010] + [VectorCraft Default] [VectorCraft-ის ნაგულისხმევი] +@msg give corners of the path: anchors between two straight sides მიუთითეთ ტრაექტორიის კუთხეები: საყრდენი წერტილები ორ სწორ გვერდს შორის Release Type on a Path… ტექსტის ტრაექტორიიდან მოხსნა… Release Type on a Path ტექსტის ტრაექტორიიდან მოხსნა Text and Path ტექსტი და ტრაექტორია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30a409b72..f393068c3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -348,6 +348,13 @@ digits Malayalam 말라얄람어 Apply Bullets 글머리 기호 적용 Numbered List 번호 매기기 목록 Apply Numbers 번호 매기기 적용 + Bullets and Numbering… 글머리 기호 및 번호 매기기… + Numbers 번호 + Level: 수준: + Bullet Character: 글머리 기호 문자: + Number Style: 번호 스타일: + Left Indent: 왼쪽 들여쓰기: + First Line Indent: 첫 줄 들여쓰기: Convert to Text 텍스트로 변환 Filled Dot 채운 점 Dash 대시 @@ -976,6 +983,10 @@ digits Malayalam 말라얄람어 Anti-alias 안티 앨리어싱 Anti-alias Rasters 래스터 안티 앨리어싱 Anti-aliased Artwork 안티 앨리어싱 아트워크 + Anti-aliasing Method 안티 앨리어싱 방식 +antiAlias Sharp 날카롭게 + Crisp 또렷하게 + Strong 강하게 Anti-aliasing: 안티 앨리어싱: Appearance Item 모양새 항목 Appearance Panel 모양새 패널 @@ -4202,6 +4213,7 @@ theme Appearance 외관 @msg no point {i} (the gradient has {_1}) 점 {i}이(가) 없습니다(그레이디언트의 점 수: {_1}) @msg no segment {segment} on line {line} 선 {line}에 세그먼트 {segment}이(가) 없습니다 @msg unknown freeform mode `{m}` (points, lines) 알 수 없는 자유형 모드 `{m}`(points, lines) +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias`는 "none", "sharp", "crisp" 또는 "strong"이어야 합니다 @msg `aspect` must be a number (%) `aspect`는 숫자(%)여야 합니다 @msg `gradient` must be an object `gradient`는 개체여야 합니다 @msg `groupColors` must be an array `groupColors`는 배열이어야 합니다 @@ -5330,6 +5342,11 @@ lighting Light 광원 Snap to Perpendicular 수직선에 스냅 @msg unknown snap option `{key}` (use {options}) 알 수 없는 스냅 옵션 `{key}` ({options} 중에서 사용하세요) Gap Length: 간격 길이: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. 단색 로고 모드는 keepColors를 사용하지 않습니다. logoColors에 지정한 색상 또는 찾은 색상을 추적합니다. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 아무것도 추적하지 않았습니다. 추적에서 찾은 색상 중 keepColors({_1})와 일치하는 색상이 없습니다. +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 아무것도 추적하지 않았습니다. 색상 무시가 keepColors에 나열된 색상의 모든 영역을 제외합니다. +@msg Nothing was traced: the colors that match keepColors produce no paths. 아무것도 추적하지 않았습니다. keepColors와 일치하는 색상에서 패스가 만들어지지 않습니다. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: 추적에서 찾은 색상 중 {c}와 일치하는 색상이 없어 이 색상은 추적하지 않습니다. @msg `colorGroup` needs `library: "document"` `colorGroup`에는 `library: "document"`가 필요합니다 @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup`에는 `palette: "documentLibrary"`가 필요합니다 @msg `colorGroup` needs `mode: "color"` `colorGroup`에는 `mode: "color"`가 필요합니다 @@ -5477,6 +5494,14 @@ axis Y Axis Y축 @msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”에 출력에 필요한 `{p}` 권한이 부여되지 않았습니다. 아무것도 변경되지 않았습니다 +@msg a heavy filter runs in the background, so it can't declare `commands` 무거운 필터는 백그라운드에서 실행되므로 `commands`를 선언할 수 없습니다 +@msg at most {MAX_COMMANDS} `commands` `commands`는 최대 {MAX_COMMANDS}개입니다 +@msg command {name} is declared twice 명령 {name}이(가) 두 번 선언되었습니다 +@msg command {name}: `label` must be 1-64 printable characters 명령 {name}: `label`은 인쇄 가능한 문자 1-64자여야 합니다 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 명령 {name}: a-z 0-9 _로 이루어지고 문자로 시작하는 1-{MAX_COMMAND_ID}자 ID를 사용하세요 +@msg command {name}: {e} 명령 {name}: {e} +@msg only a filter or a live effect declares `commands` 필터나 라이브 효과만 `commands`를 선언합니다 +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 # Actions panel: action sets (#3712). New Set… 새 세트… @@ -6023,6 +6048,16 @@ breakText Glyphs 글리프 @msg path must be a list of child indices path는 자식 인덱스 목록이어야 합니다 @msg no object at path {shown} in the symbol's art 심볼 아트의 경로 {shown}에 개체가 없습니다 @msg give fill, stroke, opacity and/or blend fill, stroke, opacity 및/또는 blend를 지정하세요 +@msg {key} cannot be set on a guide 안내선에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a blend 블렌드에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on an envelope 엔벨로프에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a gradient mesh 그레이디언트 메시에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a Repeat 반복에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a placed document 가져온 문서에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on an Intertwine group 교차 그룹에는 {key}을(를) 설정할 수 없습니다 +@msg stroke cannot be set on a symbol instance 심볼 인스턴스에는 stroke를 설정할 수 없습니다 +@msg fill on a symbol instance must be a solid color or none 심볼 인스턴스의 fill은 단색 또는 none이어야 합니다 +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 모양으로 변환 또는 개체 윤곽선화 효과가 없는 이미지에는 {key}을(를) 설정할 수 없습니다 @msg pass newName, registration, exportType, nineSlice and/or type newName, registration, exportType, nineSlice 및/또는 type을 지정하세요 Text Flow 텍스트 흐름 By Rows 행 방향 @@ -6052,6 +6087,7 @@ breakText Glyphs 글리프 Create Swatch Legend 견본 범례 만들기 Create Swatch Legend… 견본 범례 만들기… HEX HEX + Help Bar 도움말 표시줄 # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools 텍스트 도구 Text Tweaker… 텍스트 조정… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c771d8b17..94ba3c954 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -313,6 +313,13 @@ Apply Bullets Opsommingstekens toepassen Numbered List Genummerde lijst Apply Numbers Nummering toepassen + Bullets and Numbering… Opsommingstekens en nummering… + Numbers Nummers + Level: Niveau: + Bullet Character: Opsommingsteken: + Number Style: Nummerstijl: + Left Indent: Links inspringen: + First Line Indent: Eerste regel inspringen: Convert to Text Converteren naar tekst Filled Dot Gevulde stip Dash Streepje @@ -2564,6 +2571,10 @@ digits Malayalam Malayalam < 1 sec < 1 sec [Unnamed] [Naamloos] Amount: Hoeveelheid: + Anti-aliasing Method Anti-aliasingmethode +antiAlias Sharp Scherp + Crisp Helder + Strong Krachtig Anti-aliasing: Anti-aliasing: Artboard: Tekenbord: Color Table Kleurentabel @@ -4118,6 +4129,7 @@ imageTrace Outline Contour @msg Split Into Grid: select paths large enough for the grid In raster verdelen: selecteer paden die groot genoeg zijn voor het raster @msg Targa files are RGB: colorModel rgb Targa-bestanden zijn RGB: colorModel rgb @msg Text Wrap: select a wrap object Tekstomloop: selecteer een omloopobject +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` moet "none", "sharp", "crisp" of "strong" zijn @msg `aspect` must be a number (%) `aspect` moet een getal zijn (%) @msg `at` must be [x, y] `at` moet [x, y] zijn @msg `color` must be a colour `color` moet een kleur zijn @@ -5677,6 +5689,11 @@ swatch Bright Violet Felviolet @msg too many library panels were opened in this session er zijn in deze sessie te veel bibliotheekpanelen geopend @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. De afbeelding is {_1} × {_2} pixels, meer dan {LARGE_TRACE_SIDE} aan één zijde: ze wordt getraceerd, maar langzamer. Een voorinstelling met minder detail of een kleinere kopie van de afbeelding traceert sneller. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). De afbeelding is getraceerd op {_1} × {_2} pixels, verkleind van {_3} × {_4} (maximaal {_5} megapixels). +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. De modus Plat logo gebruikt keepColors niet: hij traceert de kleuren die in logoColors staan, of de kleuren die hij vindt. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Er is niets getraceerd: geen van de kleuren die het traceren vond, komt overeen met keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Er is niets getraceerd: Kleur negeren laat elk gebied van de kleuren in keepColors weg. +@msg Nothing was traced: the colors that match keepColors produce no paths. Er is niets getraceerd: de kleuren die overeenkomen met keepColors leveren geen paden op. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: geen van de kleuren die het traceren vond, komt overeen met {c}, dus daarvan wordt niets getraceerd. @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. De modus Plat logo heeft de afbeelding getraceerd op {_1} × {_2} pixels, door middeling verkleind van {_3} × {_4}. Artboard {n} of {total} Tekengebied {n} van {total} File {n} of {total} Bestand {n} van {total} @@ -6319,6 +6336,16 @@ breakText Glyphs Glyphs @msg path must be a list of child indices path moet een lijst met onderliggende indexen zijn @msg no object at path {shown} in the symbol's art geen object op pad {shown} in de illustratie van het symbool @msg give fill, stroke, opacity and/or blend geef fill, stroke, opacity en/of blend op +@msg {key} cannot be set on a guide {key} kan niet worden ingesteld op een hulplijn +@msg {key} cannot be set on a blend {key} kan niet worden ingesteld op een vermenging +@msg {key} cannot be set on an envelope {key} kan niet worden ingesteld op een envelop +@msg {key} cannot be set on a gradient mesh {key} kan niet worden ingesteld op een verloopraster +@msg {key} cannot be set on a Repeat {key} kan niet worden ingesteld op een herhaling +@msg {key} cannot be set on a placed document {key} kan niet worden ingesteld op een geplaatst document +@msg {key} cannot be set on an Intertwine group {key} kan niet worden ingesteld op een verstrengelde groep +@msg stroke cannot be set on a symbol instance stroke kan niet worden ingesteld op een symboolinstantie +@msg fill on a symbol instance must be a solid color or none fill van een symboolinstantie moet een egale kleur of none zijn +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kan niet worden ingesteld op een afbeelding zonder het effect Converteren naar vorm of Object omtrekken @msg pass newName, registration, exportType, nineSlice and/or type geef newName, registration, exportType, nineSlice en/of type op Text Flow Tekstdoorloop By Rows Per rij @@ -6348,6 +6375,7 @@ breakText Glyphs Glyphs Create Swatch Legend Stalenlegenda maken Create Swatch Legend… Stalenlegenda maken… HEX HEX + Help Bar Helpbalk # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Teksthulpmiddelen Text Tweaker… Tekst bijstellen… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index bdd887ecd..160d3a0f0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -300,6 +300,13 @@ Apply Bullets Zastosuj punktory Numbered List Lista numerowana Apply Numbers Zastosuj numerację + Bullets and Numbering… Punktory i numeracja… + Numbers Numery + Level: Poziom: + Bullet Character: Znak punktora: + Number Style: Styl numeracji: + Left Indent: Wcięcie z lewej: + First Line Indent: Wcięcie pierwszego wiersza: Convert to Text Konwertuj na tekst Filled Dot Pełna kropka Dash Myślnik @@ -2670,6 +2677,10 @@ digits Malayalam Malajalam < 1 sec < 1 s [Unnamed] [Bez nazwy] Amount: Wartość: + Anti-aliasing Method Metoda wygładzania +antiAlias Sharp Ostre + Crisp Wyraźne + Strong Mocne Anti-aliasing: Wygładzanie: Artboard: Obszar kompozycji: Color Table Tablica kolorów @@ -4234,6 +4245,7 @@ imageTrace Outline Kontur @msg Split Into Grid: select paths large enough for the grid Podziel na siatkę: zaznacz ścieżki wystarczająco duże dla siatki @msg Targa files are RGB: colorModel rgb Pliki Targa są w RGB: colorModel rgb @msg Text Wrap: select a wrap object Oblewanie tekstem: zaznacz obiekt oblewany +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` musi mieć wartość "none", "sharp", "crisp" lub "strong" @msg `aspect` must be a number (%) `aspect` musi być liczbą (%) @msg `at` must be [x, y] `at` musi mieć postać [x, y] @msg `color` must be a colour `color` musi być kolorem @@ -5609,6 +5621,11 @@ mark-style Japanese Japońskie Snap to Perpendicular Przyciągaj do prostopadłych @msg unknown snap option `{key}` (use {options}) nieznana opcja przyciągania `{key}` (użyj: {options}) Gap Length: Długość przerwy: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Tryb Płaskie logo nie używa keepColors: obrysowuje kolory podane w logoColors lub kolory, które znajdzie. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nic nie zostało obrysowane: żaden z kolorów znalezionych podczas obrysowania nie pasuje do keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nic nie zostało obrysowane: Ignoruj kolor pomija każdy obszar kolorów wymienionych w keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nic nie zostało obrysowane: kolory pasujące do keepColors nie dają żadnych ścieżek. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: żaden z kolorów znalezionych podczas obrysowania nie pasuje do {c}, więc nic z niego nie jest obrysowywane. @msg `colorGroup` needs `library: "document"` `colorGroup` wymaga `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` wymaga `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` wymaga `mode: "color"` @@ -5753,6 +5770,14 @@ axis Y Axis Oś Y @msg unknown permission `{shown}` nieznane uprawnienie `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nie otrzymała `{p}`, którego potrzebuje jej wynik: nic nie zmieniono +@msg a heavy filter runs in the background, so it can't declare `commands` ciężki filtr działa w tle, więc nie może deklarować `commands` +@msg at most {MAX_COMMANDS} `commands` najwyżej {MAX_COMMANDS} `commands` +@msg command {name} is declared twice polecenie {name} jest zadeklarowane dwa razy +@msg command {name}: `label` must be 1-64 printable characters polecenie {name}: `label` musi mieć 1-64 drukowalnych znaków +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter polecenie {name}: użyj id z 1-{MAX_COMMAND_ID} znaków spośród a-z 0-9 _, zaczynającego się od litery +@msg command {name}: {e} polecenie {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` deklaruje tylko filtr lub efekt na żywo +@msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano # Actions panel: action sets (#3712). New Set… Nowy zestaw… @@ -6297,6 +6322,16 @@ breakText Glyphs Glify @msg path must be a list of child indices path musi być listą indeksów elementów podrzędnych @msg no object at path {shown} in the symbol's art brak obiektu w ścieżce {shown} w grafice symbolu @msg give fill, stroke, opacity and/or blend podaj fill, stroke, opacity i/lub blend +@msg {key} cannot be set on a guide nie można ustawić {key} dla linii pomocniczej +@msg {key} cannot be set on a blend nie można ustawić {key} dla przejścia +@msg {key} cannot be set on an envelope nie można ustawić {key} dla koperty +@msg {key} cannot be set on a gradient mesh nie można ustawić {key} dla siatki gradientowej +@msg {key} cannot be set on a Repeat nie można ustawić {key} dla powtórzenia +@msg {key} cannot be set on a placed document nie można ustawić {key} dla umieszczonego dokumentu +@msg {key} cannot be set on an Intertwine group nie można ustawić {key} dla grupy przeplatania +@msg stroke cannot be set on a symbol instance nie można ustawić stroke dla wystąpienia symbolu +@msg fill on a symbol instance must be a solid color or none fill wystąpienia symbolu musi być jednolitym kolorem lub none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it nie można ustawić {key} dla obrazu bez efektu Konwertuj na kształt lub Kontur obiektu @msg pass newName, registration, exportType, nineSlice and/or type podaj newName, registration, exportType, nineSlice i/lub type Text Flow Przepływ tekstu By Rows Wierszami @@ -6326,6 +6361,7 @@ breakText Glyphs Glify Create Swatch Legend Utwórz legendę próbek Create Swatch Legend… Utwórz legendę próbek… HEX HEX + Help Bar Pasek pomocy # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Narzędzia tekstu Text Tweaker… Strojenie tekstu… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 822a0d59e..4b2791c54 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -198,6 +198,10 @@ Anti-alias Suavizar bordas Anti-alias Rasters Suavizar Rasters Anti-aliased Artwork Arte Suavizada + Anti-aliasing Method Método de Suavização +antiAlias Sharp Nítido + Crisp Definido + Strong Forte Anti-aliasing: Suavização: Appearance Aparência Appearance Attribute Atributo de Aparência @@ -338,6 +342,13 @@ Apply Bullets Aplicar Marcadores Numbered List Lista Numerada Apply Numbers Aplicar Numeração + Bullets and Numbering… Marcadores e Numeração… + Numbers Números + Level: Nível: + Bullet Character: Caractere do Marcador: + Number Style: Estilo de Número: + Left Indent: Recuo Esquerdo: + First Line Indent: Recuo da Primeira Linha: Convert to Text Converter em Texto Filled Dot Ponto Preenchido Dash Travessão @@ -3683,6 +3694,7 @@ axis Both Ambos @msg Split Into Grid: select paths large enough for the grid Dividir em grade: selecione traçados grandes o bastante para a grade @msg Targa files are RGB: colorModel rgb arquivos Targa são RGB: colorModel rgb @msg Text Wrap: select a wrap object Quebra de texto: selecione um objeto de quebra +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` deve ser "none", "sharp", "crisp" ou "strong" @msg `aspect` must be a number (%) `aspect` deve ser um número (%) @msg `at` must be [x, y] `at` deve ser [x, y] @msg `color` must be a colour `color` deve ser uma cor @@ -5671,6 +5683,11 @@ mark-style Japanese Japonesas Snap to Perpendicular Ajustar à Perpendicular @msg unknown snap option `{key}` (use {options}) opção de ajuste desconhecida `{key}` (use {options}) Gap Length: Comprimento da Lacuna: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. O modo Logotipo Plano não usa keepColors: ele traça as cores indicadas em logoColors ou as cores que encontra. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nada foi traçado: nenhuma das cores que o traçado encontrou corresponde a keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nada foi traçado: Ignorar Cor deixa de fora todas as áreas das cores listadas em keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nada foi traçado: as cores que correspondem a keepColors não geram nenhum caminho. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: nenhuma das cores que o traçado encontrou corresponde a {c}, então nada dela é traçado. @msg `colorGroup` needs `library: "document"` `colorGroup` requer `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` requer `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` requer `mode: "color"` @@ -5815,6 +5832,14 @@ axis Y Axis Eixo Y @msg unknown permission `{shown}` permissão desconhecida `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” não recebeu `{p}`, de que sua saída precisa: nada foi alterado +@msg a heavy filter runs in the background, so it can't declare `commands` um filtro pesado é executado em segundo plano, então não pode declarar `commands` +@msg at most {MAX_COMMANDS} `commands` no máximo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice o comando {name} está declarado duas vezes +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve ter 1-64 caracteres imprimíveis +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: use um id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que comece com uma letra +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` só um filtro ou um efeito dinâmico declara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado # Actions panel: action sets (#3712). New Set… Novo Conjunto… @@ -6360,6 +6385,16 @@ breakText Glyphs Glifos @msg path must be a list of child indices path deve ser uma lista de índices de filhos @msg no object at path {shown} in the symbol's art nenhum objeto no caminho {shown} na arte do símbolo @msg give fill, stroke, opacity and/or blend informe fill, stroke, opacity e/ou blend +@msg {key} cannot be set on a guide {key} não pode ser definido em uma guia +@msg {key} cannot be set on a blend {key} não pode ser definido em uma mesclagem +@msg {key} cannot be set on an envelope {key} não pode ser definido em um envelope +@msg {key} cannot be set on a gradient mesh {key} não pode ser definido em uma malha de gradiente +@msg {key} cannot be set on a Repeat {key} não pode ser definido em uma repetição +@msg {key} cannot be set on a placed document {key} não pode ser definido em um documento colocado +@msg {key} cannot be set on an Intertwine group {key} não pode ser definido em um grupo entrelaçado +@msg stroke cannot be set on a symbol instance stroke não pode ser definido em uma instância de símbolo +@msg fill on a symbol instance must be a solid color or none o fill de uma instância de símbolo deve ser uma cor sólida ou none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} não pode ser definido em uma imagem sem o efeito Converter em Forma ou Contornar Objeto @msg pass newName, registration, exportType, nineSlice and/or type informe newName, registration, exportType, nineSlice e/ou type # Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31). on the canvas to create one na tela para criar um @@ -6544,6 +6579,7 @@ swatch Yellow Green Verde-amarelado Create Swatch Legend Criar Legenda de Amostras Create Swatch Legend… Criar Legenda de Amostras… HEX HEX + Help Bar Barra de ajuda # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Ferramentas de texto Text Tweaker… Ajustador de texto… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 54158fab4..5c91e1b8d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -196,6 +196,10 @@ Anti-Alias Сглаживание Anti-alias Rasters Сглаживать растровые изображения Anti-aliased Artwork Сглаженный рисунок + Anti-aliasing Method Метод сглаживания +antiAlias Sharp Резкое + Crisp Чёткое + Strong Сильное Anti-aliasing: Сглаживание: Any Kind Любой тип Font Technology Технология шрифта @@ -365,6 +369,13 @@ font-technology Variable Вариативные Apply Bullets Применить маркеры Numbered List Нумерованный список Apply Numbers Применить нумерацию + Bullets and Numbering… Маркеры и нумерация… + Numbers Номера + Level: Уровень: + Bullet Character: Символ маркера: + Number Style: Стиль нумерации: + Left Indent: Отступ слева: + First Line Indent: Отступ первой строки: Convert to Text Преобразовать в текст Filled Dot Закрашенная точка Dash Тире @@ -4139,6 +4150,7 @@ axis Both Оба @msg Targa files are RGB: colorModel rgb файлы Targa являются RGB: colorModel rgb @msg Text Wrap: select a wrap object Обтекание текстом: выделите объект обтекания @msg This shortcut file is in a form VectorCraft can't read. Этот файл сочетаний клавиш записан в формате, который VectorCraft не может прочитать. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` должно быть "none", "sharp", "crisp" или "strong" @msg `aspect` must be a number (%) `aspect` должно быть числом (%) @msg `at` must be [x, y] `at` должно быть [x, y] @msg `color` must be a colour `color` должно быть цветом @@ -5364,6 +5376,11 @@ lighting Light Свет Snap to Perpendicular Привязать к перпендикуляру @msg unknown snap option `{key}` (use {options}) неизвестный параметр привязки `{key}` (допустимо: {options}) Gap Length: Длина зазора: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Режим «Плоский логотип» не использует keepColors: он трассирует цвета, указанные в logoColors, или найденные им цвета. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Ничего не трассировано: ни один из найденных при трассировке цветов не соответствует keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Ничего не трассировано: «Игнорировать цвет» исключает все области цветов, перечисленных в keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Ничего не трассировано: цвета, соответствующие keepColors, не дают ни одного контура. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ни один из найденных при трассировке цветов не соответствует {c}, поэтому из него ничего не трассируется. @msg `colorGroup` needs `library: "document"` для `colorGroup` нужен `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` для `colorGroup` нужен `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` для `colorGroup` нужен `mode: "color"` @@ -5511,6 +5528,14 @@ axis Y Axis Ось Y @msg unknown permission `{shown}` неизвестное разрешение `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не получил разрешение `{p}`, нужное для его результата: ничего не изменено +@msg a heavy filter runs in the background, so it can't declare `commands` тяжёлый фильтр выполняется в фоне, поэтому не может объявлять `commands` +@msg at most {MAX_COMMANDS} `commands` не более {MAX_COMMANDS} `commands` +@msg command {name} is declared twice команда {name} объявлена дважды +@msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` должен содержать 1-64 печатаемых символа +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: используйте id из 1-{MAX_COMMAND_ID} символов a-z 0-9 _, начинающийся с буквы +@msg command {name}: {e} команда {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` объявляет только фильтр или динамический эффект +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено # Actions panel: action sets (#3712). New Set… Новый набор… @@ -6419,6 +6444,16 @@ breakText Glyphs Глифы @msg path must be a list of child indices path должен быть списком индексов дочерних объектов @msg no object at path {shown} in the symbol's art в графике символа нет объекта по пути {shown} @msg give fill, stroke, opacity and/or blend укажите fill, stroke, opacity и/или blend +@msg {key} cannot be set on a guide {key} нельзя задать для направляющей +@msg {key} cannot be set on a blend {key} нельзя задать для перехода +@msg {key} cannot be set on an envelope {key} нельзя задать для оболочки +@msg {key} cannot be set on a gradient mesh {key} нельзя задать для градиентной сетки +@msg {key} cannot be set on a Repeat {key} нельзя задать для повторения +@msg {key} cannot be set on a placed document {key} нельзя задать для помещенного документа +@msg {key} cannot be set on an Intertwine group {key} нельзя задать для группы переплетения +@msg stroke cannot be set on a symbol instance stroke нельзя задать для экземпляра символа +@msg fill on a symbol instance must be a solid color or none fill экземпляра символа должен быть сплошным цветом или none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} нельзя задать для изображения без эффекта «Преобразовать в фигуру» или «Обвести объект» @msg pass newName, registration, exportType, nineSlice and/or type укажите newName, registration, exportType, nineSlice и/или type Text Flow Поток текста By Rows По строкам @@ -6560,6 +6595,7 @@ breakText Glyphs Глифы digits Hindi Хинди font-script Japanese Японский shape Open Открытый + Help Bar Панель подсказок # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Инструменты текста Text Tweaker… Настройка текста… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a872e969a..81e5d2143 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -186,6 +186,10 @@ Anti-Alias Kantutjämning Anti-alias Rasters Kantutjämna rasterbilder Anti-aliased Artwork Kantutjämnad grafik + Anti-aliasing Method Kantutjämningsmetod +antiAlias Sharp Skarp + Crisp Klar + Strong Kraftig Anti-aliasing: Kantutjämning: Any Kind Alla typer Font Technology Typsnittsteknik @@ -356,6 +360,13 @@ font-technology Variable Variabla Apply Bullets Använd punkter Numbered List Numrerad lista Apply Numbers Använd numrering + Bullets and Numbering… Punkter och numrering… + Numbers Nummer + Level: Nivå: + Bullet Character: Punkttecken: + Number Style: Nummerformat: + Left Indent: Vänsterindrag: + First Line Indent: Indrag för första raden: Convert to Text Konvertera till text Filled Dot Fylld punkt Dash Tankstreck @@ -4013,6 +4024,7 @@ axis Both Båda @msg Targa files are RGB: colorModel rgb Targa-filer är RGB: colorModel rgb @msg Text Wrap: select a wrap object Textombrytning: markera ett ombrytningsobjekt @msg This shortcut file is in a form VectorCraft can't read. Den här kortkommandofilen har ett format som VectorCraft inte kan läsa. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` måste vara "none", "sharp", "crisp" eller "strong" @msg `aspect` must be a number (%) `aspect` måste vara ett tal (%) @msg `at` must be [x, y] `at` måste vara [x, y] @msg `color` must be a colour `color` måste vara en färg @@ -5778,6 +5790,11 @@ swatch Yellow Green Gulgrön Snap to Perpendicular Fäst mot vinkelrät @msg unknown snap option `{key}` (use {options}) okänt fästalternativ `{key}` (använd {options}) Gap Length: Lucklängd: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Läget Platt logotyp använder inte keepColors: det vektoriserar färgerna som anges i logoColors eller färgerna det hittar. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Ingenting vektoriserades: ingen av färgerna som vektoriseringen hittade motsvarar keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Ingenting vektoriserades: Ignorera färg utelämnar alla områden med färgerna som keepColors anger. +@msg Nothing was traced: the colors that match keepColors produce no paths. Ingenting vektoriserades: färgerna som matchar keepColors ger inga banor. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ingen av färgerna som vektoriseringen hittade motsvarar {c}, så ingenting av den vektoriseras. @msg `colorGroup` needs `library: "document"` `colorGroup` kräver `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` kräver `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` kräver `mode: "color"` @@ -5923,6 +5940,14 @@ axis Y Axis Y-axel @msg unknown permission `{shown}` okänd behörighet `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades +@msg a heavy filter runs in the background, so it can't declare `commands` ett tungt filter körs i bakgrunden, så det kan inte deklarera `commands` +@msg at most {MAX_COMMANDS} `commands` högst {MAX_COMMANDS} `commands` +@msg command {name} is declared twice kommandot {name} deklareras två gånger +@msg command {name}: `label` must be 1-64 printable characters kommando {name}: `label` måste ha 1-64 utskrivbara tecken +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter kommando {name}: använd ett id med 1-{MAX_COMMAND_ID} tecken av a-z 0-9 _ som börjar med en bokstav +@msg command {name}: {e} kommando {name}: {e} +@msg only a filter or a live effect declares `commands` bara ett filter eller en live-effekt deklarerar `commands` +@msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades # Actions panel: action sets (#3712). New Set… Ny uppsättning… @@ -6494,6 +6519,16 @@ breakText Glyphs Glyfer @msg path must be a list of child indices path måste vara en lista med underordnade index @msg no object at path {shown} in the symbol's art inget objekt vid sökvägen {shown} i symbolens bild @msg give fill, stroke, opacity and/or blend ange fill, stroke, opacity och/eller blend +@msg {key} cannot be set on a guide {key} kan inte anges för en stödlinje +@msg {key} cannot be set on a blend {key} kan inte anges för en blandning +@msg {key} cannot be set on an envelope {key} kan inte anges för ett kuvert +@msg {key} cannot be set on a gradient mesh {key} kan inte anges för ett gradientnät +@msg {key} cannot be set on a Repeat {key} kan inte anges för en upprepning +@msg {key} cannot be set on a placed document {key} kan inte anges för ett placerat dokument +@msg {key} cannot be set on an Intertwine group {key} kan inte anges för en sammanflätad grupp +@msg stroke cannot be set on a symbol instance stroke kan inte anges för en symbolinstans +@msg fill on a symbol instance must be a solid color or none fill för en symbolinstans måste vara en enfärgad färg eller none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kan inte anges för en bild utan effekten Konvertera till form eller Konvertera objekt till konturer @msg pass newName, registration, exportType, nineSlice and/or type ange newName, registration, exportType, nineSlice och/eller type Text Flow Textflöde By Rows Radvis @@ -6527,6 +6562,7 @@ breakText Glyphs Glyfer # The strings the report listed as missing for sv. Font Size: Teckenstorlek: digits Hindi Hindi + Help Bar Hjälpfält # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Textverktyg Text Tweaker… Textjustering… diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index d8a34883a..447f086be 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -194,6 +194,19 @@ fn marquee_selection_labels_are_in_complete_catalogs() { } } +/// The Character panel's Anti-aliasing Method label and its choices, with the Sharp method's +/// own `antiAlias` row apart from the plain "Sharp" corner row. +#[test] +fn anti_aliasing_method_labels_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + let cat = lang.catalog(); + for label in ["Anti-aliasing Method", "Crisp", "Strong"] { + assert!(cat.plain(label).is_some(), "{} is missing {label}", lang.code); + } + assert!(cat.contextual("antiAlias", "Sharp").is_some(), "{} is missing the antiAlias Sharp row", lang.code); + } +} + /// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then /// `other`/`many`). #[test] @@ -1852,6 +1865,42 @@ fn width(p: &Value, cmd: &str, key: &str) -> Result { assert_eq!(c.message("`width` must be a number").map(|m| m.1), Some(vec![("key", "width")])); } +/// The warnings of an Image Trace with keepColors, which the status bar shows and the scan above +/// doesn't read, are translated in each language of [`COMPLETE_MESSAGES`], with the colors kept. +#[test] +fn the_keep_colors_trace_warnings_are_translated() { + use serde_json::json; + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + // A white field with a black square in it. + let img = image::RgbaImage::from_fn(40, 40, |x, y| { + image::Rgba(if (10..30).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255, 255, 255, 255] }) + }); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + let mut warnings = vec![]; + for (params, shown) in [ + (json!({"mode": "color", "palette": "limited", "colors": 2, "keepColors": ["#ff00ff", "#00ffff"]}), "#ff00ff, #00ffff"), + (json!({"mode": "color", "palette": "limited", "colors": 2, "keepColors": ["#000000", "#00ffff"]}), "#00ffff"), + (json!({"mode": "color", "palette": "limited", "colors": 2, "keepColors": ["#000000"], "ignoreWhite": true, "ignoreColor": "#000000"}), ""), + (json!({"mode": "logo", "keepColors": ["#000000"]}), ""), + ] { + s.execute("file.place", &json!({"name": "square.png", "dataBase64": data, "link": false})).unwrap(); + let r = s.execute("imageTrace.makeAndExpand", &json!({ "params": params })).unwrap(); + let w = r["warnings"].as_array().and_then(|w| w.first()).and_then(|w| w.as_str()).unwrap_or_else(|| panic!("{params}: {r}")); + warnings.push((w.to_string(), shown)); + } + // Listed colors that were traced and produced no paths (no square image gives that). + warnings.push(("Nothing was traced: the colors that match keepColors produce no paths.".to_string(), "")); + for (warning, shown) in &warnings { + for code in COMPLETE_MESSAGES { + let tr = message(Lang::from_code(code).unwrap(), warning); + assert!(tr != *warning && tr.contains(shown) && tr.contains("keepColors"), "{code}: {tr}"); + } + } +} + /// The parameter errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add /// Spine Point and Delete Spine Point, which Direct Selection, the Pen and the Anchor Point tool /// send) say what is wrong. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 300167a97..be71e9184 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -183,6 +183,10 @@ Anti-Alias Kenar Yumuşatma Anti-alias Rasters Kenar Yumuşatma Rasterleri Anti-aliased Artwork Kenar Yumuşatılmış Sanat Çalışması + Anti-aliasing Method Kenar Yumuşatma Yöntemi +antiAlias Sharp Keskin + Crisp Net + Strong Güçlü Anti-aliasing: Kenar yumuşatma: Any Kind Her Türlü Font Technology Yazı Tipi Teknolojisi @@ -351,6 +355,13 @@ font-technology Variable Değişken Apply Bullets Madde İşaretlerini Uygula Numbered List Numaralı Liste Apply Numbers Numaralandırmayı Uygula + Bullets and Numbering… Madde İşaretleri ve Numaralandırma… + Numbers Numaralar + Level: Düzey: + Bullet Character: Madde İşareti Karakteri: + Number Style: Numara Stili: + Left Indent: Sol Girinti: + First Line Indent: İlk Satır Girintisi: Convert to Text Metne Dönüştür Filled Dot Dolu Nokta Dash Tire @@ -3894,6 +3905,7 @@ axis Both Her İkisi @msg Targa files are RGB: colorModel rgb Targa dosyaları RGB'dir: colorModel rgb @msg Text Wrap: select a wrap object Metin Sarma: bir sarma nesnesi seçin @msg This shortcut file is in a form VectorCraft can't read. Bu kısayol dosyası VectorCraft'ın okuyamayacağı bir biçimdedir. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` "none", "sharp", "crisp" veya "strong" olmalıdır @msg `aspect` must be a number (%) `aspect` bir sayı olmalıdır (%) @msg `at` must be [x, y] `at` [x, y] olmalıdır @msg `color` must be a colour `color` bir renk olmalıdır @@ -5753,6 +5765,11 @@ theme Appearance Görünüm @msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor, Otomatik Gruplama gruplarını bölemez: Otomatik Gruplama olmadan izleyin @msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor, canlı Pathfinder efekti olan bir izlemeyi yeniden gruplayamaz: byColor olmadan genişletin Gap Length: Boşluk Uzunluğu: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Düz Logo modu keepColors kullanmaz: logoColors içinde adı verilen renkleri veya bulduğu renkleri izler. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Hiçbir şey izlenmedi: izlemenin bulduğu renklerin hiçbiri keepColors ({_1}) ile eşleşmiyor. +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Hiçbir şey izlenmedi: Rengi Yoksay, keepColors içinde listelenen renklerin tüm alanlarını dışarıda bırakıyor. +@msg Nothing was traced: the colors that match keepColors produce no paths. Hiçbir şey izlenmedi: keepColors ile eşleşen renkler hiçbir yol oluşturmuyor. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: izlemenin bulduğu renklerin hiçbiri {c} ile eşleşmiyor, bu yüzden bu renkten hiçbir şey izlenmiyor. @msg `colorGroup` needs `library: "document"` `colorGroup` için `library: "document"` gerekir @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` için `palette: "documentLibrary"` gerekir @msg `colorGroup` needs `mode: "color"` `colorGroup` için `mode: "color"` gerekir @@ -5857,6 +5874,14 @@ axis Y Axis Y ekseni @msg unknown permission `{shown}` bilinmeyen izin `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” öğesine çıktısının ihtiyaç duyduğu `{p}` izni verilmedi: hiçbir şey değiştirilmedi +@msg a heavy filter runs in the background, so it can't declare `commands` ağır bir filtre arka planda çalışır, bu yüzden `commands` bildiremez +@msg at most {MAX_COMMANDS} `commands` en fazla {MAX_COMMANDS} `commands` +@msg command {name} is declared twice {name} komutu iki kez bildirilmiş +@msg command {name}: `label` must be 1-64 printable characters {name} komutu: `label` 1-64 yazdırılabilir karakter olmalıdır +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} komutu: a-z 0-9 _ karakterlerinden oluşan ve harfle başlayan 1-{MAX_COMMAND_ID} karakterlik bir kimlik kullanın +@msg command {name}: {e} {name} komutu: {e} +@msg only a filter or a live effect declares `commands` `commands` yalnızca bir filtre veya canlı efekt tarafından bildirilir +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi # Actions panel: action sets (#3712). New Set… Yeni Set… @@ -6399,6 +6424,16 @@ breakText Glyphs Glifler @msg path must be a list of child indices path alt öğe dizinlerinden oluşan bir liste olmalıdır @msg no object at path {shown} in the symbol's art sembolün çiziminde {shown} yolunda nesne yok @msg give fill, stroke, opacity and/or blend fill, stroke, opacity ve/veya blend verin +@msg {key} cannot be set on a guide {key} bir kılavuzda ayarlanamaz +@msg {key} cannot be set on a blend {key} bir karışımda ayarlanamaz +@msg {key} cannot be set on an envelope {key} bir zarfta ayarlanamaz +@msg {key} cannot be set on a gradient mesh {key} bir degrade örgüde ayarlanamaz +@msg {key} cannot be set on a Repeat {key} bir tekrarda ayarlanamaz +@msg {key} cannot be set on a placed document {key} yerleştirilmiş bir belgede ayarlanamaz +@msg {key} cannot be set on an Intertwine group {key} bir iç içe geçirme grubunda ayarlanamaz +@msg stroke cannot be set on a symbol instance stroke bir sembol örneğinde ayarlanamaz +@msg fill on a symbol instance must be a solid color or none bir sembol örneğinin fill değeri düz renk veya none olmalıdır +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key}, Şekle Dönüştür veya Anahat Nesnesi efekti olmayan bir görüntüde ayarlanamaz @msg pass newName, registration, exportType, nineSlice and/or type newName, registration, exportType, nineSlice ve/veya type verin Text Flow Metin Akışı By Rows Satırlara Göre @@ -6428,6 +6463,7 @@ breakText Glyphs Glifler Create Swatch Legend Renk Örneği Açıklaması Oluştur Create Swatch Legend… Renk Örneği Açıklaması Oluştur… HEX HEX + Help Bar Yardım Çubuğu # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Metin Araçları Text Tweaker… Metin Ayarlayıcı… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4ad4f5b4d..b92dba63a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -192,6 +192,10 @@ Anti-Alias Згладжування Anti-alias Rasters Згладжувати растрові зображення Anti-aliased Artwork Згладжений малюнок + Anti-aliasing Method Метод згладжування +antiAlias Sharp Різке + Crisp Чітке + Strong Сильне Anti-aliasing: Згладжування: Any Kind Будь-який тип Font Technology Технологія шрифту @@ -361,6 +365,13 @@ font-technology Variable Варіативні Apply Bullets Застосувати маркери Numbered List Нумерований список Apply Numbers Застосувати нумерацію + Bullets and Numbering… Маркери та нумерація… + Numbers Номери + Level: Рівень: + Bullet Character: Символ маркера: + Number Style: Стиль нумерації: + Left Indent: Відступ зліва: + First Line Indent: Відступ першого рядка: Convert to Text Перетворити на текст Filled Dot Заповнена крапка Dash Тире @@ -4128,6 +4139,7 @@ axis Both Обидві @msg Targa files are RGB: colorModel rgb Файли Targa є RGB: colorModel rgb @msg Text Wrap: select a wrap object Обтікання текстом: виділіть об’єкт обтікання @msg This shortcut file is in a form VectorCraft can't read. Цей файл сполучень клавіш записано у форматі, який VectorCraft не може прочитати. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` має бути "none", "sharp", "crisp" або "strong" @msg `aspect` must be a number (%) `aspect` має бути числом (%) @msg `at` must be [x, y] `at` має бути [x, y] @msg `color` must be a colour `color` має бути кольором @@ -5349,6 +5361,11 @@ lighting Light Світло Snap to Perpendicular Прив’язати до перпендикуляра @msg unknown snap option `{key}` (use {options}) невідомий параметр прив’язки `{key}` (використовуйте {options}) Gap Length: Довжина проміжку: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Режим «Плоский логотип» не використовує keepColors: він трасує кольори, вказані в logoColors, або знайдені ним кольори. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Нічого не трасовано: жоден із кольорів, знайдених під час трасування, не відповідає keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Нічого не трасовано: «Ігнорувати колір» пропускає всі області кольорів, перелічених у keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Нічого не трасовано: кольори, що відповідають keepColors, не дають жодного контуру. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: жоден із кольорів, знайдених під час трасування, не відповідає {c}, тому з нього нічого не трасується. @msg `colorGroup` needs `library: "document"` для `colorGroup` потрібен `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` для `colorGroup` потрібен `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` для `colorGroup` потрібен `mode: "color"` @@ -5496,6 +5513,14 @@ axis Y Axis Вісь Y @msg unknown permission `{shown}` невідомий дозвіл `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не отримав дозволу `{p}`, потрібного для його результату: нічого не змінено +@msg a heavy filter runs in the background, so it can't declare `commands` важкий фільтр виконується у фоні, тому не може оголошувати `commands` +@msg at most {MAX_COMMANDS} `commands` щонайбільше {MAX_COMMANDS} `commands` +@msg command {name} is declared twice команду {name} оголошено двічі +@msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` має містити 1-64 друковані символи +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: використайте id з 1-{MAX_COMMAND_ID} символів a-z 0-9 _, що починається з літери +@msg command {name}: {e} команда {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` оголошує лише фільтр або динамічний ефект +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено # Actions panel: action sets (#3712). New Set… Новий набір… @@ -6042,6 +6067,16 @@ breakText Glyphs Гліфи @msg path must be a list of child indices path має бути списком індексів дочірніх об’єктів @msg no object at path {shown} in the symbol's art у графіці символу немає об’єкта за шляхом {shown} @msg give fill, stroke, opacity and/or blend вкажіть fill, stroke, opacity та/або blend +@msg {key} cannot be set on a guide {key} не можна задати для напрямної +@msg {key} cannot be set on a blend {key} не можна задати для переходу +@msg {key} cannot be set on an envelope {key} не можна задати для оболонки +@msg {key} cannot be set on a gradient mesh {key} не можна задати для градієнтної сітки +@msg {key} cannot be set on a Repeat {key} не можна задати для повторення +@msg {key} cannot be set on a placed document {key} не можна задати для розміщеного документа +@msg {key} cannot be set on an Intertwine group {key} не можна задати для групи переплетення +@msg stroke cannot be set on a symbol instance stroke не можна задати для екземпляра символу +@msg fill on a symbol instance must be a solid color or none fill екземпляра символу має бути суцільним кольором або none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} не можна задати для зображення без ефекту «Перетворити на фігуру» або «Обвести об’єкт» @msg pass newName, registration, exportType, nineSlice and/or type вкажіть newName, registration, exportType, nineSlice та/або type Text Flow Потік тексту By Rows За рядками @@ -6071,6 +6106,7 @@ breakText Glyphs Гліфи Create Swatch Legend Створити легенду зразків Create Swatch Legend… Створити легенду зразків… HEX HEX + Help Bar Панель підказок # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Інструменти тексту Text Tweaker… Налаштування тексту… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b493f9c64..e5ae73cba 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -216,6 +216,10 @@ Anti-Alias Khử răng cưa Anti-alias Rasters Khử răng cưa ảnh raster Anti-aliased Artwork Tác phẩm khử răng cưa + Anti-aliasing Method Phương pháp khử răng cưa +antiAlias Sharp Sắc nét + Crisp Rõ nét + Strong Đậm Anti-aliasing: Khử răng cưa: Any Kind Bất kỳ loại Font Technology Công nghệ phông chữ @@ -422,6 +426,13 @@ font-technology Variable Biến thiên Apply Bullets Áp dụng dấu đầu dòng Numbered List Danh sách đánh số Apply Numbers Áp dụng đánh số + Bullets and Numbering… Dấu đầu dòng và đánh số… + Numbers Số + Level: Cấp độ: + Bullet Character: Ký tự đầu dòng: + Number Style: Kiểu đánh số: + Left Indent: Lề trái: + First Line Indent: Thụt lề dòng đầu: Convert to Text Chuyển thành văn bản Filled Dot Chấm đặc Dash Gạch ngang @@ -4284,6 +4295,7 @@ effect Soft Mềm lighting Light Đèn theme Appearance Giao diện @msg "{0}" is not a colour: write it as #rrggbb "{0}" không phải là màu: viết dưới dạng #rrggbb +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` phải là "none", "sharp", "crisp" hoặc "strong" @msg `aspect` must be a number (%) `aspect` phải là một số (%) @msg `at` must be [x, y] `at` phải là [x, y] @msg `color` must be a colour `color` phải là một màu @@ -5350,6 +5362,11 @@ theme Appearance Giao diện Snap to Perpendicular Hút vào đường vuông góc @msg unknown snap option `{key}` (use {options}) tùy chọn hút không hợp lệ `{key}` (dùng {options}) Gap Length: Độ dài khoảng hở: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Chế độ Logo phẳng không dùng keepColors: chế độ này dò các màu được nêu trong logoColors hoặc các màu nó tìm thấy. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Không có gì được dò: không màu nào mà quá trình dò tìm thấy khớp với keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Không có gì được dò: Bỏ qua màu bỏ đi mọi vùng của các màu mà keepColors liệt kê. +@msg Nothing was traced: the colors that match keepColors produce no paths. Không có gì được dò: các màu khớp với keepColors không tạo ra đường dẫn nào. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: không màu nào mà quá trình dò tìm thấy khớp với {c}, nên không có gì của màu đó được dò. @msg `colorGroup` needs `library: "document"` `colorGroup` cần `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` cần `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` cần `mode: "color"` @@ -5497,6 +5514,14 @@ axis Y Axis Trục Y @msg unknown permission `{shown}` quyền không xác định `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” chưa được cấp `{p}`, quyền mà đầu ra của nó cần: không có gì thay đổi +@msg a heavy filter runs in the background, so it can't declare `commands` bộ lọc nặng chạy ở chế độ nền nên không thể khai báo `commands` +@msg at most {MAX_COMMANDS} `commands` tối đa {MAX_COMMANDS} `commands` +@msg command {name} is declared twice lệnh {name} được khai báo hai lần +@msg command {name}: `label` must be 1-64 printable characters lệnh {name}: `label` phải có 1-64 ký tự in được +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter lệnh {name}: hãy dùng id gồm 1-{MAX_COMMAND_ID} ký tự từ a-z 0-9 _ và bắt đầu bằng một chữ cái +@msg command {name}: {e} lệnh {name}: {e} +@msg only a filter or a live effect declares `commands` chỉ bộ lọc hoặc hiệu ứng trực tiếp mới khai báo `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt # Actions panel: action sets (#3712). New Set… Bộ mới… @@ -6043,6 +6068,16 @@ breakText Glyphs Glyph @msg path must be a list of child indices path phải là danh sách chỉ số con @msg no object at path {shown} in the symbol's art không có đối tượng tại đường dẫn {shown} trong hình của ký hiệu @msg give fill, stroke, opacity and/or blend hãy cung cấp fill, stroke, opacity và/hoặc blend +@msg {key} cannot be set on a guide không thể đặt {key} cho đường căn +@msg {key} cannot be set on a blend không thể đặt {key} cho hòa trộn +@msg {key} cannot be set on an envelope không thể đặt {key} cho envelope +@msg {key} cannot be set on a gradient mesh không thể đặt {key} cho lưới chuyển sắc +@msg {key} cannot be set on a Repeat không thể đặt {key} cho lặp lại +@msg {key} cannot be set on a placed document không thể đặt {key} cho tài liệu đã đặt +@msg {key} cannot be set on an Intertwine group không thể đặt {key} cho nhóm đan xen +@msg stroke cannot be set on a symbol instance không thể đặt stroke cho thực thể ký hiệu +@msg fill on a symbol instance must be a solid color or none fill của thực thể ký hiệu phải là màu đặc hoặc none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it không thể đặt {key} cho hình ảnh không có hiệu ứng Chuyển thành hình dạng hoặc Đường bao đối tượng @msg pass newName, registration, exportType, nineSlice and/or type hãy cung cấp newName, registration, exportType, nineSlice và/hoặc type Text Flow Luồng văn bản By Rows Theo hàng @@ -6072,6 +6107,7 @@ breakText Glyphs Glyph Create Swatch Legend Tạo chú giải mẫu màu Create Swatch Legend… Tạo chú giải mẫu màu… HEX HEX + Help Bar Thanh trợ giúp # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Công cụ văn bản Text Tweaker… Tinh chỉnh văn bản… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 99e5223df..7e11eed9c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -147,6 +147,10 @@ Anti-Alias 消除锯齿 Anti-alias Rasters 栅格消除锯齿 Anti-aliased Artwork 图稿抗锯齿 + Anti-aliasing Method 消除锯齿方法 +antiAlias Sharp 锐利 + Crisp 清晰 + Strong 强 Anti-aliasing: 消除锯齿: Appearance 外观 Appearance Attribute 外观属性 @@ -316,6 +320,13 @@ Apply Bullets 应用项目符号 Numbered List 编号列表 Apply Numbers 应用编号 + Bullets and Numbering… 项目符号和编号… + Numbers 编号 + Level: 级别: + Bullet Character: 项目符号字符: + Number Style: 编号样式: + Left Indent: 左缩进: + First Line Indent: 首行缩进: Convert to Text 转换为文本 Filled Dot 实心圆点 Dash 短划线 @@ -4003,6 +4014,7 @@ imageTrace Outline 轮廓 @msg no point {i} (the gradient has {_1}) 没有点 {i} (该渐变有 {_1} 个) @msg no segment {segment} on line {line} 线 {line} 上没有线段 {segment} @msg unknown freeform mode `{m}` (points, lines) 未知的任意形状模式 `{m}` (points、lines) +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` 必须是 "none"、"sharp"、"crisp" 或 "strong" @msg `aspect` must be a number (%) `aspect` 必须是数字 (%) @msg `gradient` must be an object `gradient` 必须是对象 @msg `groupColors` must be an array `groupColors` 必须是数组 @@ -5587,6 +5599,11 @@ lighting Light 光源 Snap to Perpendicular 对齐垂线 @msg unknown snap option `{key}` (use {options}) 未知的对齐选项 `{key}`(可用:{options}) Gap Length: 间隙长度: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. “扁平徽标”模式不使用 keepColors:它描摹 logoColors 中指定的颜色,或它找到的颜色。 +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 未描摹任何内容:描摹找到的颜色中没有一种与 keepColors ({_1}) 匹配。 +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 未描摹任何内容:“忽略颜色”排除了 keepColors 所列颜色的所有区域。 +@msg Nothing was traced: the colors that match keepColors produce no paths. 未描摹任何内容:与 keepColors 匹配的颜色未生成任何路径。 +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors:描摹找到的颜色中没有一种与 {c} 匹配,因此该颜色未被描摹。 @msg `colorGroup` needs `library: "document"` `colorGroup` 需要 `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` 需要 `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` 需要 `mode: "color"` @@ -5738,6 +5755,14 @@ axis Y Axis Y 轴 @msg unknown permission `{shown}` 未知权限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”未获授予其输出所需的 `{p}`:未作任何更改 +@msg a heavy filter runs in the background, so it can't declare `commands` 重型滤镜在后台运行,因此不能声明 `commands` +@msg at most {MAX_COMMANDS} `commands` 最多 {MAX_COMMANDS} 个 `commands` +@msg command {name} is declared twice 命令 {name} 声明了两次 +@msg command {name}: `label` must be 1-64 printable characters 命令 {name}:`label` 必须是 1-64 个可打印字符 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 命令 {name}:请使用由 a-z 0-9 _ 组成、以字母开头的 1-{MAX_COMMAND_ID} 个字符的 ID +@msg command {name}: {e} 命令 {name}:{e} +@msg only a filter or a live effect declares `commands` 只有滤镜或实时效果才能声明 `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 # Actions panel: action sets (#3712). New Set… 新建组… @@ -6283,6 +6308,16 @@ breakText Glyphs 字形 @msg path must be a list of child indices path 必须是子项索引列表 @msg no object at path {shown} in the symbol's art 符号图稿中路径 {shown} 处没有对象 @msg give fill, stroke, opacity and/or blend 请提供 fill、stroke、opacity 和/或 blend +@msg {key} cannot be set on a guide 无法为参考线设置 {key} +@msg {key} cannot be set on a blend 无法为混合设置 {key} +@msg {key} cannot be set on an envelope 无法为封套设置 {key} +@msg {key} cannot be set on a gradient mesh 无法为渐变网格设置 {key} +@msg {key} cannot be set on a Repeat 无法为重复设置 {key} +@msg {key} cannot be set on a placed document 无法为置入的文档设置 {key} +@msg {key} cannot be set on an Intertwine group 无法为交织编组设置 {key} +@msg stroke cannot be set on a symbol instance 无法为符号实例设置 stroke +@msg fill on a symbol instance must be a solid color or none 符号实例的 fill 必须是纯色或 none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 无法为没有“转换为形状”或“轮廓化对象”效果的图像设置 {key} @msg pass newName, registration, exportType, nineSlice and/or type 请提供 newName、registration、exportType、nineSlice 和/或 type Text Flow 文本流向 By Rows 按行 @@ -6312,6 +6347,7 @@ breakText Glyphs 字形 Create Swatch Legend 创建色板图例 Create Swatch Legend… 创建色板图例… HEX HEX + Help Bar 帮助栏 # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools 文字工具 Text Tweaker… 文字微调… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index eb0cf06b1..e33e3eae5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -156,6 +156,10 @@ Anti-Alias 消除鋸齒 Anti-alias Rasters 點陣影像消除鋸齒 Anti-aliased Artwork 消除鋸齒的圖稿 + Anti-aliasing Method 消除鋸齒方法 +antiAlias Sharp 銳利 + Crisp 清晰 + Strong 強 Anti-aliasing: 消除鋸齒: Any Kind 所有類型 Font Technology 字體技術 @@ -329,6 +333,13 @@ font-technology Variable 可變 Apply Bullets 套用項目符號 Numbered List 編號清單 Apply Numbers 套用編號 + Bullets and Numbering… 項目符號與編號… + Numbers 編號 + Level: 層級: + Bullet Character: 項目符號字元: + Number Style: 編號樣式: + Left Indent: 左縮排: + First Line Indent: 首行縮排: Convert to Text 轉換為文字 Filled Dot 實心圓點 Dash 短破折號 @@ -1845,6 +1856,7 @@ file-filter Templates 範本 @msg no point {i} (the gradient has {_1}) 沒有點 {i} (此漸層有 {_1} 個) @msg no segment {segment} on line {line} 線條 {line} 上沒有線段 {segment} @msg unknown freeform mode `{m}` (points, lines) 未知的任意形狀模式 `{m}` (points、lines) +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` 必須是 "none"、"sharp"、"crisp" 或 "strong" @msg `aspect` must be a number (%) `aspect` 必須是數字 (%) @msg `gradient` must be an object `gradient` 必須是物件 @msg `groupColors` must be an array `groupColors` 必須是陣列 @@ -2563,6 +2575,11 @@ file-filter Templates 範本 @msg too many library panels were opened in this session 本次工作階段開啟的資料庫面板過多 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 影像為 {_1} × {_2} 像素,有一邊超過 {LARGE_TRACE_SIDE}:仍會描圖,但速度較慢。使用精確度較低的預設集或較小的影像拷貝,描圖會較快。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 影像已以 {_1} × {_2} 像素描圖,由 {_3} × {_4} 縮小而來 (最多 {_5} 百萬像素)。 +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. 「扁平標誌」模式不使用 keepColors:它會描圖 logoColors 中指定的顏色,或它找到的顏色。 +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 未描圖任何內容:描圖找到的顏色中沒有一種與 keepColors ({_1}) 相符。 +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 未描圖任何內容:「忽略顏色」排除了 keepColors 所列顏色的所有區域。 +@msg Nothing was traced: the colors that match keepColors produce no paths. 未描圖任何內容:與 keepColors 相符的顏色未產生任何路徑。 +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors:描圖找到的顏色中沒有一種與 {c} 相符,因此該顏色未被描圖。 @msg `colorGroup` needs `library: "document"` `colorGroup` 需要 `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` 需要 `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` 需要 `mode: "color"` @@ -5534,6 +5551,14 @@ axis Y Axis Y 軸 @msg unknown permission `{shown}` 未知權限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」未獲授予其輸出所需的 `{p}`:未做任何變更 +@msg a heavy filter runs in the background, so it can't declare `commands` 重型濾鏡在背景執行,因此不能宣告 `commands` +@msg at most {MAX_COMMANDS} `commands` 最多 {MAX_COMMANDS} 個 `commands` +@msg command {name} is declared twice 指令 {name} 宣告了兩次 +@msg command {name}: `label` must be 1-64 printable characters 指令 {name}:`label` 必須是 1-64 個可列印字元 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 指令 {name}:請使用由 a-z 0-9 _ 組成、以字母開頭的 1-{MAX_COMMAND_ID} 個字元的 ID +@msg command {name}: {e} 指令 {name}:{e} +@msg only a filter or a live effect declares `commands` 只有濾鏡或即時效果才能宣告 `commands` +@msg “{name}” › {label}: {e} 「{name}」 › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 # Actions panel: action sets (#3712). New Set… 新增組… @@ -6080,6 +6105,16 @@ breakText Glyphs 字符 @msg path must be a list of child indices path 必須是子項索引清單 @msg no object at path {shown} in the symbol's art 符號圖稿中路徑 {shown} 處沒有物件 @msg give fill, stroke, opacity and/or blend 請提供 fill、stroke、opacity 和/或 blend +@msg {key} cannot be set on a guide 無法為參考線設定 {key} +@msg {key} cannot be set on a blend 無法為漸變設定 {key} +@msg {key} cannot be set on an envelope 無法為封套設定 {key} +@msg {key} cannot be set on a gradient mesh 無法為漸層網格設定 {key} +@msg {key} cannot be set on a Repeat 無法為重複設定 {key} +@msg {key} cannot be set on a placed document 無法為置入的文件設定 {key} +@msg {key} cannot be set on an Intertwine group 無法為交織群組設定 {key} +@msg stroke cannot be set on a symbol instance 無法為符號範例設定 stroke +@msg fill on a symbol instance must be a solid color or none 符號範例的 fill 必須是純色或 none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 無法為沒有「轉換為形狀」或「外框物件」效果的影像設定 {key} @msg pass newName, registration, exportType, nineSlice and/or type 請提供 newName、registration、exportType、nineSlice 和/或 type Text Flow 文字流向 By Rows 依列 @@ -6109,6 +6144,7 @@ breakText Glyphs 字符 Create Swatch Legend 建立色票圖例 Create Swatch Legend… 建立色票圖例… HEX HEX + Help Bar 說明列 # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools 文字工具 Text Tweaker… 文字微調… diff --git a/crates/ui-egui/src/layout_migration.rs b/crates/ui-egui/src/layout_migration.rs index e5c6b1f9c..b56b46720 100644 --- a/crates/ui-egui/src/layout_migration.rs +++ b/crates/ui-egui/src/layout_migration.rs @@ -44,10 +44,12 @@ impl UiState { pub fn read_preferences(bytes: &[u8]) -> Option { let mut saved: Value = serde_json::from_slice(bytes).ok()?; let fields = saved.as_object_mut()?; + help_bar_visibility(fields, "status_bar", "help_bar"); let mut intact = recover(fields, Keys::SNAKE); for key in ["custom_workspaces", "workspace_changes"] { if let Some(Value::Array(workspaces)) = fields.get_mut(key) { for workspace in workspaces.iter_mut().filter_map(Value::as_object_mut) { + help_bar_visibility(workspace, "statusBar", "helpBar"); intact &= recover(workspace, Keys::CAMEL); } } @@ -58,6 +60,15 @@ impl UiState { } } +/// Older layouts used status-bar visibility for both bottom bars. Preserve that choice until +/// the layout has an independent Help Bar setting. +fn help_bar_visibility(fields: &mut Map, status: &str, help: &str) { + if !fields.contains_key(help) { + let visible = fields.get(status).and_then(Value::as_bool).unwrap_or(true); + fields.insert(help.into(), Value::Bool(visible)); + } +} + /// The JSON keys of the docking fields: `UiState` is snake_case, [`Workspace`] camelCase. struct Keys { hidden: &'static str, diff --git a/crates/ui-egui/src/layout_migration/tests.rs b/crates/ui-egui/src/layout_migration/tests.rs index d97567eb1..9540f57bf 100644 --- a/crates/ui-egui/src/layout_migration/tests.rs +++ b/crates/ui-egui/src/layout_migration/tests.rs @@ -14,6 +14,26 @@ fn read(saved: &Value) -> ReadPreferences { UiState::read_preferences(&serde_json::to_vec(saved).unwrap()).unwrap() } +#[test] +fn legacy_help_bar_visibility_follows_each_layouts_status_bar() { + let prefs = read(&json!({ + "status_bar": false, + "custom_workspaces": [{"name":"Hidden", "statusBar": false}, {"name":"Shown", "statusBar":true}], + "workspace_changes": [{"name":"Changed", "statusBar":false}] + })); + assert!(!prefs.ui.help_bar); + assert!(!prefs.ui.custom_workspaces[0].help_bar); + assert!(prefs.ui.custom_workspaces[1].help_bar); + assert!(!prefs.ui.workspace_changes[0].help_bar); + assert!(read(&json!({})).ui.help_bar); + for (status, help) in [(true, false), (false, true)] { + let prefs = read(&json!({"status_bar":status, "help_bar":help, + "custom_workspaces":[{"name":"Explicit", "statusBar":status, "helpBar":help}]})); + assert_eq!(prefs.ui.help_bar, help); + assert_eq!(prefs.ui.custom_workspaces[0].help_bar, help); + } +} + /// The dock of a layout in the current shape: the canvas beside a dock at the end edge. fn dock(layout: &Layout) -> &[Column] { assert!(docking::valid(layout), "{layout:?}"); diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index e9cfc538a..36811ba4c 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -33,6 +33,8 @@ pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { Some(match it { Item::Sep => Entry::Sep, Item::Header(h) => Entry::Header(t(h)), + // A plug-in's submenu shows its name as it is, as its items do. + Item::Sub(label, children) if super::labels::holds_plugin_commands(children) => Entry::Sub(label, children), Item::Sub(label, children) => Entry::Sub(t(label), children), Item::Todo(label, sc) => Entry::Item(Row { command: None, diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 2594b6842..263c458b1 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -156,6 +156,10 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); return; } + // A plug-in command with parameters asks for them, from its menu item or its shortcut. + if p.as_object().is_none_or(|o| o.is_empty()) && crate::dialogs::plugin::open_command(app, id) { + return; + } if open_view_dialog(app, id, &p) || open_object_dialog(app, id, &p) { return; } diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index 8cc4a5c36..2b15eaba1 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -139,15 +139,18 @@ pub(super) fn shows_a_name(id: &str, label: &str) -> bool { SLOTS.iter().any(|s| id.starts_with(s)) || saved_selection_slot(id).is_some() || matches!(id, "text.setStyle" | "plugin.dialog" | "window.toolbar.custom" | "svgFilter.apply") + || vectorcraft_engine::cmd::plugin::is_command_id(id) || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) } /// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or -/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds -/// while another thread installs or removes plug-ins. +/// an Effect › Plug-ins effect) or one of its commands, by the plug-in's own text? Told by the item +/// itself, so it holds while another thread installs or removes plug-ins. pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { - id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() + id == "plugin.dialog" + || vectorcraft_engine::cmd::plugin::is_command_id(id) + || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() } /// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled @@ -309,6 +312,11 @@ pub const DYNAMIC_LABELS: &[&str] = &[ "Lock Grid", ]; +/// Is `children` a plug-in's submenu: does it hold a command the plug-in declares? +pub(crate) fn holds_plugin_commands(children: &[Item]) -> bool { + children.iter().any(|c| matches!(c, Item::Cmd(_, id, _) if vectorcraft_engine::cmd::plugin::is_command_id(id))) +} + /// Every English string the menus, the command palette, the panel registry, the toolbar and the /// Preferences dialog can show: what a complete language catalog has to cover. Names that are user /// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the @@ -337,6 +345,8 @@ pub fn menu_strings() -> std::collections::BTreeSet { Item::Todo(l, _) | Item::Header(l) => { out.insert(l.to_string()); } + // A plug-in's submenu is named after the plug-in. + Item::Sub(_, children) if holds_plugin_commands(children) => walk(children, under, out), Item::Sub(l, children) => { out.insert(l.to_string()); // Type › Font lists the installed families: names, not UI text. diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 2e71f9873..5ce1ddbdf 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -270,6 +270,7 @@ fn run_ui_part3(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::paragraph::apply_bullets(app, p), "type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p), + "ui.bulletsAndNumbering" => crate::dialogs::bullets_numbering::open(app, p), _ => return None, }; Some(r) @@ -448,6 +449,7 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.control_bar), + "window.helpBar" => flag(&mut app.ui.help_bar), // The Control bar's panel menu and gripper: the edge of the window the bar docks to. "window.control.dock" => { let key = "edge"; diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 1c09d3899..c0a0c04ba 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -59,6 +59,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { // a document no item is checked. "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), "window.control" => app.ui.control_bar, + "window.helpBar" => app.ui.help_bar, "window.control.dock" => { p.get("edge").and_then(Value::as_str) == Some(crate::chrome::CONTROL_BAR_EDGES[usize::from(app.ui.control_bar_bottom)]) } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 3918b9fb1..3f082ac6d 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1,6 +1,29 @@ use super::*; use crate::theme; +#[test] +fn help_bar_toggle_is_independent_and_saved_in_workspaces() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + assert!(app.ui.help_bar); + assert_eq!(checked(&app, "window.helpBar", &Value::Null), Some(true)); + assert_eq!(run_ui_command(&mut app, "window.helpBar", &Value::Null).unwrap().unwrap(), json!(false)); + assert!(app.ui.status_bar); + assert_eq!(checked(&app, "window.helpBar", &Value::Null), Some(false)); + let saved = crate::workspaces::capture(&app.ui, "No help"); + let saved: crate::workspaces::Workspace = serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap(); + app.ui.help_bar = true; + app.ui.status_bar = false; + crate::workspaces::apply(&mut app.ui, &saved); + assert!(!app.ui.help_bar && app.ui.status_bar); + assert_eq!(run_ui_command(&mut app, "window.helpBar", &Value::Null).unwrap().unwrap(), json!(true)); + let legacy: crate::workspaces::Workspace = serde_json::from_value(json!({"name":"Old workspace"})).unwrap(); + assert!(legacy.help_bar); + let restored: crate::state::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert!(restored.help_bar); + let (_, window) = menu_tree().into_iter().find(|(title, _)| *title == "Window").unwrap(); + assert!(window.iter().any(|item| matches!(item, Item::Cmd("Help Bar", "window.helpBar", _)))); +} + /// One headless frame of the in-window menu bar; returns its titles (left to right) as /// (rect, id of the title's popup). fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(egui::Rect, egui::Id)> { diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 83f6d6db4..d8305d06a 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -623,6 +623,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { sub("Workspace", crate::workspaces::menu_items()), Sep, c("Control", "window.control"), + c("Help Bar", "window.helpBar"), c("Contextual Task Bar", "window.taskBar"), c("Tools", "window.toolbar"), sub("Toolbars", crate::custom_toolbars::menu_items()), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5e985bae2..7fec7e206 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -234,6 +234,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off", ), + ( + "ui.bulletsAndNumbering", + "Bullets and Numbering…", + "", + "{tab?: \"bullets\"|\"numbers\"} open Bullets and Numbering (More Options in the Paragraph panel's Bullets and Numbered List presets) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, on that tab (without it, Numbers when the paragraphs are a numbered list, else Bullets) (dialog `bulletsAndNumbering`: tab (bullets|numbers), list (none|bullet|number: the kind of the preset picked), bullet (dot|dash|circle|square), number (arabic|upperLetters|lowerLetters|upperRoman|lowerRoman|twoDigits|threeDigits), listAlign (left|right), listLevel (1..9; 1 for paragraphs in no list), leftIndent, firstLineIndent (pt)): the type shows the dialog's values while it is open, OK runs text.setFormat with the values that changed (and the indents it shows when a preset puts the paragraphs in a list), as one undo step, and Cancel puts the type back; a preset picked for paragraphs in no list whose indents are 0 fills in 18 and -18 pt; an error with no type selected or an unknown tab", + ), ( "type.convertListToText", "Convert to Text", @@ -447,6 +453,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), ("window.control", "Control", "", "{}"), + ("window.helpBar", "Help Bar", "", "{} toggle the tool-help strip independently of the status bar; returns the new visibility"), ( "window.control.dock", "Dock Control Bar", diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 14f0b8a9d..e895bef6c 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -11,7 +11,8 @@ use crate::{VectorcraftApp, menus}; pub fn items() -> Vec<(String, String, String)> { let mut items = vec![]; // Aliases kept for older scripts run a command that is listed already. - for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)) { + let plugin_commands = vectorcraft_engine::cmd::plugin::command_specs(); + for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)).chain(plugin_commands) { let path = c.menu.join(" › "); 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())); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 9f9c66ba5..5f2f96a8c 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1,12 +1,13 @@ //! Character panel: font family / style, size, leading, kerning, tracking, vertical and horizontal -//! scale, baseline shift, character rotation and the All Caps, Small Caps, Superscript, Subscript, -//! Underline and Strikethrough toggles and the Language menu. +//! scale, baseline shift, character rotation, the anti-aliasing method and the All Caps, Small Caps, +//! Superscript, Subscript, Underline and Strikethrough toggles and the Language menu. use std::sync::{Mutex, OnceLock}; use egui::{Key, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::setup::{LANGUAGES, char_language_name}; +use vectorcraft_doc::text::TypeAntiAlias; use vectorcraft_doc::{CharStyle, NodeId, NodeKind}; use vectorcraft_tools::{Mods, ToolKey}; @@ -758,10 +759,11 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) { }); } -/// The Character panel's grid of numeric fields, `w` wide: Font Size, Leading, Kerning and -/// Tracking, then with `more` the scales, Baseline Shift and Character Rotation (`s`: the style -/// shown). With Show Font Height Options on, a row under Font Size holds the Set Font Height -/// Reference dropdown. The Properties panel shows its first rows. +/// The Character panel's grid of fields, `w` wide: Font Size, Leading, Kerning and Tracking, +/// then with `more` the scales, Baseline Shift and Character Rotation, and a row with the +/// Anti-aliasing Method dropdown under Character Rotation (`s`: the style shown). With Show Font +/// Height Options on, a row under Font Size holds the Set Font Height Reference dropdown. The +/// Properties panel shows its first rows. pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) { let fw = ((w - 66.0) / 2.0).clamp(60.0, 100.0); let unit = app.session.type_unit(); @@ -836,9 +838,62 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & } }); ui.end_row(); + // Anti-aliasing Method, under Character Rotation. + ui.label(""); + cell(ui, "aa", tl!("Anti-aliasing Method"), |ui| anti_alias_method(app, ui, fw)); + ui.end_row(); }); } +/// The Anti-aliasing Method dropdown, `width` wide: how the edges of the type objects the panel acts +/// on are drawn as pixels ([`set_anti_alias`]), blank where their methods differ. +fn anti_alias_method(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { + // The labels of [`TypeAntiAlias::ALL`], in its order. "Sharp" is looked up under the + // `antiAlias` context: the catalogs' plain "Sharp" row is the Contour tool's corner. + let sharp = crate::i18n::tr_ctx(crate::i18n::current(), "antiAlias", "Sharp"); + let labels = [tl!("None"), sharp, tl!("Crisp"), tl!("Strong")]; + let current = shared_anti_alias(app); + let shown = TypeAntiAlias::ALL.iter().zip(labels).find(|(m, _)| Some(**m) == current).map_or("", |(_, l)| l); + if let Some(m) = widgets::dropdown_names(ui, "ch-anti-alias", shown, &labels, width).and_then(|i| TypeAntiAlias::ALL.get(i)) { + set_anti_alias(app, *m); + } +} + +/// The anti-aliasing method of the type objects the panel acts on: the one the Type tool edits, +/// else the selected type (groups' included). `None` where they differ, or with no type. +fn shared_anti_alias(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + let mut acc = Shared::None; + match text_editing(app) { + Some((id, _, _)) => { + if let Some(NodeKind::Text(t)) = st.doc.node(id).map(|n| &n.kind) { + acc.add(t.anti_alias); + } + } + None => { + for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + n.walk(&mut |c| { + if let NodeKind::Text(t) = &c.kind { + acc.add(t.anti_alias); + } + }); + } + } + } + acc.value() +} + +/// Set the anti-aliasing method `m` of the frame the Type tool edits, whatever part of its text +/// is selected, else the selected type objects (`text.setFormat {antiAlias}`). +fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) { + let mut p = json!({"antiAlias": m.id()}); + if let Some((id, _, _)) = text_editing(app) { + end_typing(app); + p["ids"] = json!([id.0]); + } + app.run("text.setFormat", p).ok(); +} + /// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or /// picked from the presets, in the type unit, blank where the selected text's sizes differ. pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { @@ -1380,14 +1435,22 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool { first_selected_text(app).is_none_or(|t| t.fractional_widths) } -/// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and +/// Reset Panel, in one undo step: the type objects the panel acts on take the Sharp anti-aliasing +/// method, and the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and /// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline /// character alignment, Proportional Metrics, Tate-chu-yoko, Standard Vertical Roman Alignment and /// No Break off, Tate-chu-yoko Settings Up/Down and Left/Right 0, the Default character direction, /// Kashidas on, the Default digits and the Em Box font height reference, whether or not Show East Asian -/// Options, Show Indic Options and Show Font Height Options are on. +/// Options, Show Indic Options and Show Font Height Options are on. Inside an undo group that is +/// already open, the reset is part of that group's step. fn reset_panel(app: &mut VectorcraftApp) { + // Typing in progress stays an undo step of its own. + end_typing(app); + let open = app.session.active().is_some_and(|st| st.undo_group.is_some()); + if !open { + app.session.begin_named_undo_group(Some("Reset Panel".into())); + } let d = CharStyle::default(); style(app, json!({"tracking": 0, "leading": "auto"})); format( @@ -1401,6 +1464,12 @@ fn reset_panel(app: &mut VectorcraftApp) { "digits": d.digits, "fontHeightReference": d.font_height_reference }), ); + set_anti_alias(app, TypeAntiAlias::Sharp); + // Closes the active document's undo group only, so an undo group open in another document + // stays open. + if !open && let Some(st) = app.session.active_mut() { + st.undo_group = None; + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 577a081ef..6b948ce41 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -561,6 +561,114 @@ fn small_caps_superscript_and_subscript_menu_items_set_and_check_the_text() { } } +/// The Anti-aliasing Method dropdown under Character Rotation shows the method of the selected +/// type, blank where the selected objects differ, and a pick sets it for the whole object, also +/// while the Type tool has part of its text selected. Reset Panel sets Sharp. +#[test] +fn the_anti_aliasing_method_dropdown_shows_and_sets_the_types_method() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hamburg", "size": 12})).unwrap()["id"].as_u64().unwrap(); + let method = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.anti_alias, + _ => panic!("text"), + }; + let ctx = egui::Context::default(); + let texts = |app: &mut VectorcraftApp| frame_events(&ctx, app, vec![], show); + let shown = |app: &mut VectorcraftApp| { + texts(app).into_iter().map(|(t, _)| t).filter(|t| ["None", "Sharp", "Crisp", "Strong"].contains(&t.as_str())).collect::>() + }; + assert_eq!(shown(&mut app), ["Sharp"]); + // Open the dropdown and pick Crisp. + let at = text_rect(&texts(&mut app), "Sharp").center(); + click(&ctx, &mut app, at, show); + let listed = texts(&mut app); + let crisp = text_rect(&listed, "Crisp").center(); + click(&ctx, &mut app, crisp, show); + assert_eq!(method(&app, a), TypeAntiAlias::Crisp); + assert_eq!(shown(&mut app), ["Crisp"]); + // Another object with Sharp selected as well: blank. + let b = app.run("text.create", json!({"x": 20, "y": 120, "text": "Bremen", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert!(shown(&mut app).is_empty(), "the methods differ"); + // The Type tool with two letters selected: the whole object takes the method. + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 1, "end": 3})); + assert!(text_editing(&app).is_some()); + set_anti_alias(&mut app, TypeAntiAlias::Strong); + assert_eq!(method(&app, a), TypeAntiAlias::Strong); + assert_eq!(method(&app, b), TypeAntiAlias::Sharp, "the other selected type is unchanged while editing"); + assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Strong)); + // Reset Panel sets Sharp, with the characters' attributes, in one undo step. + range_style(&mut app, json!({"tracking": 50})); + let tracking = |app: &VectorcraftApp| shared(app, |s| s.tracking); + assert_eq!(tracking(&app), Some(50.0)); + reset_panel(&mut app); + assert_eq!((method(&app, a), tracking(&app)), (TypeAntiAlias::Sharp, Some(0.0))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!((method(&app, a), tracking(&app)), (TypeAntiAlias::Strong, Some(50.0)), "one undo step"); +} + +/// The Sharp method is named by its own `antiAlias` catalog row, not by the plain "Sharp" row +/// that names the Contour tool's corner. +#[test] +fn the_sharp_method_takes_its_own_translation() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 50, "text": "Hamburg", "size": 12})).unwrap(); + let de = crate::i18n::Lang::from_code("de").unwrap(); + let (method, corner) = (crate::i18n::tr_ctx(de, "antiAlias", "Sharp"), crate::i18n::tr(de, "Sharp")); + assert_ne!(method, corner, "the catalog names the two differently"); + crate::i18n::set_current(de); + let text = crate::tests_labels::painted_text(&mut app, show); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert!(text.contains(method) && !text.contains(corner), "{text}"); +} + +/// Reset Panel closes only the undo group it opens: in an undo group that is already open, +/// the reset is part of that group's step, and an undo group open in another document stays +/// open, with that document's later edits part of its step. +#[test] +fn reset_panel_closes_only_its_own_undo_group() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "A"})).unwrap()["id"].clone(); + let steps = |st: &vectorcraft_engine::DocState| (st.history.undo.len(), st.history.undo.last().map(|e| e.label.clone())); + // An undo group open in the same document (MCP `command_batch` with `atomic`). + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + app.run("text.setFormat", json!({"id": a, "antiAlias": "crisp"})).unwrap(); + reset_panel(&mut app); + assert!(app.session.active().unwrap().undo_group.is_some(), "the batch's undo group stays open"); + app.run("text.setFormat", json!({"id": a, "antiAlias": "strong"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + let a_steps = steps(app.session.active().unwrap()); + assert_eq!(a_steps.1, Some("Batch".to_string()), "the reset is part of the Batch step"); + // An undo group open in another document. The second document is open before the group + // begins, so switching to it opens no group there. + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + assert_eq!(app.session.active_index(), Some(1)); + let b = app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap()["id"].clone(); + app.session.set_active(0); + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + app.run("text.setFormat", json!({"id": a, "antiAlias": "none"})).unwrap(); + let a_steps = steps(app.session.active().unwrap()); + app.session.set_active(1); + assert!(app.session.active().unwrap().undo_group.is_none()); + app.run("select.set", json!({"ids": [b]})).unwrap(); + let b_steps = steps(app.session.active().unwrap()).0; + reset_panel(&mut app); + assert_eq!(steps(app.session.active().unwrap()), (b_steps + 1, Some("Reset Panel".to_string()))); + assert!(app.session.active().unwrap().undo_group.is_none()); + assert!(app.session.documents()[0].undo_group.is_some(), "the other document's undo group stays open"); + app.session.set_active(0); + app.run("text.setFormat", json!({"id": a, "antiAlias": "crisp"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + assert_eq!(steps(app.session.active().unwrap()), a_steps, "the later edit is part of the Batch step"); +} + /// Character panel menu › System Layout is off for a new document, and a click turns whole-point /// spacing on and back off for every text object. #[test] diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 4c3241608..88fdcda6e 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -1,8 +1,9 @@ //! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), the -//! Bullets and Numbered List buttons and their presets, indents, space before/after, Hyphenate, -//! Insert Kashidas (with the Indic options), Mojikumi Set and Kinsoku Set (with the East Asian -//! options); the panel menu turns Roman Hanging Punctuation on and off, opens Justification… and -//! Hyphenation… and picks the Single-line or Every-line Composer. +//! Bullets and Numbered List buttons and their presets (More Options opens Bullets and +//! Numbering…), indents, space before/after, Hyphenate, Insert Kashidas (with the Indic options), +//! Mojikumi Set and Kinsoku Set (with the East Asian options); the panel menu turns Roman Hanging +//! Punctuation on and off, opens Justification… and Hyphenation… and picks the Single-line or +//! Every-line Composer. use egui::{Ui, vec2}; use serde_json::{Value, json}; @@ -218,7 +219,7 @@ fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) { } /// A distance shared by every paragraph in scope, or `None` when they differ (blank spin field). -fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option { +pub(crate) fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| { @@ -251,7 +252,7 @@ pub(crate) fn shared_single_word_justify(app: &VectorcraftApp) -> Option(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { +pub(crate) fn shared_para(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| { @@ -465,6 +466,25 @@ pub(crate) fn apply_numbers(app: &mut VectorcraftApp, p: &Value) -> Result [(&'static str, &'static str, Option); 5] { + use vectorcraft_doc::text::Bullet; + [ + (tl!("None"), "none", None), + (tl!("Filled Dot"), "dot", Some(Bullet::Dot)), + (tl!("Dash"), "dash", Some(Bullet::Dash)), + (tl!("Hollow Circle"), "circle", Some(Bullet::Circle)), + (tl!("Filled Square"), "square", Some(Bullet::Square)), + ] +} + +/// The first `n` numbers of number style `style`, as the Numbered List presets show them ("1, 2, +/// 3"). +pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) -> String { + (1..=n).map(|k| style.format(k)).collect::>().join(", ") +} + /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`): the markers of the /// list paragraphs of the selected type, or of those the Type tool's selection touches, become /// characters of the text and the lists are turned off (`text.convertListToText`, which also moves @@ -477,9 +497,9 @@ pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result, checked: bool) -> bool { - use vectorcraft_doc::text::Bullet; let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click()); if resp.hovered() { @@ -527,24 +544,53 @@ fn bullet_row(ui: &mut Ui, name: &str, bullet: Option painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke), - Some(Bullet::Dot) => painter.circle_filled(c, 3.0, t.text_strong), - Some(Bullet::Dash) => painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], stroke), - Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, t.text_strong)), - Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, t.text_strong), - }; + paint_bullet(painter, rect.left_center() + vec2(28.0, 0.0), bullet, t.text_strong); painter.text(rect.left_center() + vec2(42.0, 0.0), egui::Align2::LEFT_CENTER, name, font, t.text_strong); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), checked, name)); resp.clicked() } +/// Draw `bullet` centered on `c` in `color` as the presets show it (a slash for None). +pub(crate) fn paint_bullet(painter: &egui::Painter, c: egui::Pos2, bullet: Option, color: egui::Color32) { + use vectorcraft_doc::text::Bullet; + let stroke = egui::Stroke::new(1.5, color); + match bullet { + None => painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke), + Some(Bullet::Dot) => painter.circle_filled(c, 3.0, color), + Some(Bullet::Dash) => painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], stroke), + Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, color)), + Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, color), + }; +} + +/// More Options at the end of the Bullets and Numbered List presets, under a line. Whether it was +/// clicked. +fn more_options_row(ui: &mut Ui) -> bool { + let t = Tokens::get(ui.ctx()); + ui.separator(); + let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover); + } + let name = tl!("More Options"); + ui.painter().text(rect.left_center() + vec2(22.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text_strong); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name)); + resp.clicked() +} + +/// More Options: open Bullets and Numbering on `tab` (`ui.bulletsAndNumbering`), or show why it +/// can't open. +fn more_options(app: &mut VectorcraftApp, tab: &str) { + if let Err(e) = app.run("ui.bulletsAndNumbering", json!({ "tab": tab })) { + app.status(e); + } +} + /// The Numbered List button and the arrow beside it (Paragraph panel). The button makes the /// paragraphs the panel acts on a numbered list (`text.setFormat {list: "number"}`) and shows /// pressed while they all are one. The arrow lists the presets (`{number}`), each shown by its -/// first three numbers: None turns the list off, and the one every paragraph has is checked. +/// first three numbers: None turns the list off, and the one every paragraph has is checked. More +/// Options after them opens Bullets and Numbering on its Numbers tab. pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { use vectorcraft_doc::text::NumberStyle; let t = Tokens::get(ui.ctx()); @@ -559,22 +605,26 @@ pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim); arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Numbered List"))); let arrow = arrow.on_hover_text(tl!("Numbered List")); + let mut more = false; let picked = egui::Popup::menu(&arrow).show(|ui| { let mut picked = None; if bullet_row(ui, tl!("None"), None, shown == Some(None)) { picked = Some(None); } for style in NumberStyle::ALL { - let sample = (1..=3).map(|n| style.format(n)).collect::>().join(", "); - if number_row(ui, &sample, shown == Some(Some(style))) { + if number_row(ui, &number_sample(style, 3), shown == Some(Some(style))) { picked = Some(Some(style)); } } + more = more_options_row(ui); picked }); if let Some(style) = picked.and_then(|r| r.inner) { format(app, json!({ "number": style.map_or(json!("none"), |s| json!(s)) })); } + if more { + more_options(app, "numbers"); + } } /// A row of the Numbered List presets: a check when `checked`, then the style's first numbers diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 5facaee19..4250de340 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -435,6 +435,36 @@ fn the_numbered_list_button_and_its_presets_set_the_list_and_reset_panel_clears_ assert_eq!(lists(&app), (None, None)); } +/// More Options at the end of the Bullets presets opens Bullets and Numbering on its Bullets +/// tab, and at the end of the Numbered List presets on its Numbers tab. +#[test] +fn more_options_opens_bullets_and_numbering_on_its_tab() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let (mut app, _) = editing_text("One\nTwo"); + select_range(&mut app, 0, 0); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let numbers = std::cell::Cell::new(false); + let row = std::cell::Cell::new(egui::Rect::NOTHING); + let mut draw = |app: &mut VectorcraftApp, ui: &mut Ui| { + let r = ui.horizontal(|ui| if numbers.get() { numbers_button(app, ui, 28.0) } else { bullets_button(app, ui, 28.0) }); + row.set(r.response.rect); + }; + for (on_numbers, tab) in [(false, "bullets"), (true, "numbers")] { + numbers.set(on_numbers); + super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw); + let arrow = row.get().min + vec2(28.0 + 7.0, 14.0); + super::super::tests_appearance::click(&ctx, &mut app, arrow, &mut draw); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw); + let at = super::super::tests_appearance::text_rect(&texts, "More Options").center(); + super::super::tests_appearance::click(&ctx, &mut app, at, &mut draw); + let d = app.ui.dialog.take().unwrap_or_else(|| panic!("More Options opens the dialog from the {tab} presets")); + assert_eq!((d.kind.as_str(), d.str("tab")), ("bulletsAndNumbering", tab.to_string())); + } + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("bulletsAndNumbering")); +} + /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`) acts on the /// paragraphs the Type tool's selection touches: their numbers become characters of the text /// and their list goes, while the other paragraphs stay in the list, and the Type tool's caret diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index a1a8c7fb1..57a99e433 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -57,8 +57,11 @@ enum IndentMarker { struct IndentTarget { id: u64, start: usize, + /// The left indent as laid out: the paragraph's own plus its list level's ([`Self::level`]). left: f64, first_line: f64, + /// How much further in the paragraph's list level sets it than its own left indent. + level: f64, } fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &mut Vec) { @@ -68,7 +71,8 @@ fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &m starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1))); targets.extend(range.filter_map(|i| { let para = text.para_at(i); - Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left: para.left_indent, first_line: para.first_line_indent }) + let left = para.laid_out_left_indent(); + Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left, first_line: para.first_line_indent, level: left - para.left_indent }) })); } @@ -113,13 +117,14 @@ fn bounded_marker_position(marker: IndentMarker, target: f64, span: f64, targets IndentMarker::Upper => { (targets.iter().map(|p| p.left - 1296.0).fold(0.0_f64, f64::max), targets.iter().map(|p| p.left + 1296.0).fold(span, f64::min)) } + // A paragraph's own left indent stays within 0 to 1296 pt, its list level's indent added. IndentMarker::Lower if both => ( - targets.iter().map(|p| -p.first_line).fold(0.0_f64, f64::max), - targets.iter().map(|p| span - p.first_line).fold(span.min(1296.0), f64::min), + targets.iter().map(|p| p.level.max(-p.first_line)).fold(0.0_f64, f64::max), + targets.iter().map(|p| (span - p.first_line).min(p.level + 1296.0)).fold(span, f64::min), ), IndentMarker::Lower => ( - targets.iter().map(|p| p.left + p.first_line - 1296.0).fold(0.0_f64, f64::max), - targets.iter().map(|p| p.left + p.first_line + 1296.0).fold(span.min(1296.0), f64::min), + targets.iter().map(|p| p.level.max(p.left + p.first_line - 1296.0)).fold(0.0_f64, f64::max), + targets.iter().map(|p| (p.left + p.first_line + 1296.0).min(p.level + 1296.0)).fold(span, f64::min), ), }; (low <= high).then(|| target.clamp(low, high)) @@ -209,28 +214,34 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: app.status(e); } } - } else if both { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"leftIndent": target})) { - app.status(e); - } - } else if targets.iter().all(|p| p.left + p.first_line == marker_position(IndentMarker::Upper, para)) { - if let Err(e) = super::paragraph::run_on_paragraphs( - app, - "text.setFormat", - json!({"leftIndent": target, "firstLineIndent": marker_position(IndentMarker::Upper, para) - target}), - ) { - app.status(e); - } } else { + // The ruler shows each paragraph's left indent with its list level's added; the paragraph + // keeps its own. + let upper = marker_position(IndentMarker::Upper, para); + let level = targets.first().map(|p| p.level).filter(|l| targets.iter().all(|p| p.level == *l)); + let params = match level { + Some(level) if both => Some(json!({"leftIndent": target - level})), + Some(level) if targets.iter().all(|p| p.left + p.first_line == upper) => { + Some(json!({"leftIndent": target - level, "firstLineIndent": upper - target})) + } + _ => None, + }; + if let Some(params) = params { + if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { + app.status(e); + } + return; + } super::character::end_typing(app); let commands: Vec = targets .iter() .rev() .map(|p| { - json!({"command": "text.setFormat", "params": { - "ids": [p.id], "start": p.start, "end": p.start, - "leftIndent": target, "firstLineIndent": p.left + p.first_line - target - }}) + let mut params = json!({"ids": [p.id], "start": p.start, "end": p.start, "leftIndent": target - p.level}); + if !both { + params["firstLineIndent"] = json!(p.left + p.first_line - target); + } + json!({"command": "text.setFormat", "params": params}) }) .collect(); if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) { @@ -245,7 +256,9 @@ fn current(app: &VectorcraftApp) -> Option<(Node, Vec, f64)> { current_para(app).map(|(n, para, span)| (n, para.tabs, span)) } -/// As [`current`], with all the attributes of the paragraph whose stops the panel shows. +/// As [`current`], with all the attributes of the paragraph whose stops the panel shows, its left +/// indent as laid out ([`ParaStyle::laid_out_left_indent`]: its list level's indent added), where +/// the ruler shows it. fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> { let editing = super::character::text_editing(app); let n = match editing { @@ -258,7 +271,8 @@ fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> { TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()), _ => 360.0, }; - let para = t.para_at(para).clone(); + let para = t.para_at(para); + let para = ParaStyle { left_indent: para.laid_out_left_indent(), ..para.clone() }; Some((n, para, span.max(72.0))) } @@ -638,6 +652,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { snap_to_unit_item(ui); return; }; + // `current_para` gives the left indent with the list level's indent added. + let left_indent = para.left_indent; let mut stops = para.tabs; let selected_marker: Option = pstate(ui.ctx(), "tabs-marker-sel"); // Clamp the selection to the last stop, as `show` does for the ruler. @@ -655,10 +671,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { apply(app, &stops); } // Repeat Tab: copies of the selected stop across the ruler, spaced by its distance from the - // previous stop, or from the paragraph's left indent when it is the first stop. + // previous stop, or from the paragraph's left indent plus its list level's indent when it is + // the first stop. if menu_item(ui, tl!("Repeat Tab"), sel < stops.len() && selected_marker.is_none(), false) { let step = match sel { - 0 => (stops[0].position - para.left_indent).abs(), + 0 => (stops[0].position - left_indent).abs(), _ => stops[sel].position - stops[sel - 1].position, } .max(1.0); diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 3244ba243..bc71e3408 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -540,9 +540,32 @@ fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame( #[test] fn lower_marker_bounds_cover_each_touched_first_line() { - let targets = [IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0 }, IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0 }]; + let targets = [ + IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0, level: 0.0 }, + IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0, level: 0.0 }, + ]; assert_eq!(bounded_marker_position(IndentMarker::Lower, 40.0, 300.0, &targets, true), Some(20.0)); assert_eq!(bounded_marker_position(IndentMarker::Lower, 1200.0, 300.0, &targets, false), Some(300.0)); + // A paragraph at list level 2 is laid out 18 pt further in: its own left indent stays at 0 or more. + let listed = [IndentTarget { id: 1, start: 0, left: 18.0, first_line: 0.0, level: 18.0 }]; + assert_eq!(bounded_marker_position(IndentMarker::Lower, 5.0, 300.0, &listed, true), Some(18.0)); +} + +/// A list paragraph at level 2 is laid out 18 pt further in than its left indent: the ruler shows +/// its markers there, and a marker moved on the ruler sets the paragraph's own left indent. +#[test] +fn indent_markers_show_and_set_a_list_paragraphs_laid_out_indent() { + let mut app = indent_app(10.0, 6.0); + app.run("text.setFormat", json!({"list": "bullet", "listLevel": 2})).unwrap(); + let para = current_para(&app).unwrap().1; + assert_eq!((marker_position(IndentMarker::Lower, ¶), marker_position(IndentMarker::Upper, ¶)), (28.0, 34.0)); + let ctx = egui::Context::default(); + marker_event(&ctx, &mut app, 28.0, 40.0, false, true, false); + assert_eq!(indent_values(&app, 0), (22.0, 6.0), "the ruler's 40 pt is the paragraph's 22 pt and the level's 18"); + marker_event(&ctx, &mut app, 46.0, 70.0, true, false, false); + assert_eq!(indent_values(&app, 0), (22.0, 30.0)); + marker_event(&ctx, &mut app, 40.0, 20.0, false, false, false); + assert_eq!(indent_values(&app, 0), (2.0, 50.0), "the first line stays at 70 pt"); } #[test] @@ -1100,7 +1123,8 @@ fn panel_menu_does_not_change_stops_while_an_indent_marker_is_selected() { } /// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's -/// left indent, from the paragraph the caret is in while the Type tool edits text. +/// left indent (18 pt further in for each list level past the first), from the paragraph the +/// caret is in while the Type tool edits text. #[test] fn repeat_tab_measures_the_first_stop_from_the_left_indent() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -1119,6 +1143,11 @@ fn repeat_tab_measures_the_first_stop_from_the_left_indent() { app.session.execute("text.tabs.set", &json!({"stops": [{"position": 5}]})).unwrap(); choose(&ctx, &mut app, "Repeat Tab"); assert_eq!(positions(&app)[..4], [5.0, 20.0, 35.0, 50.0]); + // A list paragraph at level 2 is set 18 pt further in, at 38: copies every 12 pt. + app.session.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 2})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); + choose(&ctx, &mut app, "Repeat Tab"); + assert_eq!(positions(&app)[..3], [50.0, 62.0, 74.0]); // The Type tool's caret in the second paragraph, indented 40 pt: its stop at 50 repeats // every 10 pt, and the first paragraph keeps its stop. let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index b64d54e67..733aaf200 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -57,10 +57,12 @@ pub(crate) struct Setting { /// preferences, each category's in the order the dialog lists them. fn settings() -> impl Iterator { let ui_fields = UI_FIELDS.iter().map(|&(key, category, label)| Setting { key, category, section: "", label }); + const SYSTEM_TITLE_BAR: bool = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); + const DISPLAY_SERVER_CHOICE: bool = cfg!(target_os = "linux"); let shown = |key: &str| match key { - "systemTitleBar" => cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))), + "systemTitleBar" => SYSTEM_TITLE_BAR, "appTitleBar" => APP_TITLE_BAR_SHOWN, - "linuxDisplayServer" => cfg!(target_os = "linux"), + "linuxDisplayServer" => DISPLAY_SERVER_CHOICE, _ => true, }; let prefs = diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 5dcf390fd..b17a4c47a 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -235,6 +235,7 @@ pub fn command_for_key(key: &str) -> Option<&'static str> { let c = crate::keymap::lookup(¤t().keymap, &vc_chord(key)?)?; vectorcraft_engine::command_specs() .iter() + .chain(vectorcraft_engine::cmd::plugin::command_specs()) .map(|c| c.id) .chain(menus::UI_COMMANDS.iter().map(|c| c.0)) .find(|id| *id == c.id && c.params.is_null()) @@ -387,6 +388,12 @@ pub(crate) fn snapshot() -> Arc { .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(vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| Entry { + key: c.id.into(), + label: c.label.into(), + group: c.menu.join(" › "), + is_tool: false, + })) .chain(tail.iter().cloned()) .collect(); let s = Arc::new(Snapshot { revision, effects, filters, entries }); @@ -404,7 +411,7 @@ pub fn entries() -> Arc<[Entry]> { } /// 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. +/// items, Object › Plug-ins' filters and the plug-in commands go between them. fn fixed_entries() -> &'static (Vec, Vec) { static E: OnceLock<(Vec, Vec)> = OnceLock::new(); E.get_or_init(|| { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bf54c3124..5987050c0 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -99,7 +99,7 @@ pub(crate) fn is_modifier(key: Key) -> bool { /// command's params: `{}`, or `{panel}` for the panel shortcuts (`window.panel`). pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json::Value)> { let mut v = vec![]; - for c in vectorcraft_engine::command_specs() { + for c in vectorcraft_engine::command_specs().iter().chain(vectorcraft_engine::cmd::plugin::command_specs()) { if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) { v.push((sc, c.id, json!({}))); } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index cdbdeeeba..d02576fd1 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -2566,7 +2566,7 @@ fn each_filter_plugin_takes_a_shortcut_of_its_own() { 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]); + assert_eq!(lines[1], format!("Object › Plug-ins › Same Name…\t{}", crate::menus::pretty_shortcut(chord))); // 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); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2970fd8cc..1b93832ae 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -472,6 +472,7 @@ pub struct UiState { #[serde(default)] pub tools_beside_canvas: bool, pub status_bar: bool, + pub help_bar: bool, pub dock: bool, /// The bars that Tab (`window.dock`) hid, which the next Tab shows again; none when Tab has not /// hidden them since it last showed them, or a workspace was applied since. @@ -1008,6 +1009,7 @@ impl Default for UiState { toolbar_pos: None, tools_beside_canvas: false, status_bar: true, + help_bar: true, dock: true, tab_hid: None, view: ViewFlags::default(), diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index e6d81a00a..925a0e1d2 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -159,6 +159,28 @@ fn shortcut_sets_are_read_only_inside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `app.export` refuses a key no format takes before anything is written, with a path or without. +#[test] +fn control_export_refuses_an_option_no_format_takes() { + let (mut app, tx, base) = confined("export-options"); + let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("in/a.svg"))})); + assert_eq!(r["ok"], true, "{r}"); + let path = base.join("out/a.pdf"); + let refused = |r: &Value| r["error"].as_str().is_some_and(|e| e.contains("unknown parameter")); + for mut params in [json!({"format": "pdf", "noSuchOption": true}), json!({"format": "pdf", "marks": {"useDocumentBleed": true}})] { + let r = request(&mut app, &tx, "app.export", params.clone()); + assert!(refused(&r), "{r}"); + params["path"] = json!(s(path.clone())); + params["wait"] = json!(true); + let r = request(&mut app, &tx, "app.export", params); + assert!(refused(&r), "{r}"); + assert!(!path.exists()); + } + let r = request(&mut app, &tx, "app.export", json!({"format": "pdf", "path": s(path.clone()), "wait": true})); + assert_eq!(r["ok"], true, "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// An action set another app saved (`.aia`) is read only from inside the read root, whichever way a /// control request names it: `actionSet.read` and Load Actions…. #[test] diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 8d475a917..d83938555 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -920,11 +920,10 @@ fn wheel(at: Pos2, unit: egui::MouseWheelUnit, dy: f32, modifiers: Modifiers) -> ] } -/// The mouse wheel over a focused numeric field steps it as ↑/↓ do (#485): a notch a step, Shift -/// ten, Ctrl/Cmd a tenth; a trackpad's points add up to notches. Unfocused, or with the pointer -/// elsewhere, the wheel isn't the field's. +/// Hover stepping (#5949) keeps the existing units, modifiers and trackpad accumulation, +/// without taking keyboard focus. Outside the field, the wheel is left alone. #[test] -fn the_wheel_steps_a_focused_field_under_the_pointer() { +fn the_wheel_steps_a_hovered_field_without_taking_focus() { use egui::MouseWheelUnit::{Line, Point}; let mut f = Field::new(); let u = Unit::Millimeters; @@ -932,16 +931,13 @@ fn the_wheel_steps_a_focused_field_under_the_pointer() { let draw = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(u.to_pt(mm.get())), u, 80.0).inspect(|&v| mm.set(u.from_pt(v))); f.frame(vec![], &draw); let at = f.rect.get().center(); - assert_eq!(f.frame(wheel(at, Line, 1.0, Modifiers::NONE), &draw), None, "unfocused"); - f.frame(f.click(1), &draw); - f.frame(vec![], &draw); for (dy, mods, want) in [(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 24.0)] { assert!(f.frame(wheel(at, Line, dy, mods), &draw).is_some(), "{dy} {mods:?}"); assert!((mm.get() - want).abs() < 1e-6, "{dy} {mods:?}: {}", mm.get()); } - assert_eq!(f.selection(), Some((0, "24 mm".len())), "the new value is selected"); + assert!(!f.ctx.memory(|m| m.has_focus(f.id.get())), "hover never takes keyboard focus"); // Points: a step for each line's worth, what is left carried to the next turn. let line = f.ctx.options(|o| o.input_options.line_scroll_speed); assert_eq!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw), None); @@ -957,16 +953,16 @@ fn the_wheel_steps_a_focused_field_under_the_pointer() { assert_eq!(f.frame(wheel(f.rect.get().center(), Line, -1.0, Modifiers::NONE), &count), Some(2.0)); } -/// The wheel over a focused field steps it and leaves the panel where it is; over an unfocused one -/// it scrolls the panel as before. +/// Both focused and unfocused hovered fields own the wheel, including its smoothing tail. #[test] -fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() { +fn the_wheel_scrolls_the_panel_unless_over_a_numeric_field() { for focus in [false, true] { let mut f = Field::new(); let offset = Cell::new(0.0); + let value = Cell::new(10.0); let draw = |ui: &mut egui::Ui| { let out = egui::ScrollArea::vertical().max_height(60.0).show(ui, |ui| { - let v = widgets::plain_field(ui, "f", 10.0, "", 0, 80.0); + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); ui.add_space(600.0); v }); @@ -994,11 +990,59 @@ fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() { for _ in 0..20 { f.frame(vec![], &draw); } - if focus { - assert_eq!((stepped, offset.get()), (Some(8.0), 0.0)); - } else { - assert!(stepped.is_none() && offset.get() > 0.0, "{stepped:?} {}", offset.get()); + assert_eq!((stepped, offset.get()), (Some(8.0), 0.0), "focus {focus}"); + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert_eq!(f.frame(wheel(at, egui::MouseWheelUnit::Point, -line * 0.6, Modifiers::NONE), &draw), None); + assert_eq!(offset.get(), 0.0); + assert_eq!(f.frame(wheel(at, egui::MouseWheelUnit::Point, -line * 0.6, Modifiers::NONE), &draw), Some(7.0)); + for _ in 0..20 { + f.frame(vec![], &draw); } + assert_eq!(offset.get(), 0.0); + assert_eq!(f.frame(wheel(Pos2::new(20.0, 45.0), egui::MouseWheelUnit::Line, -2.0, Modifiers::NONE), &draw), None); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert!(offset.get() > 0.0, "outside the field scrolls the panel"); + } +} + +/// A wheel turn over an unfocused field steps it without consuming an arrow key in the same frame. +#[test] +fn hover_wheel_keeps_arrow_keys_for_the_keyboard_focus() { + let mut f = Field::new(); + let arrows = Cell::new(false); + let draw = |ui: &mut egui::Ui| { + let v = widgets::plain_field(ui, "f", 1.123, "", 3, 80.0); + arrows.set(ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::ArrowUp, .. })))); + v + }; + f.frame(vec![], &draw); + let at = f.rect.get().center(); + assert_eq!(f.frame(vec![Event::PointerMoved(at), key(Key::ArrowUp, Modifiers::NONE)], &draw), None); + assert!(arrows.get()); + let mut events = wheel(at, egui::MouseWheelUnit::Line, 1.0, Modifiers::NONE); + events.push(key(Key::ArrowUp, Modifiers::NONE)); + assert_eq!(f.frame(events, &draw), Some(2.0), "existing next-multiple rounding, wheel only"); + assert!(arrows.get(), "hover does not consume arrows"); + assert!(!f.ctx.memory(|m| m.has_focus(f.id.get()))); +} + +/// Hover steps stop at both bounds, leave mixed values blank, and never edit disabled fields. +#[test] +fn hover_wheel_preserves_limits_mixed_values_and_disabled_fields() { + for (value, enabled, dy, want) in [ + (Some(0.5), true, -1.0, Some(0.0)), + (Some(0.0), true, -1.0, None), + (Some(9.5), true, 1.0, Some(10.0)), + (Some(10.0), true, -1.0, Some(9.0)), + (None, true, -1.0, None), + (Some(10.0), false, -1.0, None), + ] { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| ui.add_enabled_ui(enabled, |ui| widgets::range_field(ui, "f", value, 0.0..=10.0, "", 3, 80.0)).inner; + f.frame(vec![], &draw); + assert_eq!(f.frame(wheel(f.rect.get().center(), egui::MouseWheelUnit::Line, dy, Modifiers::NONE), &draw), want); } } diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 85d373e9b..ea2124487 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -199,6 +199,91 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// A plug-in with two commands: Grow, which adds a triangle (`_context.command` is `grow`), and +/// Size, with a parameter, whose `menu` names another menu. +fn commands_plugin(id: &str) -> Vec { + let manifest = format!( + r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"filter","commands":[{{"id":"grow","label":"Grow"}}, + {{"id":"size","label":"Size","menu":"File","params":{{"by":{{"type":"number","min":0,"max":10,"default":2}}}}}}]}}"# + ); + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + wat::parse_str(tpl::when_given(&manifest, tpl::Given::Params, r#""command":"grow""#, triangle)).unwrap() +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() +} + +/// A plug-in's commands are items of Object › Plug-ins › its name (never the menu a manifest +/// names), entries of Edit › Keyboard Shortcuts with no shortcut of their own, and gone from both +/// once it is removed. A command with parameters opens its dialog. +#[test] +fn plugin_commands_are_menu_items_and_shortcut_entries() { + let mut app = app(); + let id = "test.ui.cmds"; + let (grow, size) = ("plugin.test.ui.cmds.grow", "plugin.test.ui.cmds.size"); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&commands_plugin(id))})).unwrap(); + let entries = menus::menu_entries(&app); + let under = ["Object", "Plug-ins", "Cmd Test"]; + let item = entry(&entries, &under, "Grow").expect("Grow is listed"); + assert_eq!((item.command.as_deref(), item.enabled), (Some(grow), true)); + assert!( + entry(&entries, &under, "Size…").is_some(), + "Size is listed: {:?}", + entries.iter().filter(|e| e.path == under).map(|e| &e.label).collect::>() + ); + assert!(!entries.iter().any(|e| e.command.as_deref() == Some(size) && e.path.first().map(String::as_str) != Some("Object")), "not in File"); + assert!(!menus::menu_strings().contains("Cmd Test"), "the plug-in's name is not text to translate"); + + let listed = crate::shortcut_editor::entry(grow).expect("in Keyboard Shortcuts"); + assert_eq!((listed.label.as_str(), listed.group.as_str()), ("Grow", "Object › Plug-ins › Cmd Test")); + assert_eq!(crate::shortcut_editor::default_of(grow), None, "a manifest gives no shortcut"); + assert!(crate::keymap::template().bindable(&crate::keymap::command_of(grow)).is_some(), "a shortcut can be given to it"); + + // Size asks for its parameter; OK runs it as one undo step named after it. + let undo = app.session.doc().unwrap().history.undo.len(); + menus::invoke(&mut app, size, json!({})); + let d = app.ui.dialog.as_ref().expect("Size opens its dialog"); + assert_eq!((d.str("__command"), d.str("__label"), d.fields.get("by")), (size.to_string(), "Size".to_string(), Some(&json!(2.0)))); + frame(&mut app); + dialogs::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Size")); + + app.run("plugin.remove", json!({"id": id})).unwrap(); + assert!(menus::menu_entries(&app).iter().all(|e| e.command.as_deref() != Some(grow))); + assert!(crate::shortcut_editor::entries().iter().all(|e| e.key != grow)); + assert!(crate::keymap::template().bindable(&crate::keymap::command_of(grow)).is_none()); +} + +/// A shortcut the user gives a plug-in command runs it. +#[test] +fn a_shortcut_runs_a_plugin_command() { + let mut app = app(); + let id = "test.ui.cmds.keys"; + let grow = vectorcraft_engine::cmd::plugin::command_id(id, "grow"); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&commands_plugin(id))})).unwrap(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, &grow, "Cmd+Alt+Shift+F9"); + crate::shortcut_editor::sync(&app.ui); + assert_eq!(crate::shortcut_editor::command_for_key("Cmd+Alt+Shift+F9"), Some(grow.as_str())); + let before = objects(&app); + let m = if crate::keymap::platform().is_mac() { egui::Modifiers::MAC_CMD } else { egui::Modifiers::CTRL } | egui::Modifiers::COMMAND; + let m = m | egui::Modifiers::ALT | egui::Modifiers::SHIFT; + let events = vec![ + egui::Event::ModifiersChanged(m), + egui::Event::Key { key: egui::Key::F9, physical_key: Some(egui::Key::F9), pressed: true, repeat: false, modifiers: m }, + ]; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + crate::shortcuts::handle(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!(objects(&app), before + 1, "the command added its triangle: {}", app.ui.status); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} + /// A plug-in of API level 2 that lists `progress` names it in plain words when it asks. #[test] fn installing_a_plugin_that_reports_progress_says_so() { diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index e73a41707..ac6b1ba8f 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -247,9 +247,9 @@ pub(super) fn shown_alike(show: impl Fn(f64) -> String) -> impl Fn(f64, f64) -> /// a weight stops at 0, a miter limit at 1), and a plain step also stops at each of `stops` /// ([`step_through`]). The new value is applied at once, as in /// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing -/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused -/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the -/// wheel to the panel and the canvas. +/// replaces it when focused. Returns the new number when a step was taken. While the pointer is +/// over an enabled field the wheel is the field's (the panel under it doesn't scroll), without +/// taking keyboard focus. Arrow keys still require keyboard focus. fn step_value( ui: &Ui, resp: &Response, @@ -261,11 +261,15 @@ fn step_value( ) -> Option { // Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the // window to report keyboard focus. - if !ui.memory(|m| m.has_focus(resp.id)) { + if !resp.enabled() { return None; } + let focused = ui.memory(|m| m.has_focus(resp.id)); use egui::{Event, Key, MouseWheelUnit}; let wheel = resp.contains_pointer(); + if !focused && !wheel { + return None; + } // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is // left of a notch carries over to the next turn. let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); @@ -278,8 +282,8 @@ fn step_value( let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; let n = match e { - Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0, - Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0, + Event::Key { key: Key::ArrowUp, pressed: true, .. } if focused => 1.0, + Event::Key { key: Key::ArrowDown, pressed: true, .. } if focused => -1.0, Event::MouseWheel { unit, delta, .. } if wheel => { // Shift may turn the wheel sideways (the Mac does). let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; @@ -325,7 +329,9 @@ fn step_value( let v = within(if stops.contains(&v) { v } else { round_to(v, STEP_DECIMALS) }, bounds); *buf = show(v); ui.data_mut(|d| d.insert_temp(resp.id, buf.clone())); - select_all(ui, resp.id, buf); + if focused { + select_all(ui, resp.id, buf); + } Some(v) } diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index d13e6a688..fa7f70d96 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -35,6 +35,7 @@ pub struct Workspace { pub dock_collapsed: bool, pub open_panel: Option, pub status_bar: bool, + pub help_bar: bool, /// Tool groups torn off the toolbar into floating strips (built-in workspaces: none). pub floating_flyouts: Vec, /// Floating panel groups in workspaces saved before the canvas joined `docking`; read only @@ -192,6 +193,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { dock_collapsed: ui.dock_collapsed, open_panel: ui.open_panel.clone(), status_bar: ui.status_bar, + help_bar: ui.help_bar, floating_flyouts: ui.floating_flyouts.clone(), floating_panels: Vec::new(), docking: ui.docking.clone(), @@ -221,6 +223,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.dock_collapsed = w.dock_collapsed; ui.open_panel = w.open_panel.clone(); ui.status_bar = w.status_bar; + ui.help_bar = w.help_bar; ui.floating_flyouts = w.floating_flyouts.clone(); ui.docking_generation = ui.docking_generation.wrapping_add(1); ui.docking = w.docking.clone().filter(crate::panel_docking::valid); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5c30f7d01..0e6611a85 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8cdb6d325..6e1bc1fa4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -97,8 +97,13 @@ private editing data. VectorCraft: group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects with live effects are written as their vector art with their geometry effects applied, and their other effects, such as - Drop Shadow, the glows and Feather, are left out with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large - canvas. + Drop Shadow, the glows and Feather, are left out with a warning. A path, compound path or group linked to a graphic style names it when it has the style's + appearance and the style is written; other links to graphic styles, those on a styled group's members included, are + left out with a warning. The Graphic Styles panel's styles are written in its order, with their fills, strokes, transparency and effects (Warp, + Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag and Gaussian Blur too). Styles with gradient or pattern fills, + with fills, strokes or effects the reader doesn't read back, with a name another style has, with names over 1,024 + bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's + styles, with a warning. No art style is written on a large canvas. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, @@ -159,7 +164,7 @@ private editing data. VectorCraft: 14; #868: round trip with Illustrator 2018 loses layers and groups), some files 4× slower to open since the editing data is read whole (#758), live effects that are not read or have settings that are not read, and brushes come in as their drawn look (#637; pattern fills are read since #1025, and symbols and their instances - open live); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides + open live, as do the symbols of a symbol library without layers); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides open as ruler guides and guide paths since #5057 (#779). Type under a Warp effect is compared with the art saved with it: type that draws as that art stays editable with the effect live, and type that draws differently, or that the file's limit of checks has no check left for, opens @@ -219,42 +224,42 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) 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. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. 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; 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. 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; 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; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. 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. 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 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. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. 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). 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), 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. 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; 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. 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; 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; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. 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. 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 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. +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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. @@ -269,7 +274,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) | -| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and set other descendants' characters as type at the composite font's size (other mappings and CID-keyed fonts are still set as type in the font of their name); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | +| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and set other descendants' characters as type at the composite font's size (other mappings and CID-keyed fonts are still set as type in the font of their name); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | @@ -315,6 +320,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | | 2026-11-06 | minor | Legacy `.ai` (version 7) export: the clipping path around a flattened transparent image followed 2 pt cells, a staircase that was visibly jagged at the image's resolution. It now follows the image's own pixels (in blocks only past 4 million pixels), grown by one pixel so the whole anti-aliased edge is inside, and runs through the middle of each pixel edge with Douglas–Peucker simplification, so slopes and curves are sloped lines instead of steps (#5264). Version 7 images have no alpha channel (§8.1 of the published format specification), so the image itself is still opaque. Test `legacy_ai::a_flattened_images_clip_follows_its_edge_smoothly` (crate `vectorcraft-engine`); scores unchanged | | 2026-11-06 | minor | Paste: cells copied from Excel, Google Sheets, LibreOffice Calc or Numbers (tab-separated text, quoted cells too) paste as a group of point type, one object per cell in columns and rows, over 0.5 pt gridlines; other text pastes as before; scores and estimates unchanged (#5296) | +| 2026-11-06 | minor | EPS import: a gradient the page paints as a row of abutting one-colour strips imports as one object filled with a linear gradient through the strips' colours (fewer stops where the colours change evenly), its clip path filled with it when it was the clip's only art. Drawn one by one, the strips left seams where the art below showed through each shared edge (PDF 32000-1 §10.6.4, conflation), a pale tint across strips a pixel or two wide; an Illustrator EPS whose page drew its gradients that way differed from its layers and opened as its page. At least 6 strips side by side, each a small colour step from the last; abutting blocks of distinct colours stay apart. Tests `import::tests::abutting_strips_of_a_gradient_become_one_gradient`, `turned_strips_become_a_gradient_across_them`, `abutting_blocks_of_distinct_colours_stay_apart`; scores unchanged | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 210f14b80..057c141f6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -16,6 +16,8 @@ Group Selection keeps an already selected component selected during a drag; a co Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + The closed font-family fields in Character, Properties and Control now accept the mouse wheel for selected text (#5253). Each effective notch applies one family and adds one undo step; the list follows saved font filters, ignores search text, and @@ -38,6 +40,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. | | 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | @@ -52,6 +55,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-05 | minor | G12: Hyphenation › Default Language sets the language of new type unless Document Setup chose one, stored as a tag; each run hyphenates in its own language, English only so far, so 2 Preferences options stay unused (#394) | | 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-11-02 | minor | Font-family fields step selected text with the mouse wheel (#5253); no scored gap or estimate reassessed. | | 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests/constrain_tools.rs`); no entry closed, estimate unchanged. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | @@ -187,6 +191,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-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. - **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/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 92e5f42ac..77086c503 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -32,6 +32,20 @@ white beside a cream), at most six; scattered specks of a colour add none. Adobe its own palettes are chosen; this is VectorCraft's own method (see [image-trace-quality.md](image-trace-quality.md#auto-color-keeps-rare-colours)). +**Keep Colors.** `"keepColors": ["#rrggbb", …]` in the params of `imageTrace.make` or +`imageTrace.makeAndExpand` traces only the palette colors within about 30 levels of a listed color, +the distance Ignore Color matches by, and leaves the other colors out. With Method Abutting, the paths +of the kept colors are the same as in a trace of every color. With Method Overlapping, a kept color's +paths reach under the kept colors above it in the stack and not under the colors left out, as with +Ignore Color. An empty list traces every color, and a value that is not a six-digit hex color is +refused. With Ignore Color as well, a kept color that Ignore Color matches is still left out. Flat +Logo does not use `keepColors`: it traces the colors named in `logoColors`, or the colors it finds. +The reply's `warnings` name each listed color that matches none of the colors the trace found. When +nothing is traced because no listed color matches, because Ignore Color leaves out every area of the +ones that match, or because the ones that match produce no paths, the first warning says so, and the +status bar shows it. In Flat Logo mode the +warning says that `keepColors` is not used. + **Document Library color groups.** With Palette › Document Library and Library › Document Swatches, the panel's **Colors** menu traces with every document swatch (**All**) or with the colors of one color group from the Swatches panel, as the public @@ -51,6 +65,13 @@ traced fills and strokes take the library color without a link to its swatch. Th spot and tint swatches. Tints trace at the color shown by the tint swatch. Editing a swatch later does not recolor the trace. Colors from other swatch libraries are also unlinked. +**Ignore Color, background only.** Ignore Color leaves out every area of the chosen color. With +`"ignoreBackground": true` beside `"ignoreWhite": true` in the params of `imageTrace.make` or +`imageTrace.makeAndExpand`, it leaves out only the areas of that color that reach the image's edge, +joined through pixels above, below or to either side, and traces the areas of it that other colors +enclose: black line work inside a panel stays when the black around the panel is left out. Flat Logo +leaves out every white area whatever `ignoreBackground` is. + **Document Library** traces with the library's colors as they are. Each pixel takes the library color nearest to it in lightness and hue, with a difference in hue or chroma counted three times as much as one in lightness, so a gray or a muted brown takes a black, white or other neutral diff --git a/docs/mcp.md b/docs/mcp.md index ab0d5d55a..f2b9cd0f3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -539,7 +539,8 @@ While a spiral is being dragged out with the Spiral tool, a `drag` event with `m spiral's inner end at the distance from the center it had on the first modified `drag` event, and the outer end follows the pointer. With `mods.alt` alone, dragging outward adds whole winds of four segments and dragging inward removes them. The fitted segment count rounds to the nearest whole wind from the count on the first Alt-only `drag`, within 2…1000. -With `mods.cmd`, decay changes to fit within 5…99.99 %; it takes precedence when both modifiers are held. Switching +Decay is the percentage retained after each full wind of four segments. With `mods.cmd`, decay changes to fit within +5…99.99 %; it takes precedence when both modifiers are held. Switching back to Alt uses the current count as its new base while keeping the held inner end. The spiral drawn on release uses the new values, and they stay as the Spiral tool's `segments` and `decay` options for the next spiral: @@ -570,16 +571,23 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`, `appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`, `appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer`, `appearance.copyFrom`, `paint.default`, -`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight and -opacity of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only the kinds of row -that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the stored opacity, -and does not store a hidden topmost row that it swaps. The graph does not store the rest of a graphic style's look (more -fill and stroke rows, effects, the object's opacity and blend mode, and the link to the style), and a mark loses it when -the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend swatches take -the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes the stored -fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black outline, so these -commands do not store a stroke paint or weight for a series that draws a band. Except for `paint.swap`, they still store -a stroke opacity set on a band, and the band's outline takes it. Graph designs keep their own paint. A row that had no +`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight, +opacity and blend mode of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only +the kinds of row that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the +stored opacity and blend mode, and does not store a hidden topmost row that it swaps. `transparency.set` and +`object.setProps` with an opacity or blend mode for the object, `appearance.clear`, `appearance.transfer`, +`appearance.copyFrom` with Transparency picked up, `graphicStyle.apply` without `add` and `graphicStyle.redefine` store +the object's opacity and blend mode on the graph, and the other commands above store them when they change them on a +mark. The graph stores an opacity and blend mode as a pair, taken from the mark the command edited: a command that sets +only the opacity of one mark's object or row also stores that mark's blend mode in place of the one another mark +stored. Every path directly in the series takes the stored values when the graph is drawn again. The graph does not +store the rest of a graphic style's look (more fill and stroke rows, effects, and the link to the style), and a mark +loses it when the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend +swatches take the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes +the stored fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black +outline, so these commands do not store a stroke paint or weight for a series that draws a band. Except for +`paint.swap`, they still store a stroke opacity and blend mode set on a band, and the band's outline takes them. Graph +designs keep their own paint. A row that had no paint before the command and has none after it does not change what the graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do @@ -1155,8 +1163,8 @@ refused before the command runs: → invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`? ``` -Only the top level is checked: the keys of an object inside a value (`artboardLayout: {…}`) are the command's to -read. The one exception is an effect's `params`: `effect.apply` (and `appearance.addEffect`) and `effect.setParams` +Only the top level is checked, except for the export commands below: the keys of an object inside a value +(`artboardLayout: {…}`) are the command's to read. Another exception is an effect's `params`: `effect.apply` (and `appearance.addEffect`) and `effect.setParams` check them against the effect's own entry in `effect.list`, its `defaults` and the keys its `params` text names. A key the effect doesn't take, or a value of another type than its default (a string for a number, a number for a switch), is refused the same way: @@ -1194,7 +1202,7 @@ A number outside the range the effect documents is still stored as given and cla plug-in effect's parameters are resolved against its manifest instead. `effect.setParams` as a step of `command.batch` is checked when the step runs, against the effects the steps before it added, and the batch then fails with no change. A description with a `…` entry takes more keys than it lists: those of the command it names -(`…settings as file.new`), or any key when it names none, as `document.export` and `document.save` do for the options +(`…settings as file.new`), or, except for the export commands below, any key when it names none, as `document.save` does for the options of the format they write (one object can carry several formats' options). A command that takes its own reply back (`document.setup`, `text.areaOptions`) says so the same way. @@ -1203,6 +1211,21 @@ key, the Batch dialog or `ui.action.play`), a key a command no longer reads, rec and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect `color` or `channel` it can't read. +The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` +and `file.export` refuse a key that no format takes, and ignore a key that only another format takes +(`{"format": "pdf", "quality": 80}` writes the PDF). `document.exportPdf`, `document.pdfSettings` and `pdf.preset.save` +refuse a key that PDF doesn't take, `document.exportEps` one that EPS doesn't take and `document.exportDxf` one that DXF +doesn't take. Each of them refuses an unknown key inside an option object it takes (`marks`, `bleed`, `compression`, +`output`, `advanced`, `security`, `flattener`), naming its path: + +```json +{"id": "document.export", "params": {"format": "pdf", "marks": {"useDocumentBleed": true}}} +→ invalid parameters for `document.export`: unknown parameter `marks.useDocumentBleed`; `marks` takes: … +``` + +A saved PDF preset that holds a key that is no longer read keeps loading. `document.save`, `file.saveAs`, `file.saveCopy` +and `document.exportForScreens` don't apply this check. + An unknown command id is matched the same way as an unknown parameter key: the error names the nearest command id when one is near, ignoring case. A step of `command.batch` or `action.batch` gets the same suggestion in its step error: @@ -1797,9 +1820,14 @@ already walks Image Trace. Short agent entry points: art for itself only and stays linked. `symbol.instance.setAppearance {id?, path, fill?, stroke?, opacity?, blend?}` names the object by `path`, child indices in paint order from the art's root down through its groups (`[]` is the root; `[1, 0]` is the first object of the art's second member when that member is a group). `fill` and `stroke` take - a colour string or the paint params of `paint.setFill` (`color`, `none`, `swatch`, `tint`, `gradient`), `opacity` is - 0..100 and `blend` a blend mode name; `null` gives one back to the symbol. It is refused for a static symbol and for - a path that names no object. On the canvas (VectorCraft's own rule), a Direct Selection click on an instance of a + a colour string or the paint params of `paint.setFill` (`color`, `none`, `swatch`, `tint`, `gradient`). Paths, + compound paths, type, groups and layers take both. A symbol instance takes a solid color or `none` as its fill, + which stains its art, and no gradient or pattern fill and no stroke. An image takes a fill or stroke only when that + fill or stroke has a Convert to Shape or Outline Object effect. A guide, blend, envelope, Repeat, Intertwine group, + gradient mesh or placed document takes neither. A refused fill or stroke (VectorCraft's own rule) gives an error + that names the key and the object, and nothing of the call is applied; `opacity`, `blend` and `null` are taken for + every object, so a stored change can be taken back. `opacity` is 0..100 and `blend` a blend mode name; `null` gives + one back to the symbol. It is refused for a static symbol and for a path that names no object. On the canvas (VectorCraft's own rule), a Direct Selection click on an instance of a dynamic symbol selects the path, compound path or type object of its art under the pointer and outlines it (`symbol.instance.selectPart {id, path}`; Alt or Shift selects the instance whole). While it is selected, `paint.setFill` and `paint.setStroke` with a solid color or none, `paint.invert` and `paint.complement` on a solid @@ -1964,7 +1992,20 @@ already walks Image Trace. Short agent entry points: Export end the file name of a step with `format` in that `format`, in lower case and without a leading dot, when it is one of the format's extensions (`.svg` and `SVG` give `.svg`, `.JPEG` gives `.jpeg`), and otherwise in the format's first extension (`png8` gives `.png`, `template` gives `.vctemplate`). A step without `format` keeps the - source file's extension (`.vectorcraft` over data sets). + source file's extension (`.vectorcraft` over data sets). Override Export gives a `document.exportForScreens` or + `assets.export` step the `folder` `destinationFolder/<stem>`, in place of the step's own folder or an export + preset's: one folder for each source file, named after the file without its extension, or for each data set, + named as its files are (`datasetFileNaming`). Both names drop trailing periods and spaces (`a..svg` gives `a`). The export names + the files in it as it does outside Batch. When two source files have the same name without the extension + (`a.svg` and `sub/a.svg`, `a.svg` and `a.ai`, or `a.svg` and `a..svg`), the folder destination and the overrides + add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. They also add it when a + later file's folder or file would have the name of a file or folder an earlier one wrote: with the folder + destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. When + `destinationFolder` holds source files, only a file's own output takes its name, and no export folder takes the + name of a subfolder that holds source files: with `a..svg` and `a.svg` saved in place, `a..svg` is saved as + `a-2.svg` and `a.svg` over itself. A file's artboard files count as its files, as for data sets: with + `a.vectorcraft` and `a-Artboard-1.vectorcraft` saved in place with `separateArtboards`, `a.vectorcraft` is saved + as `a-2`. - Action set files: `ui.actionSet.save {set?: index, id?, path?}` writes the Actions panel's set at `set` (0 is the top set; by default the current set) with its actions to an action set file at `path`, else a picked file named after the set (the web downloads it), and returns `{path}`. `id` holds the panel's own set id, which the panel menu @@ -2087,6 +2128,8 @@ already walks Image Trace. Short agent entry points: black ring around a white disc around a black dot, black gets two groups. `layer.releaseToLayers` on the expanded group then gives each group a sublayer. A trace made with `autoGrouping` is refused. Expanding by color also refuses a trace with a live Pathfinder effect. + With `keepColors` in `params`, the reply's `warnings` name each listed color that no traced color matches, and say + when nothing was traced. `imageTrace.presets.save {name?, newName?, preset?, enhanced?, params?}` saves tracing settings as a preset kept with the preferences: `params` over `preset`'s (default: the saved preset `name`, else Default), checked as `imageTrace.make` checks them; without `name` it is a new "Tracing Preset N", and `newName` renames a saved preset (the reply's @@ -2432,7 +2475,10 @@ counts as a value left out except for `recolor.apply`'s `model` and a `map` row' Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes paint every member's geometry, and their effects apply to the members as one piece (one combined drop shadow; a -Transform or Warp moves or bends the whole group). Their Contents row (type: Characters) is a slot in the stack: +Transform or Warp moves or bends the whole group). Under a group's Warp, each placed gradient moves and scales from +bounds before the warp to bounds after it: a path's or compound path's gradients use its own bounds, a nested group's +own fills and strokes use the nested group's bounds, and the group's own fills and strokes use the group's bounds; a +stroke uses those bounds inflated by the stroke. Their Contents row (type: Characters) is a slot in the stack: fills and strokes above it paint over the members (characters), those below under them; new items go above it (on an image, below it). `appearance.moveItem {"from": "contents", "to": n}` puts the row above the bottom `n` items, and an item move takes @@ -2561,7 +2607,9 @@ what dragging a symbol out of the library panel onto the canvas does. `symbol.saveLibrary {path?, names?, name?, user?}` writes the document's symbols as a symbol library: a `.vectorcraft` document holding only them and what they use; without `path` it returns `{dataBase64}`, and `user: true` saves into the user library folder (category `user`, User Defined). `symbol.loadLibrary {path? | data? | dataBase64?, name?}` -loads the symbols of any document VectorCraft opens as a library to add from; a file it can't read is an error. +loads the symbols of any document VectorCraft opens as a library to add from (a `.ai` or EPS whose editing data holds +symbols and no layers gives the symbols it defines, in the order of its symbol list); a file it can't read is an +error. ## Brush libraries @@ -3225,7 +3273,8 @@ names no earlier result as the previous primitive's result; and an `feComposite` operator SVG 1.1 lacks, or `edgeMode` on a primitive other than `feConvolveMatrix`, left out, as the canvas draws them. A filter with an `feBlend` mode SVG 1.1 lacks (`overlay` and the others), an `feDropShadow` whose `flood-color` is `currentColor` or with `primitiveUnits="objectBoundingBox"`, or more than 64 primitives once -its shadows are expanded, has no SVG 1.1 form. In that case, otherwise, and with the SVG Tiny 1.2 profile, the object +its shadows are expanded, has no SVG 1.1 form. Neither does a filter whose written form, with its region and id, is +one SVG import would not read, such as one longer than 64 KB. In that case, otherwise, and with the SVG Tiny 1.2 profile, the object becomes an image. SVG import keeps a filter that the live effects (Drop Shadow, the glows, Gaussian Blur, Feather) can't express as an `svg.filter` effect holding the filter's XML, with `scale` and `origin` from the object's user space, unless that user space is turned, skewed or scaled past 0.001 to 1000 points @@ -4394,6 +4443,13 @@ When a call to `artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard. names no artboard, its journal entry records the artboards it acted on as `selectedArtboards`. A replay of the journal acts on those artboards, and a recorded action leaves the value out and acts on the selection when it plays. +`edit.copy` and `edit.cut` with the Artboard tool chosen record the same `selectedArtboards`, and `art` beside it. +`edit.copy {selectedArtboards: [n…], art?}` and `edit.cut {selectedArtboards: [n…], art?}` run `artboard.copy` and +`artboard.cut` on those artboards whichever tool is chosen and whatever is selected, so a replay of the journal in a new +session takes the same artboards. A value of `selectedArtboards` that names no artboard fails as `indices` does, and `art` +that is not true or false fails. A recorded action leaves both values out: when it plays, `edit.copy` and `edit.cut` take +artboards when the Artboard tool is chosen then, and the selection otherwise. + `artboard.selectAll {}` chooses the Artboard tool when another tool is in use and selects every artboard. The active artboard stays active, the art's selection stays as it is, and no undo step is recorded → `{selected: [index…]}`, the active artboard first. @@ -5972,13 +6028,37 @@ the text. Return at the end of a list item starts another item in the same list. set, and paragraph styles carry it: a style captured from a paragraph without a list turns the list off. Reset Panel turns it off. The change is one undo step. +`text.setFormat {listAlign: "left"|"right"}` (Bullets and Numbering › Alignment) sets where the marker stands before +the first line's text. With `"left"`, the default, the marker starts where the first line starts, at the left indent +plus the first-line indent. With `"right"` the marker ends where the text starts, against the text. The text starts at +the same place with either alignment, so numbers of different widths aligned right end at the same place, and a number +that reaches past the left indent ends at the next tab stop, where the text starts. In a right-to-left list paragraph a +marker aligned right starts where the text ends. `listAlign` sets the paragraphs that are in a list, and those that the +same call puts in one; a paragraph in no list stays out of one. A list keeps its alignment when it changes between +bullets and numbers, and a list that is turned off and on again starts aligned left. An unknown value is an error and +changes nothing. The list saves the alignment (`"align": "right"`) only when it is right. + +`text.setFormat {listLevel: 1..9}` (Bullets and Numbering › Level) sets the level of the list paragraphs it touches, and +of those that the same call puts in a list; a paragraph in no list stays out of one. The value is a whole number from 1 +to 9 (`2` or `2.0`); any other value is an error and changes nothing. Each level past the first +sets the paragraph 18 pt further in than its left indent: its marker, its text and the lines it wraps to move together, +and in a right-to-left list paragraph they move in from the right. A list keeps its level when it changes between +bullets and numbers, and a list that is turned off and on again starts at level 1. The list saves the level +(`"level": 3`) only when it is not 1. In the Type tool, Tab with the caret at the start of a list paragraph (before its +first character, with nothing selected) runs `text.setFormat {listLevel}` with the next level, and Shift+Tab with the +level before; each is one undo step, and the text is unchanged. Tab at level 9 and Shift+Tab at level 1 do nothing. +Anywhere else, with characters selected, or in a paragraph in no list, Tab and Shift+Tab insert a tab. + A numbered paragraph's number counts the numbered paragraphs of its story up to and including it: the count runs through consecutive list paragraphs, through the frames of threaded type in thread order, and starts again at 1 after a paragraph in no list. An empty paragraph has no number and is not counted, and a bulleted paragraph keeps its bullet and -is not counted. Letters run from A to Z, then AA to ZZ, then AAA; roman numerals use IV, IX, XL, XC, CD and CM; 01 and +is not counted. Each level counts its own numbers: a list paragraph that has text sets the counts of the levels +deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a +shallower level goes on with that level's count. Letters run from A to Z, then AA to ZZ, then AAA; roman numerals use IV, IX, XL, XC, CD and CM; 01 and 001 keep every digit past 99 and 999; letters and roman numerals past 3999 are written in Arabic digits. In a right-to-left paragraph the number reads left to right with its period at its left. Each threaded frame saves the count -that its earlier frames carry into it (`listCount`, only when it is not 0). Type that is in no thread counts from 1: +that its earlier frames carry into it at level 1 (`listCount`, only when it is not 0) and at the levels past it +(`listLevelCounts`, level 2 first, only when one of them is not 0). Type that is in no thread counts from 1: after Remove Threading or Release Selection, in a frame converted to point type, and in every copy of a frame (pasted, duplicated, copied in the Layers panel, or taken out of a symbol or a placed file). A symbol made of a thread keeps the numbers in the symbol's art. @@ -5999,12 +6079,29 @@ Type › Bullets and Numbering › Apply Bullets and the canvas context menu's B same menus runs `type.applyNumbers {number?}`. Without `number` it runs `text.setFormat {list: "number"}`, and with it it runs `text.setFormat {number}`. +More Options at the end of the Paragraph panel's Bullets and Numbered List presets runs `ui.bulletsAndNumbering {tab?: "bullets"|"numbers"}`. It opens the Bullets and Numbering +dialog (`bulletsAndNumbering`) on the paragraphs the Paragraph panel acts on, on that tab; without `tab` it opens on +Numbers when the paragraphs are a numbered list and on Bullets otherwise. On both tabs the Level field, above the +presets, holds the paragraphs' list level (1 to 9; 1 for paragraphs in no list). The Bullets tab has the five bullet +presets and the Numbers tab has None and the seven number styles. The Position group has Alignment (Left or Right), +Left Indent and First Line Indent. The dialog opens with the values the Paragraph panel shows; where the paragraphs +differ, no preset is picked and the field is blank. Its fields are `tab`, `list` (`none`, `bullet` or `number`: the +kind of the preset picked), `bullet`, `number`, `listAlign`, `listLevel`, `leftIndent` and `firstLineIndent` (points), +and `ui.dialog.set` sets them, including those the dialog opened blank or without. The type shows the dialog's values while it is open. A preset picked for paragraphs that are in no list and have +no indents fills in 18 pt and -18 pt, and None takes them out again while they are unchanged. OK runs `text.setFormat` +with the values that changed, as one undo step, and Cancel puts the type back as it was. When a preset puts the +paragraphs in a list, OK also sends the indents the dialog shows, and the paragraphs take them, 0 included. OK does not +send an Alignment or Level change for paragraphs that stay in no list. With no type selected, or +with an unknown `tab`, `ui.bulletsAndNumbering` is an error. + Type › Bullets and Numbering › Convert to Text runs `type.convertListToText` on the selected type's paragraphs, or on those the Type tool's selection touches, and that runs `text.convertListToText {ids?, start?, end?}`. The marker of each list paragraph becomes characters at the start of the paragraph: the bullet, or the number and its period as the paragraph showed it, then a tab. They are set in the marker's font, size and color: the style of the paragraph's first -character without the attributes the marker leaves out. The paragraph leaves its list. When the marker's tab went to -the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that +character without the attributes the marker leaves out. The paragraph leaves its list. A paragraph at a list level +past the first keeps the level's indent in its left indent, so its marker and text stay where they were. A marker aligned right +(`listAlign` `right`) is written the same way: its characters go to the first line's start, where a marker aligned left +is drawn, and the text after its tab stays where it was. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that the text after the tab starts where it started after the marker. When the marker's tab went to a tab stop, the converted tab goes to the same place when that stop is left-aligned and no stop there or past it has a leader. When a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker @@ -6048,6 +6145,9 @@ the paragraph's text now starts; when the text before `at` starts no list, nothi ```json {"name":"run_command","arguments":{"command":"text.setFormat","params":{"bullet":"square"}}} {"name":"run_command","arguments":{"command":"text.setFormat","params":{"number":"upperRoman"}}} +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"listAlign":"right"}}} +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"listLevel":2,"start":4,"end":4}}} +{"name":"invoke_menu","arguments":{"command":"ui.bulletsAndNumbering","params":{"tab":"numbers"}}} {"name":"run_command","arguments":{"command":"text.convertListToText","params":{}}} ``` @@ -6075,6 +6175,38 @@ Setting superscript on subscript characters replaces the subscript, and setting replaces the superscript. When `document.setup {superscript, subscript, smallCapsSize}` changes the proportions, the characters take the new ones. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"position":"superscript","smallCaps":false}}} + +## Anti-aliasing method + +`text.setFormat {antiAlias: "none"|"sharp"|"crisp"|"strong"}` (the selected type, or `ids`) sets the Character panel's +Anti-aliasing Method, which decides how the edges of the characters are drawn as pixels: on the canvas, in raster exports +(Export As PNG, JPEG and the other raster formats, Export for Screens, Save for Web) and when Object › Rasterize or +Effect › Rasterize draws the type. The method belongs to the whole type object, and each frame of a thread keeps its +own method: a `start`/`end` range sets the whole frame, and threading frames or opening a file keeps each frame's +method. VectorCraft defines what each method does, on the pixels of the picture being drawn (the canvas at its zoom, +an export at its resolution): + +- `sharp`, the default, draws each edge pixel with the share of it the type covers, as before. +- `none` draws hard edges: a pixel takes the type's color where the type covers at least half of it, and nothing + elsewhere. +- `crisp` draws a pixel the type covers by a share c at `2c - 0.5`, kept between 0 and 1, so the edges are about half as + wide. +- `strong` draws a pixel the type covers by a share c at `2c`, at most 1, so the type looks heavier. + +The opacity of the type's paints stays as it is inside the characters. Type is drawn as Sharp draws it when it holds +inline graphics or color glyphs (bitmap or COLR), when its own fills or strokes in the Appearance panel include a brush +stroke or a blend mode other than Normal, when it overprints while Overprint Preview is on, and on the canvas while it +shows a substitution highlight. +With Preferences › General › Anti-aliased Artwork off, the canvas draws all its art with hard edges, type included, and +so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export draw the type unchanged. The native +document such a file carries (an EPS VectorCraft wrote, or an SVG or PDF saved with Preserve Editing) keeps each frame's +method, and opening the file restores it. +Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"antiAlias":"crisp"}}} +``` + ## Character Alignment `text.setFormat {charAlign}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, charAlign}` (a range) @@ -6445,7 +6577,8 @@ under its id. One that lists `settings` or `cache` keeps settings or a cache of (VectorCraft's own caps: 256 settings and 1 MiB, 512 MiB of cache): `plugin.list` and `plugin.info` give the cache's size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in or of all of them and returns `{cleared: [id], bytesFreed}`, replacing a plug-in keeps both, and `plugin.remove` deletes both (also for an id that -isn't installed but still has them). A module with other bytes than the plug-in installed with its id, or, with none +isn't installed but still has them). Files it can't delete stay in the plug-in's folder renamed with `.removed`, where +no plug-in reaches them, until a plug-in with that id is removed again ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has settings or a cache in the `Plug-ins` folder (`needsReplace: true` in its `plugin.inspect` entry), installs only with `replace: true`: ask the user first, since it takes that plug-in's place and gets its settings and cache. Without it, `plugin.install` returns @@ -6457,6 +6590,13 @@ anything else, or imports one of these without `api` 2 and the import's permissi permission names, and a `hash` that is not the module's SHA-256. `prefs.set` returns an error and changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty value or the folder the preference already holds, and the web build does not check folders. +A filter or live effect may declare commands of its own ([plugins.md](plugins.md#commands)): each is the command +`plugin.<id>.<command>` while the plug-in is installed, listed by `command_list` and in its `plugin.list` entry +(`commands: [{id, command, label, params, paramsSchema}]`). Run it with `{ids?, params?}` (its parameters may also +be top-level keys): the parameters are checked before the plug-in runs, a run is one undo step named after the +command, and a run that traps or runs out of its instruction budget returns an error that starts with the plug-in's +name and the command's label and leaves the document and the undo history as they were. For `plugin.run` and plug-in +commands, `ids` left out or null means the selection, and an `ids` that is not a list of object ids is an error. Safe Mode starts VectorCraft without plug-ins: `vectorcraft --safe-mode`, `vectorcraft-cli --safe-mode <subcommand>`, or the choice the desktop app offers when the last launch stopped while a plug-in was loading (Disable and Continue, Start @@ -6493,6 +6633,7 @@ Disable and Continue can't save the plug-in it turns off, this launch skips it a {"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}} {"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} +{"name":"run_command","arguments":{"command":"plugin.org.example.tidy.grow","params":{"by":3}}} {"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/tiles-2.wasm","replace":true}}} {"name":"run_command","arguments":{"command":"plugin.clearCache","params":{"id":"org.example.tiles"}}} ``` diff --git a/docs/plugins.md b/docs/plugins.md index a79bae03e..94d68697b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -23,9 +23,9 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, size, source, api, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | -| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | +| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`; an `ids` that is not a list of object ids is an error). One undo step; the result is selected, except the objects it locked or made guides. | | `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | | `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`) and `needsReplace` (true when `plugin.install` needs `replace: true`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder with its [settings and cache](#settings-and-cache). | @@ -53,6 +53,31 @@ a plug-in too (on the web this is how plug-ins get in). A heavy filter asks befo background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). +### Commands + +A filter or a live effect may declare commands of its own in its manifest (`commands`, [below](#manifest)). Each +becomes the command `plugin.<id>.<command>` while the plug-in is installed, and is gone once the plug-in is removed or +replaced by a module without it. + +- **Menus.** A plug-in with commands is a submenu of **Object › Plug-ins** (a live effect: **Effect › Plug-ins**) + named after the plug-in. The submenu holds the plug-in's own item, then its commands. A command with parameters has + an ellipsis and opens a dialog for them, with a live preview, as a filter's own item does. A manifest cannot put a + command in another menu: a `menu` key in a command is ignored. +- **Keyboard Shortcuts.** Edit › Keyboard Shortcuts lists each command under its submenu, and the user can give it a + shortcut. A manifest cannot give a command a shortcut. +- **Actions and agents.** A command run is recorded in an action like any other command and plays back with its + parameters. `run`, MCP and `command.batch` call it as `plugin.<id>.<command>` with `{"ids"?: [...], "params"?: {…}}` + (its parameters may also be top-level keys). MCP's `command_list` and `list_commands` list it, and `plugin.list` + lists a plug-in's commands as `commands: [{id, command, label, params, paramsSchema}]`. +- **Runs.** The parameters are checked against the command's declarations before the plug-in runs. The plug-in runs + through `vc_run` on the selection (or `ids`) as a filter run does, with `_context.mode` `"command"` and + `_context.command` set to the command's id ([Parameters](#parameters)), under the plug-in's + [permissions](#permissions): a live effect with commands may also hold `doc.write` and `state`, which only its + commands use. `ids` left out or null means the selection; an `ids` that is not a list of object ids is an error. One run + is one undo step named after the command. A run that traps, runs out of its instruction budget or fails leaves the + document and the undo history as they were, and the error starts with the plug-in's name and the command's label + (`“Cmd Test” › Grow: …`). + **Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every `*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; the web build installs from bytes). On the desktop, Choose… beside the field picks the folder; the dialog refuses a @@ -114,8 +139,8 @@ capability model proposed for PhotoCraft plug-ins | Permission | Kinds | What it allows | |---|---|---| | `doc.read` | all | Read the objects it is given. | -| `doc.write` | filter | Change, add and delete objects in the document. | -| `state` | filter | Keep data of its own with the document (`documentData`). | +| `doc.write` | filter, effect with `commands` | Change, add and delete objects in the document. | +| `state` | filter, effect with `commands` | Keep data of its own with the document (`documentData`). | | `effect.render` | effect | Change how the objects its effect is applied to are drawn. | | `export.write` | exporter | Write the document as a file in its own format. | | `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. | @@ -123,10 +148,12 @@ capability model proposed for PhotoCraft plug-ins | `cache` | all | Keep a cache of its own, which the user can clear ([settings and cache](#settings-and-cache)). Needs `api` 2. | - A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter - `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and - `export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is - empty may do none of these, and also installs without a prompt. -- An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three. + `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`, and also `doc.write` and `state` + when it declares [commands](#commands); an exporter `doc.read` and `export.write`. Such a plug-in installs without + a prompt. This rule is VectorCraft's own. A filter whose list is empty may do none of these, and also installs + without a prompt. +- An effect or an exporter that lists permissions lists `doc.read` and its own (`effect.render`, `export.write`). An + effect with commands may add `doc.write` and `state`, which only its commands use. A filter may list any of its three. Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, `progress`, `settings` or `cache` with an `api` below 2, or one of the names kept for a later plug-in API @@ -155,7 +182,7 @@ capability model proposed for PhotoCraft plug-ins in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, a stored plug-in whose permissions are not granted to that module is skipped. The Additional Plug-ins Folder and `plugin.reload` install only plug-ins whose permissions were granted, and report the others. -- The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its +- The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the document is refused. A refused output leaves the document as it was. - `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host @@ -303,6 +330,11 @@ pass the 1 GiB returns `-7` before it reads or writes anything. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id. +- `commands` (filters and live effects; not heavy filters): up to 32 commands of the plug-in's own + ([Commands](#commands)), each `{"id": "grow", "label": "Grow", "params"?: {…}}`. An `id` is 1–32 characters from + `a-z 0-9 _` that starts with a letter, and appears once. A `label` is 1–64 printable characters; menus show it with + control and bidirectional characters escaped. `params` are declared as the plug-in's own `params` are. A malformed + entry is refused when the module is installed, and the error names the command by its id (or its place, `[0]`). - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults @@ -395,7 +427,8 @@ type and `blob` but without their data, which follows the JSON in a saved file; "_context": {"mode": "filter", "bounds": [10, 0, 75, 60], "artboard": [0, 0, 612, 792], "documentData": {"pens": 4}}} ``` -`mode` is `"filter"`, `"effect"` or `"export"`; `bounds` the selection's geometric bounds (a live effect: the reference +`mode` is `"filter"`, `"effect"`, `"export"` or `"command"` (a [command](#commands) run, whose `command` is the +command's id); `bounds` the selection's geometric bounds (a live effect: the reference box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document (`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the diff --git a/docs/roadmap.md b/docs/roadmap.md index a43e3af5a..a5e5a3558 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,9 +1,11 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (numeric fields step on wheel hover without taking keyboard focus, #5949) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence +Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. + Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -14,6 +16,8 @@ Group Selection keeps an already selected component selected during a drag; a co Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + **Feature evidence last updated:** 2026-11-03. M1 selection: Enclosing Lasso selects whole objects enclosed by a freehand loop. It is an add-on @@ -51,6 +55,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep the units their document or preset was made in, and the built-in tabs still follow the chosen units (#2078). Scores and estimates unchanged. | | 2026-11-03 | minor | M1: Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot; estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | | 2026-11-03 | minor | M7: Find Font replacement sources Document, Recent and System are shipped (#5217); estimates unchanged. | @@ -146,7 +151,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 | | M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above | @@ -183,6 +188,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged | | 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b7e9e7612..8ee5cbe92 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,9 +1,11 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence +Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. A canvas-first/panel-second regression also checks that a field turn cannot start a canvas zoom glide when the pointer enters the canvas on the next frame. Scores and estimates unchanged. + Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -14,10 +16,14 @@ Group Selection keeps an already selected component selected during a drag; a co Ligature caret stops use a font's GDEF contour-point positions in horizontal and vertical text. They use unhinted outlines at the default variation location; invalid point indices use the existing evenly divided fallback. Scores and estimates are unchanged. +Spiral decay now applies after each full wind of four segments. A newly drawn spiral at 80% decay keeps 80% of its radius after one wind. Existing saved paths retain their geometry; newly drawn paths with the same settings differ from earlier VectorCraft builds. Scores and estimates are unchanged. + Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. **Feature evidence last updated:** 2026-11-05. +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged @@ -78,9 +84,11 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-03 | minor | Correct the Type core missing list: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. | | 2026-11-03 | minor | Ligature caret stops use GDEF contour-point positions in horizontal and vertical type at the font's default variation location; invalid point indices keep the evenly divided fallback. Scores and estimates unchanged. | | 2026-11-04 | minor | Record Find Font's replacement rows: family and style together, a search over them, and a scroll state of their own (#4972); scores and estimates unchanged. | +| 2026-11-03 | minor | Spiral decay applies once per full wind of four segments; newly drawn spirals with the same settings wind more broadly than earlier builds. Existing paths are unchanged. Scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-11-02 | minor | Legacy `.ai` saves (version 7) write type with rotated characters as outlines that keep each character's rotation, with a warning unless `outlineText` is on (#5186); scores unchanged. | | 2026-11-03 | minor | Record shipped Find Font replacement sources: Document, Recent and System (#5217); scores and estimates unchanged. | | 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. | @@ -548,6 +556,7 @@ since 2026-10-01. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. +- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **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; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. @@ -647,6 +656,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: when no object is bound to a variable that the data set has a value for, for example after Import Data Sets… defines new variables without binding them, choosing the data set in the Variables panel or stepping to it with the panel's arrows shows a status message, and `dataset.select`, `dataset.next` and `dataset.prev` add the message to the reply as `warning`. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. + - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). The folder and file names drop trailing periods and spaces (`a..svg` gives `a`). When two source files would get the same name that way (compared without case), or a later file's folder or file would take the name of an output an earlier file wrote, of a source file in the destination folder or, for data sets, of a file already there, Batch adds `-2`, `-3`… to the later one's name. - Actions panel menu: Save Actions… writes the current action set with all its actions to a file, and Load Actions… adds the set from such a file to the panel ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html); `ui.actionSet.save`, `ui.actionSet.load`). The file is VectorCraft's own action set file (`.vcactions`); a loaded set named like one in the panel gets the next free number. - Actions panel menu: Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `actionSet.read`, `ui.actionSet.load`). VectorCraft's own rules: the set comes in with all its actions. A step with a label and no recorded values comes in as the one menu command with the step's label, when that command is directly in a top menu, is what its menu bar item runs with no values and opens no dialog. A step that is off in the file stays excluded from playback. The other steps, recorded menu items included, are left out, and the status bar says how many. - Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color. @@ -683,6 +693,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,113 of 1,169 pass for `String`, 262 of 262 for `Number`, 878 of 908 for `RegExp` and 6 of 6 for `Date`. The others need `Array` and `Boolean` methods, or expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). The ES5 language tests that can now run give 3,742 of 3,746 passing runs. Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. @@ -712,6 +723,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (<https://helpx.adobe.com/illustrator/using/tools.html>; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`). - Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`). - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). + - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). @@ -729,6 +741,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). + - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. - Commands that read their `ids` (or `slices`) list themselves refuse a list with an entry that is not a non-negative integer, and a value that is not a list, and change nothing; they no longer drop the entry and act on the rest (Copy Appearance From, the Edit Colors commands and Overprint Black, Find and Replace, `layer.highlight`, `layer.move`, `library.add`, the Links panel commands, `object.transform`, Make Pattern, the Repeat commands, the slice commands, Join, Average and Join, `plugin.run`, `symbol.edit`, Make Opacity Mask; `tests::ids_without_selection::an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing`). The error for an entry of an object id list names the key, the entry and its value ("`ids` entry 1 is true, not a non-negative integer object id"), with a row in every catalog that has the other object id errors. - The errors of Enter Isolation Mode (`object.isolate`) for a type object, for hidden or locked art and for a call with no `id` while nothing is selected ("missing id", which other commands that take an `id` also give), and the error of the Layers panel's target circle (`layer.target`) for an object outside the isolated group, show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch. - An EPS or PostScript file that stops on a PostScript error gives the error's name as PostScript spells it (`typecheck`, `rangecheck` and the other names the reader raises, except `undefined`, whose message names the unknown name) followed by what it means, in the warning after opening and in the error when the file can't be opened. The status bar and the open-error dialog show the meaning in the interface language: each meaning has a row in every catalog (`postscript_error_meanings_are_translated`). @@ -738,6 +751,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in library, Basic Shapes, is original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a `<use>`, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. + - **Dynamic symbol appearance follows what an object draws** (VectorCraft's own rule): `symbol.instance.setAppearance` refuses a fill or stroke for an object of the art that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a nested symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect) with an error that names the key and the object, and the call changes nothing. Opacity, blend and `null`, which takes a stored change back, are taken for every object. Tests: `crates/engine/src/tests/dynamic_symbols.rs`. - Symbol editing mode keeps every instance in step: instances of a dynamic symbol inside other symbols' art, pattern tiles, graph designs, stored brushes and opacity masks keep their appearance changes on the objects an edit reorders and drop the changes to objects it deletes. The view does not draw the instance the edit was entered from. Area type in the symbol's art takes the text wrap that moving a wrap object gives it. Editing another symbol at a place that fails keeps the current edit open, and only the editing layer or art on it can be isolated. Tests: `crates/engine/src/tests/symboledit.rs`, `isolation::symbol_editing_does_not_draw_the_instance_it_was_entered_from`, `panels::symbols::tests::double_clicking_a_symbol_in_the_panel_edits_it_in_either_view`, `the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels`, `the_control_bars_edit_symbol_edits_the_selected_instances_symbol`, `chrome::tests::the_control_bar_exits_symbol_editing_mode`. - **Dynamic symbol instances on the canvas** ([Symbols, archived copy](https://web.archive.org/web/20240206075757/https://helpx.adobe.com/illustrator/using/symbols.html)): each instance of a dynamic symbol can have an appearance of its own while it shares the symbol's shape. The rest of this entry is VectorCraft's own rules. A Direct Selection click on an instance of a dynamic symbol selects the path, compound path or type object of the symbol's art under the pointer and outlines it (`symbol.instance.selectPart`); the Swatches and Color panels, the Control bar, the Eyedropper and the other fill and stroke controls then change that object's solid color fill or stroke for this instance only, each as one undo step, and the symbol's art and its other instances stay as they were. Alt or Shift picks the instance whole. A gradient or pattern for the object, an Eyedropper source that is not a path, compound path or type, a click on any other object of the art (a group, image, blend or nested instance) and the commands that change the selected objects (opacity, moving, Delete, Group, Hide, Lock, Arrange, Expand, the gradient commands, the Appearance panel's rows and others) are refused with a status message. Copy, Export Selection, Fit to Selection, Liquify, Isolate, Add to Library and Save Selection use the whole instance (`docs/mcp.md`), and another selection tool selects the instance whole again. Tests: `crates/engine/src/tests/dynamic_symbols.rs`, `direct::tests::a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer`. - **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it stays within the art's box (guides outside the art are not supported yet) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`. @@ -766,7 +780,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With Real-time Drawing and Editing off, a dragged selection whose outline points and evaluated objects come to more than 100,000 shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects`). - With Real-time Drawing and Editing off, a move or handle drag of a group, blend, envelope, repeat or intertwine draws the outline of each of its objects instead of one union of them, as VectorCraft's own rule (`real_time_drawing_off_outlines_a_dragged_group_member_by_member`, `real_time_drawing_off_outlines_a_resized_group_member_by_member`, `real_time_drawing_off_outlines_a_dragged_blend_step_by_step`, `real_time_drawing_off_outlines_a_dragged_envelope_object_by_object`). A group with a Pathfinder effect draws the area the effect makes, a gradient mesh draws its mesh lines, and a symbol instance draws its bounds (`real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area`, `real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once`, `real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds`). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). - A plain step of a number field (Up/Down or a wheel notch) goes to the next whole unit in its direction and a Ctrl/Cmd step goes to the next tenth, so 5.1 mm steps up to 6 mm and down to 5 mm (#2881). A Shift step adds or takes ten and keeps the fraction. - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. @@ -822,6 +836,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Select by Artboards (Object › Plug-ins › Selection Tools, a VectorCraft addition: #5525, #5548, #5549) selects the objects overlapping, fully inside or fully outside one or more chosen artboards (`addon.select.byArtboards {artboards, relation}`). Overlapping is All on Active Artboard's test, shared as `select::on_artboard`; Fully inside means within one artboard, edges included; Fully outside means on none. It looks at what Select All takes, so hidden, locked and locked-artboard art stays out. Its dialog lists each artboard's number, name and size in the document's units, filters by name, chooses rows with click, Shift-click and Cmd/Ctrl-click, and fits a row's artboard in the window on double-click (`view.goToArtboard`). Tests: `crates/engine/src/cmd/addons/select/tests.rs`, `crates/ui-egui/src/addons/select/tests.rs`, `crates/engine/tests/command_sweep.rs`. Select by Link Status (Object › Plug-ins › Selection Tools and the Links panel menu, a VectorCraft addition: #5526) selects the image objects and placed documents whose link is linked (its file found unchanged), embedded, missing or modified, as `links.list` reports it (`addon.select.byLinkStatus {status, ids?, add?, zoom?}`); Select Linked, Embedded, Missing and Modified are off when no image has that status and add to the selection with Shift. Objects that are hidden or locked, inside a hidden or locked object or layer, on a template layer, on a locked artboard, or outside the isolated group are left out (the `select::is_selectable` test) and returned with the reason, and the status bar says how many, in plural forms of every catalog. Select by Link Status… lists each image and placed document with its name, status and format, searches by name, filters by status, picks rows with checkboxes and All / None, and zooms to the selection with the existing Zoom to Selection (`addon.arrange.zoomSelection`). Missing-link markers on the canvas are #5547. Tests: `crates/engine/src/cmd/addons/select/tests.rs` (`by_link_status`), `crates/ui-egui/src/addons/select/link_status/tests.rs`, `crates/engine/tests/command_sweep.rs`. A marquee selects a clip group, or an object on a layer with a clipping mask, only when it reaches the area inside the clipping path, and it measures the art only within that path's bounds. Within the bounds of a mask that is not a rectangle, it can still select art that the mask hides. Direct Selection's marquee still selects the hidden anchor points inside it (#2345). + When a clip group, a layer with a clipping mask or a single object in one is isolated, the Selection, Direct Selection and Group Selection tools also select the art that the mask hides, by a click or a marquee, and a drag from that art moves it (#3748). In isolation mode the canvas Unlock submenu also lists that art when it is locked. Outside isolation mode a click selects clipped art only inside the mask. A marquee selects a Shaper Group only when it reaches the art the group shows, not the original shapes kept under it, and Direct Selection's clicks and marquee do not select those shapes' anchor points. In Construction mode a marquee selects the original shapes, and Direct Selection selects their anchor points. While an opacity mask or a pattern is edited, a Shaper double-click on a Shaper Group in the mask or pattern art selects the original shape under the pointer without entering Construction mode, the Shaper Group's mode widget does not show, and a Shaper scribble, Release and Expand keep the editing layer isolated. A marquee selects an Intertwine group only when it reaches one of the group's members, as for a plain group, and it selects a repeat only when it reaches one of the copies the repeat draws. Direct and Group Selection's marquees select the anchor points of an Intertwine group's members, not the group, and Group Selection's E marquee selects the members inside it. @@ -859,7 +874,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). While a grid is dragged out, F lowers `rowSkew` (rectangular) or `radialSkew` (polar) by 10 percentage points and V raises it by 10; X lowers `columnSkew` (rectangular) or `concentricSkew` (polar) by 10 and C raises it by 10. Plain and Shift presses work; with Alt, Ctrl or Cmd held they do not, as for the other letters a tool takes while dragging. Each press changes the skew once and updates the preview; values stay within ±500% and remain in the tool options after release. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list the four drag keys and a 10% change. The signs (positive values follow VectorCraft's existing skew convention, so F and X lower the stored value) and one change per press with key repeat ignored are our own rules. While an arc is dragged out, C switches it between open and closed, F switches its base between the X and Y axes in the same box, and Up/Down raise and lower its slope by 10 within −100…100; each press switches once, and the values stay as the Arc tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list C, F and Up/Down for an arc being dragged out. F as the base switch, the arrows changing the slope, the step of 10 and one switch per press are our own rules. - While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. + While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind and Decay as the amount each wind decreases. An 80% spiral has 80% of its outer radius after four segments; newly drawn spirals with the same settings wind more broadly than in earlier VectorCraft builds. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. Escape while an arc, spiral or grid is dragged out cancels it and puts back the options its keys, Alt and Ctrl/Cmd changed (slope, open or closed, base axis, segments, decay, dividers and skews), so the next drag and the tool's options start from the values before the canceled drag; a drag that is let go keeps them, and an option set with `tool.setOption` during the drag stays. This is VectorCraft's own rule. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). While Alt is held, the Scissors tool works as the Add Anchor Point tool ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): a click on a segment adds an anchor point without cutting (`path.insertAnchor`), and the pointer shows the Add Anchor Point cursor. When Alt is let go, a click cuts again. @@ -889,7 +904,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. @@ -976,6 +991,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Artboards › Fit to Selected Art resizes the active artboard (`artboard.fitToSelection {index}`). It used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Artwork Bounds and Fit to Selected Art steps that name no artboard resize the artboard that is active then. Object › Artboards › Fit to Artwork Bounds and the Artboards panel menu's Fit to Artwork Bounds fit each selected artboard (those the Artboard tool has selected, else the active one) to the art on it, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes (`artboard.fitToArt {indices}`). An object partly on an artboard counts for each artboard it overlaps, and an artboard with no art on it keeps its size and place. They used to fit the active artboard to all the art in the document. The Artboards panel menu's Duplicate Artboards copies each selected artboard (those the Artboard tool has selected, else the active one) with its art, in one undo step, and the copies are then selected, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes. With the Artboard tool, Copy and Cut take every selected artboard with its art, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes, and Paste brings them all back in one undo step (`artboard.duplicate`, `artboard.copy` and `artboard.cut` with `indices`). The copies keep their places relative to each other, together right of the last artboard. All three used to act on the active artboard only. + A replay of the journal repeats a Cut or Copy made with the Artboard tool on the artboards it took (`edit.cut` and `edit.copy` record `selectedArtboards` and `art`), whichever tool is chosen in the new session and whatever is selected there. A recorded action follows the tool chosen when it plays. The replay used to act on the objects selected then, or stop with nothing selected. This rule is VectorCraft's own. With the Artboard tool, the canvas context menu lists the entries of the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page in its order: Cut, Copy, Paste at the pointer, Undo, Redo, New Artboard, Duplicate Artboards, Rename (`artboard.rename`), Delete Artboards, Delete Empty Artboards, Select All Artboards (`artboard.selectAll`), Lock Artboards and Unlock Artboards, Rearrange, Fit to Artwork Bounds, Switch Orientation (`artboard.switchOrientation`), Artboard Options and Export. A right-click on an artboard that is not selected selects it first. The Artboards panel menu's New Artboard adds the artboard in one undo step and makes it the active one, as the panel's button, the Control bar and Properties do; with the Artboard tool it is then the only one selected. This is VectorCraft's own rule. The menu item used to leave the active artboard where it was. With the Artboard tool, Paste and Paste in Place make the first pasted artboard the active one (the navigator's and the Artboards panel's), and Cut makes the artboard before the first cut one active (the new first artboard when artboard 1 was cut). A command that chooses another document (`document.activate`) starts the tool on that document's active artboard, as choosing its tab does, and File › Revert keeps the active artboard and starts the tool on it. Paste used to leave the active artboard where it was while the tool had the pasted artboard active, and Cut left the active artboard on the artboard after the cut one while the tool had the one before it. @@ -993,6 +1009,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Geometry and effects:** - **Open-path anchor Delete:** Delete/Backspace removes selected anchors and their adjoining segments, leaving drawable runs as separate open subpaths of the original object. Surviving curves and appearance stay intact; one Undo restores the document and selection, and a second Delete removes the selected remainder. Explicit Remove Anchor Points still reconnects/refits. Four core regressions, one full-frame Delete/Backspace regression and three existing removal/segment controls pass, covering these distinctions, multiple deletions, transformed geometry and mixed whole-object selection. Behaviour references: the legacy public [Illustrator reference guide, Add or delete an anchor point](https://helpx.adobe.com/content/dam/help/en/pdf/illustrator_reference.pdf) distinguishes keyboard deletion from dedicated removal; the current [Add or remove anchor points](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/add-or-remove-anchor-points.html) describes the dedicated tools. Other G11 gaps and scores/hours remain unchanged. - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). + Outline Stroke, Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines apply the object's visible geometry effects (Offset Path, Zig Zag and the others) to the path, and then apply each stroke's own geometry effects before outlining that stroke, as the canvas draws it. The filled outline of a stroke without a brush keeps the stroke's other effects, such as a drop shadow. Outline Stroke and Unite remove the overlaps of a long, low path with the sweep turned along it: when the estimated number of segments the sweep holds at once is under two thirds as large for the path turned a quarter turn (paths of 2,048 segments or more), the overlaps are removed on the turned path and the result is turned back, which moves no point by rounding. The lookups of the input curve that a piece of the result lies on, and of the input anchors that are kept, can search along y instead of x: once lookups with more than 64 candidates along x have counted 16 for each input curve or anchor, a lookup searches along y when it finds fewer candidates there (under a quarter as many for curves), and where that has rarely happened it measures along y in only one lookup of 32. On one open path of 200,000 anchors in rows of 1,000, Outline Stroke takes 17.3 s of processor time instead of 31.3 s and Unite 2.9 s instead of 6.5 s in a release build. A turned result has as many subpaths as the result without the turn, and the area where the two differ is no larger than the refit precision times the outline's length (`crates/pathops/src/sweep_axis.rs` tests). - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged. - Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect. @@ -1038,6 +1055,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in rectangular area type, rows and columns included. In area type of another shape, or with text wrap, such a paragraph is broken with the Single-line Composer. The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. + The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. @@ -1070,9 +1088,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. The Character panel menu's Kashidas switch (with the Indic options; `text.setFormat` / `text.setRangeStyle {kashidas}`) allows Kashidas on selected characters, on by default for new and old text. A pair of joined letters receives one only when both characters allow it; turning one off leaves other eligible pairs in the word available. Character styles and the native file keep the switch. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html) documents the menu control and the separate paragraph length choices; the default and pair rule are VectorCraft choices. - The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and list detection while typing are not built yet. + The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and Convert to Text are not built yet. The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1. + More Options in the Bullets and Numbered List presets (`ui.bulletsAndNumbering`) opens the Bullets and Numbering dialog ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): a Bullets tab and a Numbers tab with the presets, and the Position group's Alignment, Left Indent and First Line Indent, shown on the type while the dialog is open; OK applies them with `text.setFormat` (Alignment is its `listAlign`) as one undo step, and Cancel puts the type back. Alignment Left starts the marker at the first line's start, and Right ends it where the text starts. The cited page does not say where the marker sits for each choice, so these meanings are VectorCraft's own rule. Paragraph styles and the native file keep the alignment. + Tab and Shift+Tab with the caret at the start of a list paragraph (`text.setFormat {listLevel}`), and the Bullets and Numbering dialog's Level field (OK sends it as `listLevel`), move the paragraph down and up the list's nine levels ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html)): each level past the first sets the paragraph 18 pt further in, each level counts its own numbers from 1, and a paragraph back at a shallower level goes on with that level's count, through threaded frames too. Elsewhere Tab inserts a tab as before; one Undo takes a level change back, and paragraph styles and the native file keep the level. Convert to Text writes each level's numbers and keeps a deeper paragraph's indent. Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted. While no type is being edited, a press with Shift held makes new type with the horizontal or vertical counterpart of the Type, Area Type or Type on a Path tool chosen, and a press with Alt (Option) held makes it with Type on a Path in place of Area Type or the reverse, in the chosen tool's writing direction; with both keys held, both switch ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). The pointer shows the tool the press acts as, and the tool chosen stays selected. The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `+`, `#` or `>` makes it a bulleted list with the filled dot, the hollow circle, the filled square or the dash (a VectorCraft choice: the page does not say which bullet each character gives). The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing. @@ -1084,7 +1104,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Snap to Unit off and Shift not held, a tab stop placed by a click on the Tabs panel's ruler or dragged on it lands on the nearest whole point. A position typed in the X or Y field keeps its hundredths of a point. The Tabs ruler's upper first-line marker and lower left-indent marker show absolute positions and set paragraph indents (`text.setFormat`). Dragging the lower marker keeps the upper position, while Ctrl-dragging it (Command on macOS) moves both; marker drags snap like tab stops, and each release is one undo step. With differing left indents, only the upper marker is shown and its absolute target applies to every touched paragraph. When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. - The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. + The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. @@ -1104,6 +1124,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, Document Library keeps neutral tones neutral:** each pixel takes the library color nearest in Oklab with differences in hue and chroma counted three times, so a gray or a muted brown traces as a neutral library color instead of a saturated one of similar lightness; a code-drawn test of gray and brown patches with a black, light and blue library in `crates/trace`. - **Image Trace, source image on hold:** while the eye button next to the Image Trace panel's View is held down, the canvas shows the selected Image Trace objects' source image whatever their View, and shows their View again on release, with no change to the document or the undo history. - **Image Trace, large images:** images over 1500 px on a side trace with a warning in the result and the status bar; the anchor limit is 8,000,000 (2,000,000 on the web), measured at about 90 bytes an anchor (see [quality notes](image-trace-quality.md#large-images)). +- **Image Trace, Ignore Color for the background only:** `"ignoreBackground": true` with Ignore Color leaves out only the areas of the ignored color that reach the image's edge and traces the areas of it that other colors enclose, in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger decoded raster (the 64-megapixel copy for a bigger image) is averaged down in whole-number blocks first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger image is averaged down by a whole factor first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Preview:** the Image Trace panel's Preview checkbox, before Trace, is on by default, and a setting change traces the selected Image Trace object again at once. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected. When Preview is turned on with that object not selected, the object is traced with its changes when it is selected again. Preview is kept with the UI preferences, and Reset to Default leaves it. The `imageTrace.preview` command turns it on or off. @@ -1123,6 +1144,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. +- **Image Trace, Keep Colors:** `"keepColors": ["#rrggbb", …]` traces only the palette colors near a listed color (Abutting: each with the paths the trace of every color gives it; Overlapping: not reaching under the colors left out), in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. @@ -1301,6 +1323,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`). + A `.ai` or EPS symbol library, whose editing data holds symbol definitions and their list but no layers, opens with those symbols in list order, each with its registration point. Other Library… in the Symbols panel's Symbol Libraries Menu imports the symbols of the file you pick ([Create or import symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)) and lists such a library's symbols in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the symbols defined before its layers (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ai/tests.rs`, `cmd::fileio::tests_aiimport::a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols`). A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`). In SVG export without Fewer `<tspan>` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned `<tspan>`, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`). To find those characters, SVG export lays a type object out a second time only when the second layout can differ from the first: point type with Line-end Punctuation Half Width and no Japanese punctuation or letters, the setting new type gets, is laid out once. Exporting 5,000 such objects takes a median of 109.4 ms instead of 171.5 ms on Windows 11, with the same SVG (`crates/svg/src/export/text/tests.rs`). @@ -1427,6 +1450,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429). Offset Path looks up the segments near each candidate hole or island in a tree of the source path's segment bounds when it decides whether to drop that contour, instead of testing the bounds of every segment. The output paths are the same. On compound and open paths of 25,000 to 40,000 anchors with 6,400 to 11,976 candidate contours, that check takes 11 to 13 ms instead of 140 to 649 ms (medians of five interleaved runs). The limit on overlapping segment pairs is the same, so a path that stops with "Offset Path: the path is too complex to offset" still stops there. Finding the distinct regions of an arrangement lists the shapes that cover each region instead of keeping a flag for every shape, so that step's time follows how many shapes cover each region rather than how many shapes there are. Divide, Hard Mix, Soft Mix, Shape Builder, Flatten Transparency, trapping, Intertwine and the Pathfinder effects are among the commands that use that step. The regions, their order and the output are the same. Divide on 3,000 overlapping rectangles takes 0.84 to 1.13 billion CPU cycles on its thread instead of 1.25 to 1.46 billion (dev build, six runs of each, alternating, while other builds were running). + The contour passes of the two-path boolean operations, Offset Path, compound shapes and Intertwine test the list of shapes that cover each face instead of a flag for every shape, so each test's time follows how many shapes cover the face rather than how many shapes there are. The contours, their order and the output are the same. The many-shape boolean that compound shapes use takes 0.75 to 0.83 billion CPU cycles on its thread instead of 1.28 to 1.33 billion to unite 10,000 squares that do not touch, and 2.27 to 2.30 billion instead of 2.43 to 2.47 billion for 1,500 members of two squares each over 3,000 overlapping squares (dev build, six runs of each, alternating, while other builds were running). With more than 10,000 type on a path objects selected, the Selection and Direct Selection tools neither draw nor hit-test the objects' brackets. The renderer outlines the stroke of a path object with more than 1,024 path elements, without dashes or a width profile, only in and around the view, with a margin of the view's size on each side, and outlines it again when the view moves past that margin. The pixels are the same. At 1600 × 1200, the frame after one anchor of an open path of 200,000 anchors moves takes 28 to 30 ms instead of 103 ms, and with a closed path of 50,000 anchors 10 ms instead of 30 ms (`cargo run --release -p vectorcraft-engine --example anchor_bench`). The renderer outlines a dashed stroke of such a path the same way, without a width profile, and draws its dashes and dots in the same places as when the whole path is dashed, so the pixels are the same. A pattern too fine to draw is drawn solid, as before, and its stroke is outlined near the view too. At 1600 × 1200, the frame after one anchor moves takes 14 ms instead of 102 ms with a filled closed path of 50,000 anchors and a stroke of 6 pt dashes and 4 pt gaps (`cargo run --release -p vectorcraft-engine --example anchor_bench`, which now includes that document). @@ -1538,8 +1562,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. -- **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. @@ -1788,7 +1813,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov ## Dynamic symbols (2026-11-01) -Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. +Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own opacity and blend for any object of the symbol's art, and its own fill and stroke for an object that draws them, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. ## Document Tools add-on: Export All Open Documents (2026-11-01) @@ -1841,6 +1866,8 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | +| 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index b7e7fffab..d9fe790bd 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -21,10 +21,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | | Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool that double-clicked into the type, or to the Selection tool (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | +| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | | Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62f6e496d..5cfffecc6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,9 +1,11 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (numeric fields step on wheel hover without taking keyboard focus, #5949) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence +Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. + | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -15,6 +17,8 @@ Group Selection keeps an already selected component selected during a drag; a co Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + **Feature evidence last updated:** 2026-11-03. Enclosing Lasso is an add-on under Object › Plug-ins with its own heading in All Tools. It has @@ -73,6 +77,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-03 | minor | Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-10-26 | minor | Pencil Fidelity goes down to 0.01 pt (dialog, tool and engine; Smooth keeps 0.05) on a logarithmic slider across the Tool Options row, with hundredths in the field and key steps that fit the range (#3328); scores and estimates unchanged (estimated) | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | @@ -259,7 +264,7 @@ tools, never checked side by side) isn't captured row by row. | Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. Other font-picking menus and modifier-wheel input keep their existing behavior. | | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | -| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a focused field; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | +| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | @@ -268,7 +273,7 @@ tools, never checked side by side) isn't captured row by row. | Scrubby labels (beyond Illustrator) | done | #400; the labels that are links (the Control bar's Stroke: and Opacity:, the Properties panel's Stroke) scrub too, and a click on them still opens their panel (#4088) | | 3D Lighting Color picker, preview, reset dot and Reset all | initial | One to ten opaque RGB lights for Revolve, Extrude & Bevel, Inflate and Rotate; select, add or delete a light and edit its settings or gizmo; Standard, Diffuse, Top Left and Right select original rigs, with Custom for manual changes; Cancel restores all lights | | Dialogs open with the first field focused and selected; Enter confirms | done | on every open, not only the first (#1367); a first field that starts greyed out takes the focus once enabled (#1395) | -| Value fields: selected text readable in every brightness; a focused field follows its value when a slider or preview changes it | partial | #1490 (part of #1465: the wheel over a hovered, unfocused field still scrolls the panel), #1457 | +| Value fields: selected text readable in every brightness; a focused field follows its value when a slider or preview changes it | partial | #1490, #1457; hover wheel stepping #5949 | | Sliders in dialogs and popups (Save for Web, raster export options, Preferences, the opacity popup) show their rail, filled up to the handle | done | #1483 | | Rectangular Grid and Polar Grid click dialogs: size, divider counts with a skew slider each, Use Outside Rectangle As Frame / Create Compound Path From Ellipses, Fill Grid; kept as the tool's options, used by drags too | done | #1377. The options are the documented ones; how far a skew percentage moves the dividers and which way the radial skew turns are our own choice (the public documentation gives neither). The reference point locator is in the dialog, and a double-click on the tool opens it as the tool's Tool Options (next row) | | Double-click the Line Segment, Arc, Rectangular Grid or Polar Grid tool: its Tool Options ([Rectangular Grid tool](https://helpx.adobe.com/illustrator/using/tool-techniques/rectangular-grid-tool.html) and [Arc tool](https://helpx.adobe.com/illustrator/using/tool-techniques/arc-tool.html) step 3, [Tools](https://helpx.adobe.com/illustrator/using/tools.html)) | done | #2691. The tool's click dialog without a place to draw: Line Segment's Length, Angle and Fill Line; Arc's lengths, Reference Point, Type, Base Along, Slope and Fill Arc; the grids' Reference Point, Default Size, dividers, skews and switches. OK draws nothing and sets what the next drag draws with (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and what a click's dialog opens at (with the Length, Angle and Default Size); Cancel changes nothing (`double_clicking_the_line_arc_and_grid_tools_opens_their_tool_options`) | @@ -289,7 +294,7 @@ tools, never checked side by side) isn't captured row by row. | Artboard tool: the canvas right-click menu has Lock Artboards (Lock Selected, Lock All Except Selected, Lock All) and Unlock Artboards (Unlock Selected, Unlock All) | done | #2531; `artboard.setProps` with `locked` for each artboard named; the lock itself is #2592 | | Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool | done | Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. | | Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture | done | Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. | -| Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none | done | #1802 (#1686, #1656) | +| Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch | done | #1802 (#1686, #1656) | | Selection tool: selected area type and type on a path show an in port and an out port (empty, an arrow where threaded, the red "+" of overflow); a click on either loads the text cursor; a click on a closed path, on empty canvas or a drag threads the text there, after the clicked frame from its out port, before it from its in port; Escape or another tool drops it; a double-click on a threaded port breaks the thread there | partial | #2572 (`text.thread.add`, `before` for the in port); the out port sits on the frame's right side above the bottom-right corner handle and the in port on its left side below the top-left one, so the handles can still be grabbed; a double-click on a threaded port breaks the thread (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)); the loaded cursor borrows the I-beam | | Shift-click selects several artboards (Artboard tool, Artboards panel); they move, Alt-copy and delete together | done | `artboard.move`/`artboard.delete` `indices` (#1139); in the panel, Shift- and Ctrl/Cmd-click work with any tool by switching to the Artboard tool (#5698); panel menu, trash and Properties share selected-set deletion; all-selected retains artboard 1 and a single board cannot be deleted, with pointer-event and Undo/Redo coverage | | Artboards panel: drag a row above or below another to reorder the artboards (as Move Up / Move Down do) | done | #4687; a line shows where the row lands, `artboard.reorder` in one undo step, the moved artboard becomes active; public forum answers describe the drag | @@ -405,6 +410,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | | Workspaces (save, reset, switch); each remembers the layout changes made in it across switches and restarts until Reset <name> | done | `UiState::workspace_changes` | | Opening a file: after 0.4 s a progress window with the file name, a moving bar (reading counts no steps), how many more files wait, Cancel (Esc: the file doesn't open) and Continue in Background; never over another dialog or progress window | done | #5930; `opening::show`, `file.cancelOpen`, `file.openInBackground`. Save, Place and Close still show only the status bar's spinner (Place and Save run off the UI thread; Close is immediate) | +| Window › Help Bar shows or hides the tool-help strip apart from the status bar; kept with the preferences and in saved workspaces; Presentation Mode hides both bars | done | `window.helpBar`; `help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar` | | Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet | | Swatch, graphic style and symbol library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again; when its file is missing the panel reopens showing the file's path with Locate Library…, and the status bar names the file) | done | #2603; panel ids `library:<kind>:<id>`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`, `ui.libraryPanel.locate {panel, path?}`; the library panels have no sort option, as before | | DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending | @@ -482,6 +488,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, as with Direct Selection chosen, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected, a Shift-click on a corner widget picks that corner alone instead of deselecting it (#5188); adding a hidden corner's widget by Shift-click stays open (#1232); scores and estimates unchanged | | 2026-11-06 | minor | Keyboard shortcuts: Cmd/Ctrl+Alt+. and Cmd/Ctrl+Alt+, (Stroke Weight Up and Down) step each selected stroke's weight, or the next object drawn's (#5797); scores and estimates unchanged | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index bfd6172ac..0324964c5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -44,7 +44,8 @@ commands: stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 verified), parse its ES5 subset with vectorcraft-js and run the ES5 language - tests its interpreter supports, printing the counts + tests its interpreter supports and the String, Number, RegExp and Date + built-ins tests, printing the counts web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs index 30f6f2472..88cbef395 100644 --- a/xtask/src/test262.rs +++ b/xtask/src/test262.rs @@ -2,7 +2,8 @@ //! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against //! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then //! runs `vectorcraft-js`'s `test262` tests, which parse the ES5 parse subset, run the ES5 -//! `test/language` tests that need only the interpreter's built-ins, and print each one's counts. +//! `test/language` tests that need only the interpreter's built-ins, run the `test/built-ins` tests +//! for `String`, `Number`, `RegExp` and `Date`, and print each one's counts. //! //! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the //! archive's SHA-256 in the error, check the archive, and update [`SHA256`].