From f5be571d4742411f25724fbb8125ba8f0f06c308 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:25:27 +0700 Subject: [PATCH 01/93] .ai import: Bas Relief opens with its settings Squashed from #5227 onto main. --- crates/engine/tests/import_fuzz.rs | 54 +++++++ crates/eps/src/import/ai/style.rs | 115 +++++++++++++- crates/eps/src/import/ai/style/adapter.rs | 179 ++++++++++++++++++++++ crates/eps/src/import/ai/style/tests.rs | 178 +++++++++++++++++++++ crates/eps/src/import/ai/tests.rs | 45 ++++++ docs/file-format-parity.md | 34 ++-- docs/target-app-parity.md | 1 + 7 files changed, 584 insertions(+), 22 deletions(-) create mode 100644 crates/eps/src/import/ai/style/adapter.rs diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 59d079bcd..81d75ca70 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4720,6 +4720,13 @@ fn arb_ai_token() -> impl Strategy { "(Fill Style Filter) 0 0 /Filter ,", "(Adobe Outer Glow) 1 0 /Filter ,", "(Adobe Drop Shadow) 1 0 /Filter ,", + "(PSAdapter_plugin_BsRl) 1 0 /Filter ,", + "/Binary : /ASCII85Decode ,", + "/Binary : /ASCII85Decode ,\n%!!!!\"z~>\n; (data) ,", + "\n%zzzz~>\n", + "; (data) ,", + "8 /Int (dataSize) ,", + "8 /Real (dataSize) ,", "(Adobe Pathfinder) 1 0 /Filter ,", "0 /Int (Command) ,", "0.5 /Real (Mix) ,", @@ -5229,6 +5236,53 @@ proptest! { survive(what, || vectorcraft_engine::cmd::fileio::load("x.eps", &file).ok().map(|l| l.doc))?; } } + + /// A filter-gallery effect's settings block in an art style: fields of any key, marker, type and + /// value, objects nested in objects, any version and `dataSize`, and its ASCII85 lines damaged or cut. + #[test] + fn ai_filter_gallery_settings_never_panic( + fields in prop::collection::vec(arb_settings_field(), 0..8), + inner in prop::collection::vec(arb_settings_field(), 0..4), + nesting in 0usize..4, + version in prop_oneof![Just(1u32), any::()], + size in prop_oneof![Just(None), any::().prop_map(Some)], + cut in 0usize..600, + edits in prop::collection::vec((0usize..600, prop::sample::select(vec!['%', '~', '>', 'z', 'v', '!', 'u', '\n', ';', ' '])), 0..6), + ) { + use vectorcraft_testkit::ai; + let object = |body: Vec| [&b"Objc"[..], &0x8000_0000u32.to_be_bytes(), b"Clss", &(body.len() as u32 + 4).to_be_bytes(), &1u32.to_be_bytes(), &body].concat(); + let mut nested = inner.concat(); + for _ in 0..nesting { + nested = object(nested); + } + let data = [version.to_be_bytes().to_vec(), fields.concat(), nested].concat(); + let lines: String = ascii85(&data).as_bytes().chunks(60).map(|c| format!("%{}\n", String::from_utf8_lossy(c))).collect(); + let size = size.unwrap_or(data.len() as i64); + let block = mutate_text(&format!("/Binary : /ASCII85Decode ,\n{lines}; (data) ,\n{size} /Int (dataSize) ,\n"), cut, &edits); + let styles = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(R) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(PSAdapter_plugin_BsRl) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{block}; /Dict ;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ); + let hidden = ai::rect(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let art = format!("0 Ae\nu\n0 1 1 0 1 0 0 Xa\n{}f\nU\n%_{hidden}f\n1 (R) XW\n", ai::rect(0.0, 0.0, 10.0, 10.0)); + let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", &art))); + survive("filter-gallery settings", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("filter-gallery settings in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + } +} + +/// A field of a filter-gallery settings descriptor: a key, a marker, a type and a value, each one +/// that is read or anything else. +fn arb_settings_field() -> impl Strategy> { + let key = prop_oneof![prop::sample::select(vec![*b"GEfk", *b"Dtl ", *b"Smth", *b"LghD", *b"Xtra"]), any::<[u8; 4]>()]; + let marker = prop_oneof![Just(0u32), Just(0x2000), Just(0x8000_0000), any::()]; + let kind = prop_oneof![prop::sample::select(vec![*b"long", *b"doub", *b"bool", *b"LghD", *b"GEft"]), any::<[u8; 4]>()]; + let value = prop_oneof![ + prop::sample::select(vec![b"LDBt".to_vec(), b"BsRl".to_vec(), 8i32.to_be_bytes().to_vec(), vec![1], f64::NAN.to_be_bytes().to_vec()]), + prop::collection::vec(any::(), 0..12), + ]; + (key, marker, kind, value).prop_map(|(k, m, t, v)| [&k[..], &m.to_be_bytes(), &t, &v].concat()) } // ---------- font files a folder search reads ---------- diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 9699e3ac6..04ddf27c1 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -160,8 +160,8 @@ pub(super) struct ArtStyle { pub plain: bool, /// It is one fill and one stroke at most, in solid colours or the object's own gradient, with /// no transparency or effects of their own, and only Drop Shadow, Inner Glow, Outer Glow, - /// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke) - /// and some Distort & Transform effects ([`any_object_takes`]) on the object + /// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke), + /// Bas Relief read from its settings block and some Distort & Transform effects ([`any_object_takes`]) on the object /// (and Pathfinder effects when it has no fills or strokes): what any object is read with. Type takes the other plain styles too (its drawn look /// can't be retyped); other objects keep their drawn look, which is the app's own rendering. pub simple: bool, @@ -277,6 +277,8 @@ struct Filter { xy: Option<(usize, f32)>, /// `… isolate knockout … Xy`. xy_group: Option<(bool, Knockout)>, + /// Its settings block's bytes (`/Binary : /ASCII85Decode ,`), `Err` when it isn't read. + data: Option, ()>>, } impl Default for Filter { @@ -304,6 +306,7 @@ impl Default for Filter { plugin_object: None, xy: None, xy_group: None, + data: None, } } } @@ -383,12 +386,18 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { let mut style_entry: Option = None; // A name of a basic filter's that may be an entry's type, with the string that follows it. let mut other_entry: Option> = None; + // The last name was `/Binary`. + let mut binary = false; let mut lex = Lexer::new(data); while let Some(t) = lex.next_token() { if let (Some(a), Tok::Num(v)) = (array.as_mut(), &t.tok) { a.push(V::Num(*v)); continue; } + let after_binary = binary; + if let Tok::Name(n) = &t.tok { + binary = *n == b"Binary"; + } match t.tok { Tok::Num(v) => operands.push(V::Num(v)), Tok::Str(s) | Tok::Hex(s) => { @@ -397,6 +406,11 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { } operands.push(V::Str(s)); } + // A basic filter's settings block: its bytes are on the comment lines the lexer skips. + Tok::Name(b"ASCII85Decode") if after_binary && in_basic => { + keep_block(&mut lex, data.len(), filters.last_mut()); + operands.clear(); + } Tok::Name(n) => match n { b"KnownStyle" => { finish(name.take(), &mut filters); @@ -520,6 +534,62 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { styles } +/// Reads the settings block that `lex` (over `data_len` bytes) has just reached into `filter`'s +/// `data`; a second block on one filter, or a block that isn't read, leaves it `Err`. +fn keep_block(lex: &mut Lexer, data_len: usize, filter: Option<&mut Filter>) { + let block = binary_block(lex, data_len).ok_or(()); + if let Some(f) = filter { + f.data = Some(if f.data.is_some() { Err(()) } else { block }); + } +} + +/// The bytes of the settings block whose `/Binary : /ASCII85Decode` `lex` (over `data_len` bytes) +/// has just read: the rest of that line is its `,`, each line after it holds ASCII85 after a `%` +/// until `~>`, and `; (data) ,` follows. `lex` is moved past the `~>` when the lines up to it are +/// the block's and fit in [`adapter::MAX_ENCODED`] bytes, and past `; (data)` when they follow. +/// `None` when the block is malformed or decodes to more than [`adapter::MAX_DECODED`] bytes. +fn binary_block(lex: &mut Lexer, data_len: usize) -> Option> { + let from = lex.offset(); + let text = lex.slice(from, from.saturating_add(adapter::MAX_ENCODED).min(data_len)); + let mut lines = text.split(|b| matches!(b, b'\r' | b'\n')); + let first = lines.next()?; + if first.trim_ascii() != b"," { + return None; + } + // Where the line being read starts in `text`. + let mut at = first.len() + 1; + let mut encoded = vec![]; + for line in lines { + let start = at; + at += line.len() + 1; + if line.is_empty() { + continue; + } + // A line that isn't the block's ends it unread, the lexer left before it. + let digits = line.strip_prefix(b"%")?; + if let Some(i) = super::super::lex::find(digits, b"~>") { + encoded.extend_from_slice(digits.get(..i + 2)?); + lex.seek(from + start + 1 + i + 2); + return block_data(lex, &encoded); + } + encoded.extend_from_slice(digits); + } + None +} + +/// The bytes of a settings block's ASCII85 `encoded`, when `; (data)` follows it in `lex`. When it +/// doesn't, `lex` is left before those tokens, so the next style's `/KnownStyle` is still read. +fn block_data(lex: &mut Lexer, encoded: &[u8]) -> Option> { + let at = lex.offset(); + let data = crate::ps::ascii85_decode_within(std::str::from_utf8(encoded).ok()?, adapter::MAX_DECODED)?; + let (close, key) = (lex.next_token().map(|t| t.tok), lex.next_token().map(|t| t.tok)); + if close == Some(Tok::Word(b";")) && key == Some(Tok::Str(b"data".to_vec())) { + return Some(data); + } + lex.seek(at); + None +} + /// A paint operator of a fill's or stroke's style. fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { let last = vals.iter().rev().find_map(V::num); @@ -1068,8 +1138,14 @@ fn effect(f: &Filter, under: Under) -> Option { TWIST => ("distort.twist", json!({"angle": real(f, "angle", 0.0, -3600.0..=3600.0)?})), ZIG_ZAG => ("distort.zigZag", zig_zag(f)?), GAUSSIAN => ("blur.gaussian", json!({"radius": param("blur").unwrap_or(5.0).clamp(0.0, 1000.0)})), - // Its settings are in a Photoshop plug-in's binary block: the effect's defaults stand in. - BAS_RELIEF => ("sketch.basRelief", json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})), + BAS_RELIEF => ( + "sketch.basRelief", + match &f.data { + // Without a settings block, the effect's defaults stand in. + None => json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}), + Some(data) => adapter::bas_relief(&descriptor(f, data)?)?, + }, + ), SHAPE_EFFECTS => return shape_effect(f), PATHFINDER => return pathfinder_effect(f), _ => return None, @@ -1077,6 +1153,18 @@ fn effect(f: &Filter, under: Under) -> Option { Some(Effect { id: id.into(), params, visible: f.visible }) } +/// The fields of filter `f`'s settings block `data`, when it was read and its `dataSize`, written +/// as `/Int`, is its length. +fn descriptor(f: &Filter, data: &Result, ()>) -> Option> { + let data = data.as_ref().ok()?; + (f.kinds.get("dataSize") == Some(&Kind::Int)).then_some(())?; + let size = f.params.get("dataSize").copied()?; + if size != data.len() as f64 { + return None; + } + adapter::decode(data) +} + /// The Effect › Pathfinder effect of [`PATHFINDER_COMMANDS`] that an integer `Command` names, when /// every key of [`PATHFINDER_OPTIONS`] is there with its kind and value (Soft Mix's `Mix` from 0.01 /// to 1, as its Mixing Rate in percent) and the only other key is the `DisplayString` text, each @@ -1389,6 +1477,21 @@ fn any_object_takes(e: &Effect) -> bool { } } +/// Does every Bas Relief filter in `filters` have a settings block, so that it reads from its +/// settings and not from its defaults standing in? +fn bas_relief_read(filters: &[Filter]) -> bool { + filters.iter().all(|f| f.name != BAS_RELIEF || f.data.is_some()) +} + +/// Is `e` read on the whole object: Bas Relief when `bas_relief` says its settings were read, and +/// otherwise what [`any_object_takes`] says? +fn object_takes(e: &Effect, bas_relief: bool) -> bool { + if e.id == "sketch.basRelief" { + return bas_relief; + } + any_object_takes(e) +} + /// Do Free Distort params `p`'s corners (top left, top right, bottom right, bottom left) make a /// parallelogram, so that the bilinear map is affine? fn affine(p: &serde_json::Value) -> bool { @@ -1443,13 +1546,14 @@ fn style(filters: &[Filter]) -> ArtStyle { b.plain = false; } st.plain = b.plain; + let bas_relief = bas_relief_read(filters); let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count(); st.simple = st.plain && count(true) <= 1 && count(false) <= 1 && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. })) && st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty()) - && st.effects.iter().all(any_object_takes) + && st.effects.iter().all(|e| object_takes(e, bas_relief)) // An effect that moves the outline is drawn on its own: with others, their order and what // scales with it aren't kept. && (!st.moves_outline() || st.effects.len() == 1) @@ -1458,5 +1562,6 @@ fn style(filters: &[Filter]) -> ArtStyle { st } +mod adapter; #[cfg(test)] mod tests; diff --git a/crates/eps/src/import/ai/style/adapter.rs b/crates/eps/src/import/ai/style/adapter.rs new file mode 100644 index 000000000..c06122e56 --- /dev/null +++ b/crates/eps/src/import/ai/style/adapter.rs @@ -0,0 +1,179 @@ +//! The settings of a filter-gallery effect in an art style: a basic filter's `/Binary : +//! /ASCII85Decode ,` block, whose comment lines hold the bytes until `~>`, then `; (data) ,` and the +//! byte count `n /Int (dataSize) ,`. The bytes are a descriptor, all numbers big-endian: +//! +//! ```text +//! version (u32, 1) +//! field* key (4 chars) marker (u32) … +//! marker 0: type (4 chars: long, doub, bool) value (i32, f64 or one byte) +//! marker 0x2000: an enumeration: type (4 chars) value (4 chars) +//! marker 0x80000000: an object: class (4 chars) length (u32) and that many bytes, a descriptor +//! ``` +//! +//! A filter-gallery effect's first field is `GEfk`, an enumeration of type `GEft` whose value is the +//! effect's four-character code. The keys and the enumerations' values are the ones the Photoshop +//! Actions Event Guide lists for Bas Relief, and `GEfk` and `GEft` are the filter gallery's in +//! published scripting examples. Anything else (another marker or type, a field past the end, bytes after the last field, more +//! than [`MAX_FIELDS`] fields or objects nested more than [`MAX_DEPTH`] deep) isn't read. + +use serde_json::{Value as Json, json}; + +/// Most bytes a block's text is read from (its comment lines and `~>`). +pub(super) const MAX_ENCODED: usize = 6 * 1024; +/// Most bytes a block decodes to. +pub(super) const MAX_DECODED: usize = 4 * 1024; +/// Most fields read in one descriptor, nested ones included. +const MAX_FIELDS: usize = 32; +/// Most levels of objects in objects. +const MAX_DEPTH: usize = 2; + +/// A field's value. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Value { + Long(i32), + Doub(f64), + Bool(bool), + /// An enumeration's type and value. + Enum([u8; 4], [u8; 4]), + /// An object's class and fields. + Object([u8; 4], Vec<(Key, Value)>), +} + +/// A field's key. +pub(super) type Key = [u8; 4]; + +/// The fields of descriptor `data`; `None` when it isn't one read here. +pub(super) fn decode(data: &[u8]) -> Option> { + let mut count = 0; + fields(data, 0, &mut count) +} + +fn fields(data: &[u8], depth: usize, count: &mut usize) -> Option> { + let mut r = Reader { data, at: 0 }; + if r.u32()? != 1 { + return None; + } + let mut out = vec![]; + while r.at < data.len() { + *count += 1; + if *count > MAX_FIELDS { + return None; + } + let key = r.four()?; + let value = match r.u32()? { + 0 => match &r.four()? { + b"long" => Value::Long(i32::from_be_bytes(r.four()?)), + b"doub" => Value::Doub(f64::from_be_bytes(r.eight()?)).finite()?, + b"bool" => match r.take(1)? { + [0] => Value::Bool(false), + [1] => Value::Bool(true), + _ => return None, + }, + _ => return None, + }, + 0x2000 => Value::Enum(r.four()?, r.four()?), + 0x8000_0000 if depth < MAX_DEPTH => { + let class = r.four()?; + let n = usize::try_from(r.u32()?).ok()?; + Value::Object(class, fields(r.take(n)?, depth + 1, count)?) + } + _ => return None, + }; + out.push((key, value)); + } + Some(out) +} + +impl Value { + fn finite(self) -> Option { + match self { + Value::Doub(v) if !v.is_finite() => None, + v => Some(v), + } + } +} + +struct Reader<'a> { + data: &'a [u8], + at: usize, +} + +impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let end = self.at.checked_add(n)?; + let bytes = self.data.get(self.at..end)?; + self.at = end; + Some(bytes) + } + + fn four(&mut self) -> Option<[u8; 4]> { + self.take(4)?.try_into().ok() + } + + fn eight(&mut self) -> Option<[u8; 8]> { + self.take(8)?.try_into().ok() + } + + fn u32(&mut self) -> Option { + Some(u32::from_be_bytes(self.four()?)) + } +} + +/// The fields of a filter-gallery effect `code`, its `GEfk` field taken off; `None` when that field +/// names another effect. +fn gallery<'a>(fields: &'a [(Key, Value)], code: &[u8; 4]) -> Option<&'a [(Key, Value)]> { + match fields.split_first() { + Some((([b'G', b'E', b'f', b'k'], Value::Enum(kind, value)), rest)) => (kind == b"GEft" && value == code).then_some(rest), + _ => Some(fields), + } +} + +/// A whole number in `range`. +fn long(v: &Value, range: std::ops::RangeInclusive) -> Option { + match v { + Value::Long(n) if range.contains(n) => Some(f64::from(*n)), + _ => None, + } +} + +/// Sketch › Bas Relief's params (`sketch.basRelief`) from its fields: `Dtl ` (detail), `Smth` +/// (smoothness) and `LghD` (the light's direction), each once; `None` when one is missing, out of +/// range or not read here, or another field is there. +pub(super) fn bas_relief(fields: &[(Key, Value)]) -> Option { + let (mut detail, mut smoothness, mut light) = (None, None, None); + for (key, value) in gallery(fields, b"BsRl")? { + let slot = match key { + b"Dtl " => &mut detail, + b"Smth" => &mut smoothness, + b"LghD" => &mut light, + _ => return None, + }; + if slot.is_some() { + return None; + } + *slot = Some(match key { + b"LghD" => json!(light_direction(value)?), + _ => json!(long(value, 1..=15)?), + }); + } + Some(json!({"detail": detail?, "smoothness": smoothness?, "lightDirection": light?})) +} + +/// The light direction of a `LghD` enumeration's value (`typeLightDirection`). +fn light_direction(v: &Value) -> Option<&'static str> { + let Value::Enum(kind, value) = v else { return None }; + if kind != b"LghD" { + return None; + } + Some(match value { + b"LDBt" => "bottom", + b"LDBL" => "bottomLeft", + b"LDLf" => "left", + b"LDTL" => "topLeft", + b"LDTp" => "top", + b"LDTR" => "topRight", + b"LDRg" => "right", + b"LDBR" => "bottomRight", + _ => return None, + }) +} diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index da9a83ad7..0845ca0d9 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1051,6 +1051,184 @@ fn a_hidden_fill_comes_in_hidden() { assert_eq!(st.looks.iter().map(|l| l.visible).collect::>(), [false, true]); } +/// A descriptor (version 1) of `fields`. +fn descriptor(fields: &[Vec]) -> Vec { + [vec![0, 0, 0, 1], fields.concat()].concat() +} + +fn long_field(key: &[u8; 4], v: i32) -> Vec { + [&key[..], &[0; 4], b"long", &v.to_be_bytes()].concat() +} + +fn enum_field(key: &[u8; 4], kind: &[u8; 4], v: &[u8; 4]) -> Vec { + [&key[..], &0x2000u32.to_be_bytes(), kind, v].concat() +} + +/// Bas Relief's fields: the filter-gallery code, `detail`, `smoothness` and the light direction `light`. +fn relief_fields(detail: i32, smoothness: i32, light: &[u8; 4]) -> Vec> { + vec![enum_field(b"GEfk", b"GEft", b"BsRl"), long_field(b"Dtl ", detail), long_field(b"Smth", smoothness), enum_field(b"LghD", b"LghD", light)] +} + +/// A settings block of `data`: the ASCII85 on comment lines, then `; (data) ,`. +fn block(data: &[u8]) -> String { + let lines: String = crate::ps::ascii85(data).lines().map(|l| format!("%{l}\n")).collect(); + format!("/Binary : /ASCII85Decode ,\n{lines}; (data) ,\n") +} + +/// The style `name`: a black fill and Bas Relief on the object, whose dictionary holds `body`. +fn relief_text(name: &str, body: &str) -> String { + let stack = compound("Stack Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Conduit Filter", "")]); + let relief = basic("PSAdapter_plugin_BsRl", body); + format!("/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[stack, relief])) +} + +/// The style of [`relief_text`]. +fn relief_style(body: &str) -> ArtStyle { + art_styles(relief_text("Relief", body).as_bytes()).remove("Relief").unwrap_or_default() +} + +#[test] +fn a_settings_block_without_its_end_leaves_the_next_style_alone() { + // GUARDS: the block's `~>` was looked for past its own lines, in the next style's block, which was skipped. + let data = descriptor(&relief_fields(8, 1, b"LDBt")); + let cut = format!("{}{} /Int (dataSize) ,\n", block(&data).replace("~>", ""), data.len()); + let whole = format!("{}{} /Int (dataSize) ,\n", block(&data), data.len()); + let styles = art_styles(format!("{}{}", relief_text("Cut", &cut), relief_text("Whole", &whole)).as_bytes()); + assert!(styles["Cut"].effects.is_empty(), "{:?}", styles["Cut"]); + assert!(styles["Whole"].simple, "{:?}", styles["Whole"]); + assert_eq!(styles["Whole"].effects[0].params["detail"], json!(8.0)); +} + +#[test] +fn a_settings_block_without_its_data_key_leaves_the_next_style_alone() { + // GUARDS: the tokens after the block's `;` were taken as its `(data)` key, the closing of its + // dictionary or the next style's `/KnownStyle` among them, and the next style was lost. + let data = descriptor(&relief_fields(8, 1, b"LDBt")); + let whole = format!("{}{} /Int (dataSize) ,\n", block(&data), data.len()); + let keyless = block(&data).replace("(data) ,\n", ""); + let next = relief_text("Whole", &whole); + // A style cut short right after its block's `;`, the next style's `/KnownStyle` following. + let short = relief_text("Cut", &keyless); + let short = short.get(..short.find("~>\n;").unwrap_or_default() + 4).unwrap_or_default(); + for text in [ + format!("{short}\n{next}"), + format!("{}{next}", relief_text("Cut", &keyless)), + format!("{}{next}", relief_text("Cut", &format!("{keyless}{} /Int (dataSize) ,\n", data.len()))), + ] { + let styles = art_styles(text.as_bytes()); + assert!(styles.get("Whole").is_some_and(|s| s.simple), "{styles:?}"); + assert_eq!(styles["Whole"].effects[0].params["detail"], json!(8.0)); + assert!(!styles.get("Cut").is_some_and(|s| s.simple), "{styles:?}"); + } +} + +/// [`relief_style`] with the settings block of `data` and a `dataSize` of its length. +fn relief_with(data: &[u8]) -> ArtStyle { + relief_style(&format!("{}300 /Int (PrevDres) ,\n{} /Int (dataSize) ,\n1 /Bool (go) ,\n", block(data), data.len())) +} + +#[test] +fn bas_relief_reads_its_settings_block() { + // GUARDS: the effect's defaults stood in for the settings, and an object other than type with it + // came in as its drawn look. + for (code, light) in [ + (b"LDBt", "bottom"), + (b"LDBL", "bottomLeft"), + (b"LDLf", "left"), + (b"LDTL", "topLeft"), + (b"LDTp", "top"), + (b"LDTR", "topRight"), + (b"LDRg", "right"), + (b"LDBR", "bottomRight"), + ] { + let st = relief_with(&descriptor(&relief_fields(8, 1, code))); + assert!(st.plain && st.simple, "{st:?}"); + let [e] = st.effects.as_slice() else { panic!("{st:?}") }; + assert_eq!((e.id.as_str(), &e.params), ("sketch.basRelief", &json!({"detail": 8.0, "smoothness": 1.0, "lightDirection": light}))); + } + // Without the filter-gallery code, and with the fields in another order. + let [_, detail, smoothness, light] = <[Vec; 4]>::try_from(relief_fields(15, 15, b"LDTp")).unwrap(); + let st = relief_with(&descriptor(&[light, smoothness, detail])); + assert!(st.simple, "{st:?}"); + assert_eq!(st.effects[0].params, json!({"detail": 15.0, "smoothness": 15.0, "lightDirection": "top"})); +} + +#[test] +fn bas_relief_without_a_settings_block_keeps_the_effects_defaults_on_type_only() { + let st = relief_style("300 /Int (PrevDres) ,\n"); + assert!(st.plain && !st.simple, "{st:?}"); + assert_eq!(st.effects[0].params, json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})); +} + +#[test] +fn bas_relief_with_settings_not_read_here_keeps_the_drawn_look() { + let good = || relief_fields(8, 1, b"LDBt"); + let with = |i: usize, field: Vec| { + let mut f = good(); + f[i] = field; + descriptor(&f) + }; + let cases: Vec<(&str, Vec)> = vec![ + ("detail 0", with(1, long_field(b"Dtl ", 0))), + ("detail 16", with(1, long_field(b"Dtl ", 16))), + ("smoothness -1", with(2, long_field(b"Smth", -1))), + ("an unknown direction", with(3, enum_field(b"LghD", b"LghD", b"LDXx"))), + ("a direction of another type", with(3, enum_field(b"LghD", b"Dirc", b"LDBt"))), + ("a detail that isn't a whole number", with(1, [&b"Dtl "[..], &[0; 4], b"doub", &8.0f64.to_be_bytes()].concat())), + ("another effect's code", with(0, enum_field(b"GEfk", b"GEft", b"Crst"))), + ("an unknown key", [descriptor(&good()), long_field(b"Xtra", 1)].concat()), + ("a key twice", [descriptor(&good()), long_field(b"Dtl ", 8)].concat()), + ("a missing key", descriptor(&good()[..3])), + ("a byte after the last field", [descriptor(&good()), vec![0]].concat()), + ("a cut field", descriptor(&good()).split_last().unwrap().1.to_vec()), + ("version 2", [vec![0, 0, 0, 2], good().concat()].concat()), + ]; + for (what, data) in cases { + let st = relief_with(&data); + assert!(!st.plain && !st.simple && st.effects.is_empty(), "{what}: {st:?}"); + } + let data = descriptor(&good()); + let encoded = block(&data); + for (what, body) in [ + ("a wrong dataSize", format!("{encoded}{} /Int (dataSize) ,\n", data.len() + 1)), + ("no dataSize", encoded.clone()), + ("a dataSize of another kind", format!("{encoded}{} /Real (dataSize) ,\n", data.len())), + ("no ~>", format!("{}{} /Int (dataSize) ,\n", encoded.replace("~>", ""), data.len())), + ("a digit past u", format!("{}{} /Int (dataSize) ,\n", encoded.replacen("%", "%v", 2), data.len())), + ("a line without %", format!("{}{} /Int (dataSize) ,\n", encoded.replacen("\n%", "\n", 1), data.len())), + ("no (data) key", format!("{}{} /Int (dataSize) ,\n", encoded.replace("(data)", "(other)"), data.len())), + ("two blocks", format!("{encoded}{encoded}{} /Int (dataSize) ,\n", data.len())), + ] { + let st = relief_style(&body); + assert!(!st.plain && st.effects.is_empty(), "{what}: {st:?}"); + } +} + +#[test] +fn settings_blocks_are_bounded() { + // More than the decoded limit, though its text is short (`z` is four zero bytes). + let zeros = format!("/Binary : /ASCII85Decode ,\n%{}~>\n; (data) ,\n4100 /Int (dataSize) ,\n", "z".repeat(1025)); + assert!(relief_style(&zeros).effects.is_empty()); + // An end further than the encoded limit. + let far = format!("/Binary : /ASCII85Decode ,\n{}%~>\n; (data) ,\n0 /Int (dataSize) ,\n", "%!!!!!\n".repeat(1000)); + assert!(relief_style(&far).effects.is_empty()); + // Objects nest two deep at most, and a descriptor holds 32 fields at most. + let object = |inner: Vec| [&b"Objc"[..], &0x8000_0000u32.to_be_bytes(), b"Clss", &(inner.len() as u32).to_be_bytes(), &inner].concat(); + let two = descriptor(&[object(descriptor(&[object(descriptor(&[long_field(b"Valu", 1)]))]))]); + assert!(adapter::decode(&two).is_some()); + let three = descriptor(&[object(descriptor(&[object(descriptor(&[object(descriptor(&[]))]))]))]); + assert!(adapter::decode(&three).is_none()); + assert!(adapter::decode(&descriptor(&vec![long_field(b"Valu", 1); 32])).is_some()); + assert!(adapter::decode(&descriptor(&vec![long_field(b"Valu", 1); 33])).is_none()); + let long_object = [&b"Objc"[..], &0x8000_0000u32.to_be_bytes(), b"Clss", &u32::MAX.to_be_bytes()].concat(); + assert!(adapter::decode(&descriptor(&[long_object])).is_none()); + let nan = [&b"Valu"[..], &[0; 4], b"doub", &f64::NAN.to_be_bytes()].concat(); + assert!(adapter::decode(&descriptor(&[nan])).is_none()); + let flag = |b: u8| [&b"Valu"[..], &[0; 4], b"bool", &[b]].concat(); + assert!(adapter::decode(&descriptor(&[flag(1)])).is_some()); + assert!(adapter::decode(&descriptor(&[flag(2)])).is_none()); +} + /// A Convert to Shape filter whose dictionary holds `keys`. fn shape(keys: &str) -> String { basic("Adobe Shape Effects", &format!("(Shape) /UnicodeString (DisplayString) ,\n{keys}")) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index e227ec12b..831be2edd 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5900,6 +5900,51 @@ fn a_path_with_a_pathfinder_effect_keeps_its_drawn_look() { assert_eq!(effects, 0); } +#[test] +fn an_object_with_bas_relief_comes_in_with_its_settings() { + // GUARDS: Bas Relief's settings block wasn't read, so the object came in as its drawn look. + let settings: Vec = [ + &[0, 0, 0, 1][..], + b"Dtl ", + &[0; 4], + b"long", + &5i32.to_be_bytes(), + b"Smth", + &[0; 4], + b"long", + &9i32.to_be_bytes(), + b"LghD", + &0x2000u32.to_be_bytes(), + b"LghD", + b"LDTR", + ] + .concat(); + let lines: String = crate::ps::ascii85(&settings).lines().map(|l| format!("%{l}\n")).collect(); + let style = format!( + "%AI9_BeginArtStyles\n\ + /KnownStyle :\n(Carved) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\ + /BasicFilter :\n(PSAdapter_plugin_BsRl) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/Binary : /ASCII85Decode ,\n{lines}; (data) ,\n\ + {} /Int (dataSize) ,\n; /Dict ;\n /Part ,\n;\n\ + %AI9_EndArtStyles\n", + settings.len() + ); + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_0 0 0 1 k\n%_{hidden_rect}f\n1 (Carved) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let r = read_ok(&format!("{style}{}", layer(&art_))); + let a = art(&r.doc); + assert_eq!(a.len(), 1); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look"); + let [relief] = a[0].appearance.effects.as_slice() else { panic!("{:#?}", a[0].appearance) }; + assert_eq!( + (relief.id.as_str(), &relief.params), + ("sketch.basRelief", &serde_json::json!({"detail": 5.0, "smoothness": 9.0, "lightDirection": "topRight"})) + ); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + #[test] fn an_object_with_convert_to_shape_comes_in_as_itself_with_the_effect_live() { // GUARDS: `Adobe Shape Effects` wasn't read, so the object came in as its drawn look. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f716d693b..9ab9f7fb2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -215,23 +215,23 @@ 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; 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 comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). 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, 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 comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). 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. 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 66983a171..eee501ef9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1307,6 +1307,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [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); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. Placed (linked) files in a `.ai`'s editing data come in 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 (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); 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 made taller with a warning in the open result (#1392). From d61a7abc960ebc7302802bf91bde96128198beab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:25:34 +0700 Subject: [PATCH 02/93] Save: include a PDF in new native files by default Squashed from #5320 onto main. --- apps/vectorcraft-cli/src/main.rs | 7 +- apps/vectorcraft-cli/src/perf.rs | 106 ++++++++++++- crates/engine/src/cmd/fileio/load.rs | 11 +- crates/engine/src/cmd/fileio/native.rs | 16 +- crates/engine/src/cmd/fileio/pdf.rs | 28 +++- crates/engine/src/cmd/fileio/save.rs | 35 +++- crates/engine/src/cmd/fileio/tests.rs | 15 +- crates/engine/src/cmd/fileio/tests_svg.rs | 6 +- crates/engine/src/cmd/recovery.rs | 14 +- crates/engine/src/tests/nativefile.rs | 6 +- crates/engine/src/tests/recovery.rs | 51 ++++++ crates/engine/src/tests/saveoptions.rs | 143 ++++++++++++++++- crates/engine/tests/plugin_export.rs | 15 +- crates/format/src/lib.rs | 149 +++++++++++++++++- .../ui-egui/src/dialogs/save_options/tests.rs | 1 + crates/ui-egui/src/tests/nativeoptions.rs | 2 +- docs/mcp.md | 6 +- docs/plugins.md | 4 +- 18 files changed, 579 insertions(+), 36 deletions(-) diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 2d8df4592..3a8f18cd8 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -128,12 +128,17 @@ USAGE: Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame (warm), multithreaded and single-threaded. - vectorcraft-cli perf [--paths N] [--deep] + vectorcraft-cli perf [--paths N] [--deep] [--native-io [--artboards 1..4]] Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded, unless the report says the machine is busy. --deep also times work with no budget yet and prints memory use (the working set on Windows), with the peak so far on Windows and Linux. + --native-io instead measures the application's native save/open commands (one warmup, + five samples, no disk I/O), including preview, PDF, compression and API base64 costs. + It reports the default and explicit PDF opt-out, with and without preview/compression; + --artboards repeats the same artwork + on 1 to 4 overlapping artboards. This mode allows at most 50000 paths. "; /// The usage text plus the formats `document.open` reads and `document.export` writes. diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 3ff82512d..e533d00b7 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -64,6 +64,61 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { v.get(v.len() / 2).copied().unwrap_or(0.0) } +/// Exercise the application's native save/open commands, including previews, PDF output, +/// compression and API base64 conversion. Session setup and validation are outside timing. +/// Five measured runs follow one warmup; no filesystem or GPU window is involved. +fn native_io(doc: &Document) -> Result<(), String> { + for (label, compatible, preview) in [ + ("default options", None, false), + ("PDF disabled", Some(false), false), + ("preview+gzip", None, true), + ("preview+gzip, PDF disabled", Some(false), true), + ] { + let mut session = vectorcraft_engine::Session::new(); + session.add_document(doc.clone(), None); + let mut params = json!({"format":"vectorcraft","modified":null,"preview":preview,"compress":preview}); + if let Some(value) = compatible { + params["pdfCompatible"] = json!(value); + } + let (mut saves, mut opens) = (Vec::new(), Vec::new()); + let (mut size, mut has_pdf, mut compressed) = (0, false, false); + for run in 0..6 { + let start = Instant::now(); + let saved = session.execute("file.saveAs", ¶ms).map_err(|e| e.to_string())?; + let save_ms = start.elapsed().as_secs_f64() * 1000.0; + let data = saved["dataBase64"].as_str().ok_or("native save returned no dataBase64")?; + let bytes = vectorcraft_format::base64_decode(data).ok_or("native save returned invalid base64")?; + size = bytes.len(); + has_pdf = vectorcraft_format::pdf_content(&bytes).is_some(); + if compatible == Some(false) && has_pdf { + return Err("native save ignored the explicit PDF opt-out".into()); + } + compressed = vectorcraft_format::is_compressed(&bytes); + if compressed != preview || vectorcraft_format::preview(&bytes).is_some() != preview { + return Err("native save did not apply the requested preview/compression options".into()); + } + let args = json!({"name":"perf.vectorcraft","dataBase64":data}); + let mut opened = vectorcraft_engine::Session::new(); + let start = Instant::now(); + opened.execute("document.open", &args).map_err(|e| e.to_string())?; + let open_ms = start.elapsed().as_secs_f64() * 1000.0; + if opened.doc().map_err(|e| e.to_string())?.doc.artboards.len() != doc.artboards.len() { + return Err("native round trip changed the artboard count".into()); + } + if run > 0 { + saves.push(save_ms); + opens.push(open_ms); + } + } + saves.sort_by(f64::total_cmp); + opens.sort_by(f64::total_cmp); + let save = saves.get(2).copied().ok_or("no save measurements")?; + let open = opens.get(2).copied().ok_or("no open measurements")?; + outln!(" native {label}: save {save:.2} ms, open {open:.2} ms, {size} bytes, PDF {has_pdf}, compressed {compressed}"); + } + Ok(()) +} + /// 1-minute load average (macOS / Linux), if available. #[cfg(not(windows))] fn load_average() -> Option { @@ -393,19 +448,36 @@ fn deep(n: usize) -> Result<(), String> { } pub fn run(args: &[String]) -> Result<(), String> { - if crate::help_requested(args, &["--paths"]) { + if crate::help_requested(args, &["--paths", "--artboards"]) { return crate::help(); } let mut n = 50_000usize; let mut want_deep = false; + let mut want_native = false; + let mut artboards = None::; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--deep" => want_deep = true, + "--native-io" => want_native = true, + "--artboards" => { + artboards = + Some(it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=4).contains(n)).ok_or("--artboards needs a number from 1 to 4")?) + } "--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?, other => return Err(format!("unknown perf option `{other}`")), } } + if want_native && n > 50_000 { + return Err("--native-io allows at most 50000 paths".into()); + } + if want_native && want_deep { + return Err("--native-io and --deep are separate measurement modes".into()); + } + if artboards.is_some() && !want_native { + return Err("--artboards requires --native-io".into()); + } + let artboards = artboards.unwrap_or(1); let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); // The figure is the 1-minute load average, or on Windows the cores busy over one second. #[cfg(not(windows))] @@ -418,8 +490,20 @@ pub fn run(args: &[String]) -> Result<(), String> { outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); } let t = Instant::now(); - let doc = synthetic(n)?; + let mut doc = synthetic(n)?; outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); + if want_native { + let first = doc.artboards.first().cloned().ok_or("synthetic document has no artboard")?; + // Overlapping artboards intentionally repeat the same paths on every PDF page. + for index in 1..artboards { + let mut page = first.clone(); + page.id = first.id.checked_add(index as u32).ok_or("artboard id overflow")?; + page.name = format!("Perf {}", index + 1); + doc.artboards.push(page); + } + outln!(" application native I/O: {artboards} overlapping artboard(s), one warmup + five samples; medians; no disk I/O"); + return native_io(&doc); + } let mut rows: Vec<(&str, f64, f64)> = vec![]; let opts = RenderOptions::default(); @@ -513,6 +597,24 @@ pub fn run(args: &[String]) -> Result<(), String> { mod tests { use super::*; + #[test] + fn native_io_exercises_the_application_save_and_open_commands() { + native_io(&synthetic(3).unwrap()).unwrap(); + } + + #[test] + fn native_io_rejects_conflicting_modes_and_excessive_workloads() { + for (args, expected) in [ + (vec!["--native-io", "--deep"], "separate measurement modes"), + (vec!["--native-io", "--paths", "50001"], "at most 50000"), + (vec!["--native-io", "--artboards", "5"], "1 to 4"), + (vec!["--artboards", "1"], "requires --native-io"), + (vec!["--artboards", "2"], "requires --native-io"), + ] { + assert!(run(&args.into_iter().map(str::to_owned).collect::>()).unwrap_err().contains(expected)); + } + } + #[test] fn busy_share_leaves_out_idle_time() { // In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time, diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 03bfde85b..7ff22689c 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -29,6 +29,8 @@ pub struct Loaded { /// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art /// that it carries (its layers), not from its PDF part alone. pub editing_data: bool, + /// Whether the native source declared a PDF; other formats carry no native save preference. + pub native_pdf_compatible: Option, } impl Loaded { @@ -341,6 +343,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let mut converted = false; let mut preview_only = false; let mut editing_data = false; + let mut native_pdf_compatible = None; let (mut doc, mut warnings, restored) = match format.id { // EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read. _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { @@ -353,6 +356,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } "vectorcraft" | "template" => { let (d, info, warnings) = native_file(bytes)?; + native_pdf_compatible = Some(info.pdf_compatible); converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION; (d, warnings, false) } @@ -500,7 +504,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result doc.title = title; } warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes))); - Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data }) + Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data, native_pdf_compatible }) } /// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's @@ -593,7 +597,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st /// Make a loaded file the new active document; `links` is what became of its linked files, and /// `opts` are the options it was read with. fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { - let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs); + let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs); // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing // fallbacks must ask before overwriting the richer source they could not fully read. let losses: Vec = warnings @@ -630,6 +634,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, include_linked: bool, embed_profiles: Option, - pdf_compatible: bool, + pdf_compatible: Option, } /// What [`save_options`] leaves to [`encode`] (rendering and encoding take time). @@ -171,7 +171,9 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> so.profiles = embedded_profiles(doc); } so.check().map_err(|e| bad(cmd, e.to_string()))?; - Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible })) + let pdf = o.pdf_compatible.unwrap_or(true); + so.pdf_compatible = Some(pdf); + Ok((so, Extras { preview: o.preview, pdf })) } /// The `precision` option `v`: a whole number of decimal places, 0 to @@ -213,9 +215,11 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp if extras.preview { so.preview = preview_png(&drawn)?; } - if extras.pdf { - // Every artboard as PDF pages (no editing data: the file is the native document). - so.pdf = Some(super::pdf::encode(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }))?.0); + if extras.pdf && !drawn.artboards.is_empty() { + // Every artboard as PDF pages (no editing data: the file is the native document), dated by + // the document's File Info, so the same document and dates give the same bytes. + let at = drawn.metadata.modified.or(drawn.metadata.created); + so.pdf = Some(super::pdf::encode_at(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }), at)?.0); } drop(drawn); let native = match full { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 785764db9..52e7489dd 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -240,9 +240,13 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result Result { + options_at(cmd, doc, p, vectorcraft_doc::metadata::output_time()) +} + +/// [`options`] modified at `now` (Unix seconds) instead of the time of writing. +fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option) -> Result { let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() }; let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?; - let now = vectorcraft_doc::metadata::output_time(); Ok(PdfOptions { settings: settings(cmd, p)?, artboards, @@ -265,9 +269,14 @@ pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { + encode_at(cmd, doc, p, None) +} + +/// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing. +pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; + let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; warnings.extend(drawn_warnings); warnings.extend(overflow_warning(cmd, &full, p)?); Ok((bytes, warnings)) @@ -294,7 +303,20 @@ pub(super) fn encode_carrying( native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, ) -> Result<(Vec, Vec)> { - let mut opts = options(cmd, doc, p)?; + encode_carrying_at(cmd, doc, p, None, native, private) +} + +/// [`encode_carrying`] modified at `modified` (Unix seconds) when given, instead of the time of +/// writing. +fn encode_carrying_at( + cmd: &str, + doc: &Document, + p: &Value, + modified: Option, + native: impl FnOnce() -> Result>, + private: impl FnOnce() -> Result)>>, +) -> Result<(Vec, Vec)> { + let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); let filled = super::export::with_artboard_fills(doc, p, pages).map_err(|e| bad(cmd, e))?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 0eefa45c0..893b783bf 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -270,7 +270,18 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { } } // None given: the ones the document was last saved with in this format. - let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; + let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; + // Remember the effective choice, including the encoder's default, so another document's + // later save cannot change what this one writes. Invalid non-null values still reach validation. + if matches!(format.id, "vectorcraft" | "template") { + let pdf = match options.get("pdfCompatible") { + None | Some(Value::Null) => Some(native_pdf_preference(s, Some(st)).unwrap_or(true)), + _ => None, + }; + if let Some(pdf) = pdf { + options.insert("pdfCompatible".into(), json!(pdf)); + } + } // Save writes the document's own file; the other modes only where they are told to. let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten()); @@ -336,6 +347,15 @@ fn same_value(a: &Value, b: &Value) -> bool { } } +/// Native documents and templates share only their PDF choice. Other source formats use the +/// application's last native choice, keeping an AI file's separate PDF option out of it. +fn native_pdf_preference(s: &Session, st: Option<&DocState>) -> Option { + st.filter(|d| matches!(d.format, "vectorcraft" | "template")) + .and_then(|d| d.save_options.get("pdfCompatible")) + .and_then(Value::as_bool) + .or_else(|| s.prefs.native_save_options.get("pdfCompatible").and_then(Value::as_bool)) +} + /// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`) /// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is /// left out, and one that moved is named by its new number. When none of the artboards the save @@ -1028,12 +1048,15 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; crate::cmd::recovery::forget(s, uid); s.replace_document(index, doc); + if let Some(pdf) = native_pdf_compatible { + s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf)); + } Ok(json!({ "path": path })) } @@ -1053,11 +1076,17 @@ fn format_options(s: &mut Session, p: &Value) -> Result { .or_else(|| (f.id == "vectorcraft" && !s.prefs.native_save_options.is_empty()).then(|| s.prefs.native_save_options.clone())); // A native save compresses as Use Compression says, unless told otherwise. let prefs = with_compression_pref(&s.prefs, &json!({})); + let native_pdf = matches!(f.id, "vectorcraft" | "template").then(|| native_pdf_preference(s, st)).flatten(); let mut v = f.to_json(); if let Some(options) = v["options"].as_object_mut() { for (name, o) in options.iter_mut() { let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]); o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone(); + if name == "pdfCompatible" + && let Some(pdf) = native_pdf + { + o["value"] = json!(pdf); + } } } v["saveFormats"] = @@ -1072,7 +1101,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index fca92d73f..84513cd2c 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -680,7 +680,9 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] { let path = d.join(name).to_string_lossy().to_string(); - let p = json!({"path": path, "compress": compress}); + // A PDF-compatible file's PDF is dated to the second it is written, so the byte comparison + // uses files without one. + let p = json!({"path": path, "compress": compress, "pdfCompatible": false}); let r = s.execute("file.saveCopy", &p).unwrap(); let written = std::fs::read(&path).unwrap(); assert_eq!(r["bytes"], written.len(), "{r}"); @@ -690,6 +692,17 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() { assert_eq!(written, job.encode().unwrap().files[0].1, "{name}"); assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate")); } + // A save with the default options streams a file that carries its PDF. + let path = d.join("d.vectorcraft").to_string_lossy().to_string(); + let p = json!({"path": path}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let written = std::fs::read(&path).unwrap(); + assert_eq!(r["bytes"], written.len(), "{r}"); + assert!(vectorcraft_format::pdf_content(&written).is_some()); + let plan = save_plan(&s, SaveMode::Copy, &p).unwrap(); + let job = save_job(&mut s, plan).unwrap(); + assert!(job.streams()); + assert!(vectorcraft_format::pdf_content(&job.encode().unwrap().files[0].1).is_some()); // A writer that fails: the save fails with its error. let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap(); let job = save_job(&mut s, plan).unwrap(); diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 73ee604a9..eb3989597 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -224,7 +224,11 @@ fn save_writes_svg_and_remembers_its_options() { let native = dir.join("doc.vectorcraft").to_string_lossy().to_string(); s.execute("document.save", &json!({"path": native})).unwrap(); assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap())); - assert!(s.doc().unwrap().save_options.is_empty(), "a native save remembers no options"); + assert_eq!( + Value::Object(s.doc().unwrap().save_options.clone()), + json!({"pdfCompatible":true}), + "a native save retains its PDF choice without SVG options" + ); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 1ad456b94..8b5731b33 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -565,6 +565,10 @@ struct Meta { path: Option, /// The format Save writes for it ([`SAVE_FORMATS`]). format: String, + /// A document's own native PDF choice before the recovery copy omits its PDF. None keeps + /// inheritance from the current application preference. Older copies did not record a choice. + #[serde(skip_serializing_if = "Option::is_none")] + native_pdf_compatible: Option, /// When the copy was written (Unix seconds; none without a clock). saved: Option, /// The file it was read from when that import left things out of the document, and what @@ -821,7 +825,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec) -> Result<(Vec, file: &str) -> R st.imported_from = meta.imported_from.clone(); st.import_losses = meta.import_losses.clone(); st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft"); + if let Some(pdf) = meta.native_pdf_compatible { + st.save_options.insert("pdfCompatible".into(), json!(pdf)); + } st.mark_unsaved(); // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); diff --git a/crates/engine/src/tests/nativefile.rs b/crates/engine/src/tests/nativefile.rs index e3a5588b7..4156dc759 100644 --- a/crates/engine/src/tests/nativefile.rs +++ b/crates/engine/src/tests/nativefile.rs @@ -222,7 +222,8 @@ fn a_version_1_documents_twist_turns_as_it_was_saved() { /// A save records the date the document was made in, so the same commands twice gave different /// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and -/// `SOURCE_DATE_EPOCH` for an export that takes no date param. +/// `SOURCE_DATE_EPOCH` for an export that takes no date param. The PDF a native file carries by +/// default is dated by the document's dates, not by the time of writing. #[test] fn a_pinned_date_makes_saves_byte_identical() { let once = |t: i64| { @@ -232,6 +233,9 @@ fn a_pinned_date_makes_saves_byte_identical() { saved(&mut s, json!({})) }; let at = 1_791_208_980; + let pdf = vectorcraft_format::pdf_content(&once(at)).expect("a native save carries a PDF by default"); + let info = vectorcraft_pdf::file_info(&pdf, None).unwrap(); + assert_eq!((info.created, info.modified), (Some(at), Some(at)), "the PDF has the document's dates"); assert_eq!(once(at), once(at), "the same document and date give the same bytes"); // The date really is in the bytes, so the comparison above is not vacuous. assert_ne!(once(at), once(at + 1), "another date shows in the bytes"); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 56c8d899d..9fe2d846c 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -59,6 +59,57 @@ fn restored(r: serde_json::Value) -> usize { r["restored"].as_array().unwrap().len() } +#[test] +fn native_recovery_copies_leave_out_the_pdf() { + let (mut s, store) = session(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + let file = format!("{}.vectorcraft", r["saved"][0]["file"].as_str().unwrap()); + let bytes = store.read(&file).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), s.doc().unwrap().doc.node_count()); +} + +#[test] +fn restored_recovery_copies_keep_the_original_pdf_preference() { + let dir = crate::tests_links::Folder::new("recovered-pdf-preference"); + for pdf in [false, true] { + let (mut s, store) = session(); + s.execute("file.saveAs", &json!({"path": dir.file(&format!("original-{pdf}.vectorcraft")), "pdfCompatible": pdf})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap(); + let copied = s.execute("file.recovery.save", &json!({})).unwrap(); + let file = format!("{}.vectorcraft", copied["saved"][0]["file"].as_str().unwrap()); + assert!(vectorcraft_format::pdf_content(&store.read(&file).unwrap()).is_none()); + drop(s); + let mut s = relaunch(&store); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(pdf))); + let saved = s.execute("document.save", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&bytes).is_some(), pdf); + } +} + +#[test] +fn recovered_unsaved_documents_keep_inheriting_the_current_pdf_preference() { + let dir = crate::tests_links::Folder::new("recovered-inherited-pdf-choice"); + let (mut s, store) = session(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none()); + s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path":dir.file("other.vectorcraft"),"pdfCompatible":false})).unwrap(); + assert!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().is_empty()); + let latest_preferences = s.prefs.clone(); + drop(s); + let mut s = relaunch(&store); + // The host loads its latest preferences before offering recovery. + s.prefs = latest_preferences; + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none()); + let saved = s.execute("document.save", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); +} + #[test] fn recovery_save_writes_modified_documents_once() { let (mut s, store) = session(); diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index 9324b1f4b..a5b19563a 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -157,7 +157,146 @@ fn pdf_compatible_files_carry_a_pdf() { assert!(pdf.starts_with(b"%PDF")); let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); assert_eq!(info["pages"], 3); - assert!(vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({})).unwrap()["dataBase64"])).is_none()); + assert!( + vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({"pdfCompatible": false})).unwrap()["dataBase64"])).is_none() + ); +} + +/// A native save is portable to the other apps by default. The option is still an explicit +/// opt-out, and a remembered opt-out survives later saves of the document. +#[test] +fn native_saves_carry_all_artboards_as_pdf_by_default() { + let mut s = three_boards(); + for compress in [false, true] { + let result = s.execute("file.saveCopy", &json!({"compress": compress})).unwrap(); + let file = bytes_of(&result["dataBase64"]); + let pdf = vectorcraft_format::pdf_content(&file).expect("the default native save carries a PDF"); + let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); + assert_eq!(info["pages"], 3); + assert_eq!(vectorcraft_format::load(&file).unwrap().artboards.len(), 3); + } + let dir = Folder::new("pdf-opt-out"); + let path = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + let options = s.execute("file.formatOptions", &json!({"format":"vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false); + s.execute("document.save", &json!({"compress": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], false); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + s.execute("document.save", &json!({"pdfCompatible": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some()); +} + +#[test] +fn native_saves_without_artboards_keep_the_editable_document() { + let mut s = three_boards(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear(); + let result = s.execute("file.saveCopy", &json!({})).unwrap(); + let file = bytes_of(&result["dataBase64"]); + let restored = vectorcraft_format::load(&file).unwrap(); + assert!(restored.artboards.is_empty()); + assert_eq!(restored.node_count(), s.doc().unwrap().doc.node_count()); + assert!(vectorcraft_format::pdf_content(&file).is_none()); + s.execute("document.open", &json!({"name": "no-artboards.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap(); + assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], true); + s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap(); + let result = s.execute("file.saveCopy", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(&result["dataBase64"])).is_some()); +} + +#[test] +fn native_template_saves_keep_the_document_pdf_preference() { + let mut s = three_boards(); + let dir = Folder::new("template-pdf-preference"); + s.execute("file.saveAs", &json!({"path": dir.file("compact.vectorcraft"), "pdfCompatible": false, "version": 3})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "template"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false, "the save dialog must offer the same retained choice"); + let r = s.execute("file.saveAsTemplate", &json!({"format": "template"})).unwrap(); + let bytes = bytes_of(&r["dataBase64"]); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); + assert_eq!( + vectorcraft_format::load_file(&bytes).unwrap().info.version, + vectorcraft_format::VERSION, + "unrelated format options are not carried over" + ); + let r = s.execute("file.saveAsTemplate", &json!({"format": "template", "pdfCompatible": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(&r["dataBase64"])).is_some()); +} + +#[test] +fn a_default_native_save_keeps_its_pdf_after_another_document_opts_out() { + let dir = Folder::new("default-native-pdf-choice"); + for (index, options) in [json!({}), json!({"options":{"pdfCompatible":null}})].into_iter().enumerate() { + let mut s = three_boards(); + let path = dir.file(&format!("first-{index}.vectorcraft")); + let mut params = options.as_object().unwrap().clone(); + params.insert("path".into(), json!(path)); + s.execute("file.saveAs", &Value::Object(params)).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some()); + s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path":dir.file(&format!("other-{index}.vectorcraft")),"pdfCompatible":false})).unwrap(); + s.execute("document.activate", &json!({"index":0})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), "the first document keeps the PDF it was saved with"); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(true))); + } +} + +#[test] +fn a_null_nested_pdf_option_keeps_the_saved_opt_out() { + let dir = Folder::new("null-nested-pdf-option"); + let mut s = three_boards(); + let path = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap(); + s.execute("document.save", &json!({"options": {"pdfCompatible": null}})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none(), "null leaves the saved choice"); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(false))); +} + +#[test] +fn reopening_native_files_keeps_each_files_pdf_compatibility() { + let dir = Folder::new("pdf-reopened-options"); + let mut s = three_boards(); + let pdf = dir.file("portable.vectorcraft"); + let compact = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": pdf, "pdfCompatible": true})).unwrap(); + s.execute("file.saveAs", &json!({"path": compact, "pdfCompatible": false})).unwrap(); + s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + let copy = s.execute("file.saveCopy", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(©["dataBase64"])).is_none(), "new files use the last native preference"); + for (path, expected) in [(&pdf, true), (&compact, false)] { + s.execute("document.open", &json!({"path": path})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], expected); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(path).unwrap()).is_some(), expected); + } +} + +#[test] +fn reverting_native_files_updates_their_pdf_compatibility() { + let dir = Folder::new("pdf-revert-options"); + for saved in [false, true] { + let mut s = three_boards(); + let path = dir.file(if saved { "with-pdf.vectorcraft" } else { "compact.vectorcraft" }); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": saved})).unwrap(); + let replacement = s.execute("file.saveCopy", &json!({"pdfCompatible": !saved})).unwrap(); + std::fs::write(&path, bytes_of(&replacement["dataBase64"])).unwrap(); + s.execute("shape.rectangle", &json!({"x": 70, "y": 20, "width": 8, "height": 8})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], !saved); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), !saved); + } } #[test] @@ -429,7 +568,7 @@ fn format_options_list_the_save_options() { assert_eq!(v["options"][k]["value"], value, "{k}"); } let v = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); - assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(false), true)); + assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(true), true)); // Saved with them, a document remembers them for Save. let dir = Folder::new("remember"); let path = dir.file("r.vectorcraft"); diff --git a/crates/engine/tests/plugin_export.rs b/crates/engine/tests/plugin_export.rs index 1a49f800a..df06b5ee2 100644 --- a/crates/engine/tests/plugin_export.rs +++ b/crates/engine/tests/plugin_export.rs @@ -69,9 +69,8 @@ fn an_exporter_adds_a_write_only_format_while_it_is_installed() { assert!(e.to_string().contains("unknown format"), "{e}"); } -/// The exporter is given the document as a native file's JSON: what `document.save` writes before -/// the data of large images, which stays behind (compact, where a save of a small document is -/// indented). +/// The exporter receives the native document JSON without optional save renditions or preference +/// metadata. Large image bytes stay in the saved file's tail; plug-in input stays compact. #[test] fn the_exporter_is_given_the_native_file_s_json() { let mut s = session(); @@ -94,8 +93,14 @@ fn the_exporter_is_given_the_native_file_s_json() { let saved = exported(&s.execute("document.save", &json!({})).unwrap()); assert!(saved.len() > given.len() + png.len(), "the image data follows the JSON in the saved file"); let doc: Value = serde_json::from_slice(&given).unwrap(); - let saved_json: Value = serde_json::from_slice(vectorcraft_format::json_part(&saved)).unwrap(); - assert_eq!(doc, saved_json, "the saved file's JSON"); + let mut saved_json: Value = serde_json::from_slice(vectorcraft_format::json_part(&saved)).unwrap(); + assert!(saved_json.get("pdf").is_some(), "a default application save carries a PDF"); + assert_eq!(saved_json["pdfCompatible"], true); + assert!(doc.get("pdf").is_none() && doc.get("pdfCompatible").is_none(), "the plug-in input omits optional save members"); + let saved_members = saved_json.as_object_mut().unwrap(); + saved_members.remove("pdf"); + saved_members.remove("pdfCompatible"); + assert_eq!(doc, saved_json, "the native document JSON without optional save members"); assert!(!given.contains(&b'\n'), "without indentation"); assert_eq!((&doc["format"], &doc["version"]), (&json!("vectorcraft"), &json!(vectorcraft_format::VERSION))); let images = doc["images"].as_object().unwrap(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f82cd4eb1..b3aa7eab7 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -45,11 +45,13 @@ mod stream; mod unpack; use std::collections::{BTreeMap, HashSet}; +use std::fmt; use std::io::Write; use std::sync::Arc; use craft_vectorcraft_format::{Contents, Head, Image, ReadError, Tail}; -use serde::Serialize; +use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserializer, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] @@ -146,6 +148,9 @@ pub struct SaveOptions { pub profiles: BTreeMap>, /// Create PDF-Compatible File: a PDF of the document to carry. pub pdf: Option>, + /// The application's PDF save preference, retained even when there are no artboards to draw. + /// `None` omits this metadata, preserving the lower-level writer's existing output. + pub pdf_compatible: Option, /// Decimal places to round path anchors and handles to ([`Document::round_geometry`]), at /// most [`MAX_PRECISION`] (a larger value rounds to that many); `None` writes them exactly. pub precision: Option, @@ -164,6 +169,7 @@ impl Default for SaveOptions { include_linked: false, profiles: BTreeMap::new(), pdf: None, + pdf_compatible: None, precision: None, } } @@ -327,6 +333,9 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For env.field(&mut out, "format", &if o.version < 3 { LEGACY_EXTENSION } else { EXTENSION })?; env.field(&mut out, "version", &o.version)?; env.field(&mut out, "generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?; + if let Some(compatible) = o.pdf_compatible { + env.field(&mut out, "pdfCompatible", &compatible)?; + } if let Some(png) = &o.preview { env.field(&mut out, "preview", &Image::inline("image/png", png))?; } @@ -470,6 +479,9 @@ pub struct FileInfo { pub version: u32, /// Written under the project's former name (`"format": "drawcraft"`). pub legacy: bool, + /// The saved `pdfCompatible` preference, or the presence of a non-null `pdf` member in + /// files without a boolean preference. The PDF payload is not read or validated. + pub pdf_compatible: bool, } impl FileInfo { @@ -511,7 +523,8 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result ReadError::Envelope(e) => e.into(), e => FormatError::NotVectorcraft(e.to_string()), })?; - let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }; + let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; + let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION, pdf_compatible }; // A version 1 document saved before VectorCraft 0.21.0, or for an app before it (an older file // version): its Twist angles turned the other way. The app that wrote it is read from the JSON // before the data after it is taken; the walk's depth is bounded by the JSON reader's nesting @@ -602,6 +615,92 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result Ok(NativeFile { doc, depth_notes, info, profiles }) } +/// The saved PDF preference, falling back to the presence of its payload without retaining or +/// decoding it. The array form has neither member. Repeated members use their last value. +fn pdf_member(json: &[u8]) -> Result { + struct PdfMember; + impl<'de> Visitor<'de> for PdfMember { + type Value = bool; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a native file envelope") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut present = false; + let mut preference = None; + while let Some(key) = map.next_key::()? { + match key.as_str() { + "pdfCompatible" => preference = map.next_value_seed(Preference)?, + "pdf" => present = map.next_value::>()?.is_some(), + _ => { + map.next_value::()?; + } + } + } + Ok(preference.unwrap_or(present)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + while seq.next_element::()?.is_some() {} + Ok(false) + } + } + serde_json::Deserializer::from_slice(json).deserialize_any(PdfMember) +} + +/// A `pdfCompatible` value: `Some` for a boolean, `None` for null or any other value, which is +/// skipped without being kept, as an unknown member is. +struct Preference; + +impl<'de> DeserializeSeed<'de> for Preference { + type Value = Option; + + fn deserialize>(self, d: D) -> Result, D::Error> { + d.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for Preference { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("any value") + } + + fn visit_bool(self, v: bool) -> Result, E> { + Ok(Some(v)) + } + + fn visit_i64(self, _: i64) -> Result, E> { + Ok(None) + } + + fn visit_u64(self, _: u64) -> Result, E> { + Ok(None) + } + + fn visit_f64(self, _: f64) -> Result, E> { + Ok(None) + } + + fn visit_str(self, _: &str) -> Result, E> { + Ok(None) + } + + fn visit_unit(self) -> Result, E> { + Ok(None) + } + + fn visit_seq>(self, seq: A) -> Result, A::Error> { + IgnoredAny.visit_seq(seq).map(|_| None) + } + + fn visit_map>(self, map: A) -> Result, A::Error> { + IgnoredAny.visit_map(map).map(|_| None) + } +} + /// The file's first `generator` member, the app that wrote it (`None`: it has none, it is not a /// string, or the file is not an object of members). The other members, and any further /// `generator`, are skipped without being kept, as reading the file skips them. @@ -671,17 +770,59 @@ mod tests { assert!(load(br#"{"format":"other","version":1,"document":{}}"#).is_err()); } + #[test] + fn file_info_keeps_pdf_presence_without_reading_its_payload() { + let d = Document::new(10.0, 10.0); + for version in [3, 4] { + for compress in [false, true] { + for present in [false, true] { + let options = SaveOptions { version, compress, pdf: present.then(|| vec![7; 128 * 1024]), ..SaveOptions::default() }; + let bytes = save_with(&d, &options).unwrap(); + assert_eq!(load_info(&bytes).unwrap().1.pdf_compatible, present); + } + } + } + let mut value: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + let array = serde_json::json!([EXTENSION, VERSION, value["document"], value["images"], {}]); + assert!(!load_info(&serde_json::to_vec(&array).unwrap()).unwrap().1.pdf_compatible); + value["pdf"] = serde_json::json!({"mime":"application/pdf", "data":"not base64"}); + assert!(load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible); + value["pdf"] = serde_json::Value::Null; + value["unrecognized"] = serde_json::json!({"pdf":{"data":"nested"}}); + assert!(!load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible); + // A preference that is not a boolean is skipped like an unknown member: the file opens, + // and the presence of the PDF decides. + for odd in [serde_json::json!(1), serde_json::json!("yes"), serde_json::json!([true]), serde_json::json!({"on": true})] { + for (pdf, present) in [(serde_json::Value::Null, false), (serde_json::json!({"mime":"application/pdf"}), true)] { + value["pdf"] = pdf; + value["pdfCompatible"] = odd.clone(); + assert_eq!(load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible, present, "{odd}"); + } + } + } + + #[test] + fn file_info_keeps_the_pdf_preference_without_artboards() { + let mut d = Document::new(10.0, 10.0); + d.artboards.clear(); + for compatible in [false, true] { + let bytes = save_with(&d, &SaveOptions { pdf_compatible: Some(compatible), ..SaveOptions::default() }).unwrap(); + assert_eq!(load_info(&bytes).unwrap().1.pdf_compatible, compatible); + assert!(pdf_content(&bytes).is_none()); + } + } + #[test] fn file_info_tells_old_files() { let d = Document::new(10.0, 10.0); let current = save(&d, false); - assert_eq!(load_info(¤t).unwrap().1, FileInfo { version: VERSION, legacy: false }); + assert_eq!(load_info(¤t).unwrap().1, FileInfo { version: VERSION, legacy: false, pdf_compatible: false }); assert!(!load_info(¤t).unwrap().1.is_old()); let text = String::from_utf8(current).unwrap(); let legacy = text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1); assert!(load_info(legacy.as_bytes()).unwrap().1.is_old()); let v1 = text.replacen(&format!("\"version\":{VERSION}"), "\"version\":1", 1); - assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false }); + assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false, pdf_compatible: false }); assert!(load_info(v1.as_bytes()).unwrap().1.is_old()); } diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 1f33575e7..959079968 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -221,6 +221,7 @@ fn save_a_copy_and_template_suggest_their_names() { assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft"); assert!(app.ui.dialog.is_none(), "the native format has no options"); assert!(vectorcraft_format::sniff(&written.borrow()[0].1)); + assert!(vectorcraft_format::pdf_content(&written.borrow()[0].1).is_some()); assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty()); app.run("file.saveAsTemplate", json!({})).unwrap(); let pick = &picks.borrow()[1]; diff --git a/crates/ui-egui/src/tests/nativeoptions.rs b/crates/ui-egui/src/tests/nativeoptions.rs index 59b8c91de..5f136d514 100644 --- a/crates/ui-egui/src/tests/nativeoptions.rs +++ b/crates/ui-egui/src/tests/nativeoptions.rs @@ -32,7 +32,7 @@ fn save_as_native_asks_for_its_options_and_writes_every_artboard() { let d = app.ui.dialog.as_ref().unwrap(); assert_eq!( (d.str("format").as_str(), d.bool("separateArtboards"), d.bool("embedProfiles"), d.bool("pdfCompatible")), - ("vectorcraft", false, true, false) + ("vectorcraft", false, true, true) ); assert!(written.borrow().is_empty(), "nothing is written before OK"); frame(&mut app); diff --git a/docs/mcp.md b/docs/mcp.md index 366f31921..0c42c160f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4976,8 +4976,12 @@ or in `options` (`file.formatOptions {format}` lists them with their values): - `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews. - `embedProfiles` (default true) carries the ICC profiles the document is tagged with that were loaded from files; opening the file installs them where they are missing (a warning names one that can't be used). -- `pdfCompatible`: native files (default false) also carry a PDF of every artboard (`pdf` in the file); `.ai` files +- `pdfCompatible`: native files (default true) also carry a PDF of every artboard (`pdf` in the file). This makes saves + larger and adds PDF rendering work. Set it to false to leave the PDF out; Save remembers that choice. `.ai` files (default true) with false write blank pages around the native document (smaller; other apps show empty pages). + Native files without artboards and Data Recovery copies omit the PDF. + Reopening a native file keeps its saved PDF preference, falling back to whether it declared a PDF in older files. + Existing files saved without a PDF remain compact. - `.ai` only: `compress` (default true) is the PDF option `compression.compressText`. - The editing copy of a `.ai` or `.ait` file (`editingData`) names its active artboard (`CropAreaActive`): the active artboard of the app's view when the app saves or exports the file, otherwise the artboard the document opened on (the diff --git a/docs/plugins.md b/docs/plugins.md index 27a7b0f91..66866efcd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -282,8 +282,8 @@ object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a fill's colour to decide its shape, but it changes only the geometry. -An **exporter** gets something else: the document as a native `.vectorcraft` file's JSON, as `document.save` writes it -but without indentation (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, +An **exporter** gets something else: the native `.vectorcraft` document JSON without indentation, optional preview/PDF +renditions or save-preference metadata (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, appearance and effect record, artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks `version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. From cd8e98c6cd85b26427df2ae72cf742fcc2efcc78 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:25:53 +0700 Subject: [PATCH 03/93] DXF: a CNC / CAM preset sets the export options for cutting software Squashed from #5500 onto main. --- crates/cad/src/lib.rs | 72 +++++++++++++++ crates/cad/src/tests.rs | 24 +++++ crates/engine/src/cmd/fileio/dxf.rs | 19 +++- crates/engine/src/cmd/fileio/tests_dxf.rs | 26 ++++++ crates/ui-egui/src/dialogs.rs | 7 +- crates/ui-egui/src/dialogs/dxf_options.rs | 106 +++++++++++++++++----- crates/ui-egui/src/dialogs/tests_dxf.rs | 66 ++++++++++++++ crates/ui-egui/src/menus/ui_commands.rs | 2 +- docs/control-protocol.md | 8 +- docs/mcp.md | 9 +- docs/target-app-parity.md | 2 +- 11 files changed, 309 insertions(+), 32 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 3fc4bcb3b..603521160 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -308,6 +308,78 @@ impl Default for DxfOptions { } } +/// A named set of DXF options (the `dxfPreset` param): the options it sets, and the ones a set of +/// options matches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DxfPreset { + /// The options as chosen (the export's defaults for those left out). + #[default] + Custom, + /// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight + /// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize + /// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths), + /// and placed images as PNG. + Cnc, +} + +impl DxfPreset { + pub const ALL: [Self; 2] = [Self::Custom, Self::Cnc]; + + /// The `dxfPreset` param value. + pub fn id(self) -> &'static str { + match self { + Self::Custom => "custom", + Self::Cnc => "cnc", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Custom => "Custom", + Self::Cnc => "CNC / CAM", + } + } + + /// A preset by id (`custom`, `cnc`), any case. + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id())) + } + + /// The options the preset sets (Custom: the defaults); the region and crop are the defaults. + pub fn options(self) -> DxfOptions { + match self { + Self::Custom => DxfOptions::default(), + Self::Cnc => DxfOptions { + version: DxfVersion::R2000, + unit: Unit::Millimeters, + scale: 1.0, + scale_lineweights: false, + colors: ColorDepth::Aci256, + raster: RasterFormat::Png, + preserve: Preserve::Editability, + alter_paths: false, + outline_text: true, + ..DxfOptions::default() + }, + } + } + + /// The preset `o` matches: CNC / CAM when every option it sets is as it sets it, else Custom. + pub fn of(o: &DxfOptions) -> Self { + let c = Self::Cnc.options(); + let same = o.version == c.version + && o.unit == c.unit + && o.scale == c.scale + && o.scale_lineweights == c.scale_lineweights + && o.colors == c.colors + && o.raster == c.raster + && o.preserve == c.preserve + && o.alter_paths == c.alter_paths + && o.outline_text == c.outline_text; + if same { Self::Cnc } else { Self::Custom } + } +} + /// An image file a drawing links to, to be written next to it under `name`. #[derive(Clone, Debug)] pub struct DxfImage { diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index d99858511..5c57fa634 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -823,6 +823,30 @@ fn units_scale_coordinates_and_set_insunits() { assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err()); } +#[test] +fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines() { + let mut d = text_doc("Cut", 20.0); + let layer = d.layers[0].id; + let frame = path(&mut d, shapes::rectangle(Rect::new(5.0, 5.0, 195.0, 95.0)), Paint::None, Paint::solid(Color::BLACK), 10.0); + d.insert(Some(layer), usize::MAX, frame).unwrap(); + let o = DxfOptions { region: d.artboards[0].rect, ..DxfPreset::Cnc.options() }; + let out = export(&d, &o).unwrap(); + let p = pairs(&out.bytes); + assert_eq!(header(&p, "$ACADVER").as_deref(), Some("AC1015")); + assert_eq!(header(&p, "$INSUNITS").as_deref(), Some("4")); + let k = kinds(&out); + assert!(!k.iter().any(|k| matches!(k.as_str(), "TEXT" | "MTEXT" | "IMAGE")), "type is paths: {k:?}"); + assert!(k.len() > 2, "the glyphs' outlines: {k:?}"); + // The 10 pt stroke (wider than the widest lineweight) is one center line, not a filled outline. + assert_eq!(k.last().map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert_ne!(k.get(k.len() - 2).map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert!(out.images.is_empty()); + assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc); + assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom); + assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc)); +} + #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 4320cbb9c..32b87d80d 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde::Deserialize; use serde_json::{Value, json}; -use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_cad::{ColorDepth, DxfOptions, DxfPreset, DxfVersion, Preserve, RasterFormat}; use vectorcraft_doc::{Document, Unit}; use vectorcraft_geom::Rect; @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Export DXF", [], None, - "{path?, 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}", has_doc, export_dxf )] @@ -33,6 +33,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS, + FormatOption { + name: "dxfPreset", + ty: "string", + default: "\"custom\"", + description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it", + }, FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, FormatOption { name: "unit", @@ -120,6 +126,7 @@ impl Unsupported { struct Params { #[serde(flatten)] boards: ArtboardPick, + dxf_preset: Option, version: Option, unit: Option, scale: Option, @@ -150,7 +157,8 @@ fn choice(field: &str, v: Option, read: impl Fn(&str) -> Option, d /// The DXF options in `p` (the region and crop are set per drawing) and the artboard choice. fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { let q: Params = if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("DXF options: {e}")))? } else { Params::default() }; - let d = DxfOptions::default(); + // The options a preset sets are the defaults of those `p` leaves out. + let d = choice("dxfPreset", q.dxf_preset, DxfPreset::from_id, DxfPreset::Custom, "custom or cnc")?.options(); let scale = q.scale.unwrap_or(d.scale); if !(scale.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&scale)) { return Err(bad(C, format!("DXF scale must be a positive number up to {}, not {scale}", vectorcraft_cad::SCALE_RANGE.end()))); @@ -181,6 +189,11 @@ pub fn check(p: &Value) -> std::result::Result<(), String> { parse(p).map(|_| ()).map_err(|e| e.to_string()) } +/// The DXF options in `p` (the region and crop left at their defaults). +pub fn options(p: &Value) -> std::result::Result { + parse(p).map(|(o, _)| o).map_err(|e| e.to_string()) +} + /// Encode `doc` as DXF: one drawing whose origin is the chosen artboard's bottom-left corner /// (default the first), or with `use_artboards` one drawing per chosen artboard (default all) /// holding the art over it. diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 298adeb11..849767683 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -63,6 +63,32 @@ fn export_writes_dxf_of_the_chosen_version() { assert_eq!(header(&text(&ser), "$INSUNITS").as_deref(), Some("1")); } +#[test] +fn the_cnc_preset_sets_the_options_that_are_not_given() { + let mut s = two_boards(); + let cnc = text(&s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap()); + assert_eq!(header(&cnc, "$ACADVER").as_deref(), Some("AC1015")); + assert_eq!(header(&cnc, "$INSUNITS").as_deref(), Some("4")); + // document.export takes it too, beside a PDF preset's name. + let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc"})).unwrap(); + assert_eq!(header(&text(&r), "$ACADVER").as_deref(), Some("AC1015")); + let o = dxf::options(&json!({"dxfPreset": "CNC", "unit": "in"})).unwrap(); + assert_eq!( + (o.version, o.unit, o.colors, o.preserve, o.alter_paths, o.outline_text), + ( + vectorcraft_cad::DxfVersion::R2000, + vectorcraft_doc::Unit::Inches, + vectorcraft_cad::ColorDepth::Aci256, + vectorcraft_cad::Preserve::Editability, + false, + true + ) + ); + assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default"); + let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string(); + assert!(e.contains("custom or cnc"), "{e}"); +} + #[test] fn use_artboards_writes_one_drawing_per_artboard() { let mut s = two_boards(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a7766dd8d..d7e65781a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -473,7 +473,8 @@ pub fn accepts_field(d: &Dialog, field: &str) -> bool { /// Pattern Brush Options corner tile set this way is saved by OK, as one chosen from its menu is, /// an Edit Data Set field set this way is no longer the row's empty text value, and a Layer /// Options setting set this way for several rows is sent to every row (Template also sets Lock -/// and Dim Images, as its checkbox does). +/// and Dim Images, as its checkbox does). A DXF Options preset set this way sets its fields, as +/// one picked from its menu does, and the preset it shows follows the DXF options set this way. pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == brush_options::KIND { brush_art_options::note_set(d, &field); @@ -484,7 +485,11 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == layer_options::KIND { layer_options::note_set(d, &field, &value); } + let dxf = (d.kind == dxf_options::KIND).then(|| field.clone()); d.fields.insert(field, value); + if let Some(field) = dxf { + dxf_options::note_set(d, &field); + } } /// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index dc6a54d16..cc5262393 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -1,13 +1,15 @@ -//! DXF Options (Export As → DXF): version, scale (1 unit of the art = N drawing units) and -//! lineweights, number of colours, the format of placed images, Preserve Appearance or Maximize -//! Editability, and Export Selected Art Only, Alter Paths for Appearance and Outline Text. +//! DXF Options (Export As → DXF): a preset (Custom, or CNC / CAM, which sets the options below for +//! cutting software and shows Custom again once one is changed), version, scale (1 unit of the +//! art = N drawing units) and lineweights, number of colours, the format of placed images, +//! Preserve Appearance or Maximize Editability, and Export Selected Art Only, Alter Paths for +//! Appearance and Outline Text. //! //! Fields: `document.export` params (`format: dxf`, `path?`, the artboard choice) and the DXF //! options of `document.exportDxf`. OK remembers the options for next time and writes the file //! (asking where when there is no path); bad options keep the dialog open. use serde_json::{Map, Value, json}; -use vectorcraft_cad::{ColorDepth, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_cad::{ColorDepth, DxfPreset, DxfVersion, Preserve, RasterFormat}; use vectorcraft_doc::Unit; use vectorcraft_engine::cmd::fileio; @@ -32,40 +34,95 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Fields that are not remembered between exports (they belong to one export). const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"]; +/// The preset field (the `dxfPreset` param). +const PRESET: &str = "dxfPreset"; +/// The preset the fields were last set from or matched (a change of [`PRESET`] sets its fields). +const SHOWN_PRESET: &str = "__shownPreset"; + /// The options a first DXF Options dialog starts from (the export's defaults). fn defaults() -> Map { + let mut m = preset_fields(DxfPreset::Custom); + m.insert(PRESET.into(), json!(DxfPreset::Custom.id())); + m.insert("selectedOnly".into(), json!(false)); + m +} + +/// The fields `preset` sets (Custom: the export's defaults). +fn preset_fields(preset: DxfPreset) -> Map { + let o = preset.options(); let mut m = Map::new(); for (k, v) in [ - ("version", json!(DxfVersion::default().id())), - ("unit", json!(Unit::Millimeters.label())), - ("scale", json!(1)), - ("scaleLineweights", json!(false)), - ("colors", json!(ColorDepth::default().id())), - ("rasterFormat", json!(RasterFormat::default().id())), - ("preserve", json!(Preserve::default().id())), - ("alterPaths", json!(false)), - ("outlineText", json!(false)), - ("selectedOnly", json!(false)), + ("version", json!(o.version.id())), + ("unit", json!(o.unit.label())), + ("scale", json!(o.scale)), + ("scaleLineweights", json!(o.scale_lineweights)), + ("colors", json!(o.colors.id())), + ("rasterFormat", json!(o.raster.id())), + ("preserve", json!(o.preserve.id())), + ("alterPaths", json!(o.alter_paths)), + ("outlineText", json!(o.outline_text)), ] { m.insert(k.into(), v); } m } +/// Show the preset the fields match: CNC / CAM while every field it sets is as it sets it, else +/// Custom. An unknown preset stays as it is, for OK to refuse. +fn show_matching_preset(d: &mut Dialog) { + if DxfPreset::from_id(&d.str(PRESET)).is_none() { + return; + } + let preset = fileio::dxf::options(&super::form::params(d)).map_or(DxfPreset::Custom, |o| DxfPreset::of(&o)); + d.fields.insert(PRESET.into(), json!(preset.id())); + d.fields.insert(SHOWN_PRESET.into(), json!(preset.id())); +} + +/// A preset picked since the fields were last matched, or any preset when `force` is set, sets +/// its fields; Custom puts back the export's defaults. +fn apply_picked_preset(d: &mut Dialog, force: bool) { + if (force || !d.str(PRESET).eq_ignore_ascii_case(&d.str(SHOWN_PRESET))) + && let Some(p) = DxfPreset::from_id(&d.str(PRESET)) + { + d.fields.extend(preset_fields(p)); + } +} + +/// `field` was set (`ui.dialog.set`): a preset sets its fields now, even the one shown, so fields +/// set after it keep their values, and any other field shows the preset the fields then match, as +/// drawing does. +pub(super) fn note_set(d: &mut Dialog, field: &str) { + if field == PRESET { + apply_picked_preset(d, true); + } + show_matching_preset(d); +} + /// Open DXF Options for an export whose params (path, artboard choice, any DXF option) are -/// `params`, over the options used last. +/// `params`, over the options used last. A `dxfPreset` in `params` sets its fields under the +/// options `params` gives. pub fn open(app: &mut VectorcraftApp, params: &Value) { let mut fields = defaults(); - for given in [&app.ui.dxf_options, params] { - if let Some(o) = given.as_object() { - fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + if let Some(o) = app.ui.dxf_options.as_object() { + fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + if let Some(o) = params.as_object() { + if let Some(p) = o.get(PRESET).and_then(Value::as_str).and_then(DxfPreset::from_id) { + fields.extend(preset_fields(p)); } + fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); } fields.insert("format".into(), json!("dxf")); - app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields))); + let mut d = Dialog::new(KIND, Value::Object(fields)); + show_matching_preset(&mut d); + app.ui.dialog = Some(d); } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let presets: Vec<(&str, &str)> = DxfPreset::ALL.iter().map(|p| (p.id(), p.label())).collect(); + choice(ui, d, PRESET, tl!("Preset:"), &presets); + apply_picked_preset(d, false); + ui.add_space(4.0); let versions: Vec<(&str, &str)> = DxfVersion::ALL.iter().map(|v| (v.id(), v.label())).collect(); choice(ui, d, "version", tl!("Version:"), &versions); ui.add_space(4.0); @@ -113,6 +170,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { Preserve::Editability => tl!("Type stays text, and every stroke is a line with its lineweight and dashes."), }, ); + show_matching_preset(d); false } @@ -146,14 +204,20 @@ fn note(ui: &mut egui::Ui, text: &str) { /// Check the options, remember them, and write the file(s). fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + // A preset or field set since the dialog last drew (the control channel sets fields without + // drawing) counts as drawn. + let mut d = d.clone(); + apply_picked_preset(&mut d, false); + show_matching_preset(&mut d); + let d = &d; let params = super::form::params(d); fileio::dxf::check(¶ms)?; // Nothing selected keeps the dialog open too. if let Some(st) = app.session.active() { fileio::export_source(st, ¶ms).map_err(|e| e.to_string())?; } - app.ui.dxf_options = - Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect()); + let remembered = params.as_object().map(|o| o.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone()))); + app.ui.dxf_options = Value::Object(remembered.into_iter().flatten().collect()); app.ui.dialog = None; let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); io::export(app, Some("dxf"), path, ¶ms) diff --git a/crates/ui-egui/src/dialogs/tests_dxf.rs b/crates/ui-egui/src/dialogs/tests_dxf.rs index 252c67f7e..22429b164 100644 --- a/crates/ui-egui/src/dialogs/tests_dxf.rs +++ b/crates/ui-egui/src/dialogs/tests_dxf.rs @@ -40,6 +40,66 @@ fn export_as_dxf_shows_dxf_options_then_writes_and_remembers_them() { assert!(app.ui.dxf_options.get("useArtboards").is_none()); } +#[test] +fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { + let (mut app, written) = app(1); + app.run("ui.dxfOptionsDialog", json!({"path": "/out/a.dxf"})).unwrap(); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "custom"); + set(&mut app, "dxfPreset", json!("cnc")); + frame(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + let fields = |d: &crate::state::Dialog| { + ["dxfPreset", "version", "unit", "scale", "colors", "preserve", "alterPaths", "outlineText"].map(|k| d.str(k)).join(" ") + }; + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + // Any change to a field the preset sets shows Custom, and keeps the other fields. + set(&mut app, "colors", json!("true")); + frame(&mut app); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2000 Millimeters 1.0 true editability false true"); + set(&mut app, "colors", json!("256")); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc", "matching every field again"); + confirm(&mut app).unwrap(); + let dxf = text(&written.borrow()[0].1); + let lines: Vec<&str> = dxf.lines().collect(); + let header = |var: &str| lines.iter().position(|l| *l == var).and_then(|i| lines.get(i + 2)).map(|v| v.trim()); + assert_eq!((header("$ACADVER"), header("$INSUNITS")), (Some("AC1015"), Some("4"))); + // The next DXF Options start from the preset; Custom puts back the defaults. + crate::menus::invoke(&mut app, "ui.dxfOptionsDialog", json!({})); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc"); + set(&mut app, "dxfPreset", json!("custom")); + frame(&mut app); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2018 Millimeters 1.0 true appearance false false"); + // A preset the command gives sets the fields it does not give. + app.ui.dialog = None; + app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "cnc", "unit": "Inches"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(fields(d), "custom 2000 Inches 1.0 256 editability false true"); + // A preset set and confirmed without drawing (the control channel) sets its fields too. + set(&mut app, "dxfPreset", json!("cnc")); + confirm(&mut app).unwrap(); + assert_eq!((app.ui.dxf_options["dxfPreset"].as_str(), app.ui.dxf_options["unit"].as_str()), (Some("cnc"), Some("Millimeters"))); + // `ui.dialog.set` sets the preset's fields at once, so a field set after it keeps its value. + app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "custom"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + set_field(d, "dxfPreset".into(), json!("cnc")); + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + set_field(d, "unit".into(), json!("Inches")); + assert_eq!(d.str("dxfPreset"), "custom"); + // Setting the preset again sets its fields again. + set_field(d, "dxfPreset".into(), json!("cnc")); + assert_eq!(d.str("unit"), "Millimeters"); + set_field(d, "unit".into(), json!("Inches")); + // Setting Custom while Custom is shown still puts back the defaults. + let mut reset = d.clone(); + set_field(&mut reset, "dxfPreset".into(), json!("custom")); + assert_eq!(fields(&reset), "custom 2018 Millimeters 1.0 true appearance false false"); + confirm(&mut app).unwrap(); + let remembered = ["dxfPreset", "version", "unit"].map(|k| app.ui.dxf_options[k].as_str()); + assert_eq!(remembered, [Some("custom"), Some("2000"), Some("Inches")]); +} + #[test] fn bad_dxf_options_keep_the_dialog_open() { let (mut app, written) = app(1); @@ -47,6 +107,12 @@ fn bad_dxf_options_keep_the_dialog_open() { set(&mut app, "scale", json!(-1)); assert!(confirm(&mut app).is_err()); assert_eq!(kind(&app), Some("dxfOptions")); + set(&mut app, "scale", json!(1)); + set(&mut app, "dxfPreset", json!("laser")); + frame(&mut app); + let e = confirm(&mut app).unwrap_err(); + assert!(e.contains("custom or cnc"), "{e}"); + set(&mut app, "dxfPreset", json!("custom")); set(&mut app, "scale", json!(10)); // Export Selected Art Only with nothing selected. app.run("select.none", json!({})).unwrap(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5813da723..94f375c4f 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1245,7 +1245,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.dxfOptionsDialog", "DXF Options Dialog", "", - "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", + "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last, a dxfPreset's options under them; the dxfPreset field shows cnc while every field it sets matches, else custom); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", ), ( "links.editOriginal", diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a799f22ff..3fcec09b8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -669,9 +669,11 @@ with. DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`, then the save dialog), or `ui.dxfOptionsDialog {path?, …document.exportDxf options}` for agents, opens the `dxfOptions` dialog. Its fields are -`document.export` params: `version`, `unit` (a unit name such as `Millimeters`), `scale`, `scaleLineweights`, `colors` -(`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, `editability`), `selectedOnly`, -`alterPaths`, `outlineText`, plus `path` and the artboard choice. `ui.dialog.confirm` checks them (a bad value, or +`document.export` params: `dxfPreset` (`custom`, `cnc`), `version`, `unit` (a unit name such as `Millimeters`), `scale`, +`scaleLineweights`, `colors` (`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, +`editability`), `selectedOnly`, `alterPaths`, `outlineText`, plus `path` and the artboard choice. Setting `dxfPreset` to +`cnc` sets the fields it sets (see `document.exportDxf`), and `dxfPreset` reads `custom` again once one of them is +changed; setting it to `custom` puts back the defaults. `ui.dialog.confirm` checks them (a bad value, or Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and writes the file (asking for a path when there is none). Export As also lists DWG, greyed out: setting its `format` to `dwg` shows, and on confirm answers, the hint to export DXF instead. diff --git a/docs/mcp.md b/docs/mcp.md index 0c42c160f..284a5e887 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4649,8 +4649,12 @@ and every stroke a line. `alterPaths` writes as filled outlines those strokes (w wider than the widest lineweight (2.11 mm), and every stroke in R13 and R14, which have no lineweights; it keeps other strokes as lines. `outlineText` outlines type, `selectedOnly` writes only the selected objects in their layers (every export takes it), and `useArtboards: true` writes one drawing per chosen -artboard holding the art over it. What DXF can't hold (gradients and patterns as one colour, blending, opacity masks, -raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with +artboard holding the art over it. `dxfPreset: "cnc"` (CNC / CAM; default `"custom"`) sets the options for cutting +software that the call does not give: version `2000` (the oldest version that has both lightweight polylines and lineweights), `unit: +"mm"` and `scale: 1`, `colors: 256` (all version 2000 holds), `preserve: "editability"` with `alterPaths: false` (every +stroke a center line), `outlineText: true` (type as paths), and `rasterFormat: "png"`. DXF Options has the same choice as its Preset menu, +which shows Custom again once a field it sets is changed. What DXF can't hold (gradients and patterns as one colour, +blending, opacity masks, raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with no linked file, and `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such images. @@ -4661,6 +4665,7 @@ opened. ```json {"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/plan.dxf","version":"2013","unit":"mm","scale":10,"preserve":"editability"}}} {"name":"export","arguments":{"path":"/tmp/plan.dxf","options":{"useArtboards":true,"colors":256}}} +{"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/sign.dxf","dxfPreset":"cnc"}}} ``` ## DXF import diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eee501ef9..c388ee2fd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1336,7 +1336,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); CNC preset and VCarve QA remain pending. + - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, PNG placed images), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. From 122b10143ce748dc7a6747d14fbaecd6cec38c00 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 07:16:11 -0800 Subject: [PATCH 04/93] Layers: a document opened after a close keeps its thumbnail jobs Closing a document told the worker to forget every row whose document was not open at that moment. The worker keeps that test until it is idle, so while a job still ran, a document opened afterward had its rows dropped and they stayed without thumbnails. The worker now forgets only the documents that closed. --- crates/ui-egui/src/panels/layers/thumbs.rs | 40 +++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index 49ce4b61b..4fcdc03e4 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -266,18 +266,17 @@ impl Thumbs { return; } let open: HashSet = uids.iter().copied().collect(); - let closed = self.open_docs.as_ref().is_some_and(|before| before.iter().any(|uid| !open.contains(uid))); + let closed: HashSet = self.open_docs.as_ref().map(|before| before.difference(&open).copied().collect()).unwrap_or_default(); let stale = self.entries.keys().chain(self.pending.keys()).any(|key| !open.contains(&key.0)) || self.showing.is_some_and(|uid| !open.contains(&uid)); self.entries.retain(|key, _| open.contains(&key.0)); self.pending.retain(|key, _| open.contains(&key.0)); self.showing = self.showing.filter(|uid| open.contains(uid)); - if closed { + if !closed.is_empty() { // Images finished before now are dropped with the rest of the queue: the open - // documents' rows are asked for again. + // documents' rows are asked for again. Documents opened later keep their jobs. if let Some(w) = &self.worker { - let open = open.clone(); - w.forget(move |key| !open.contains(&key.0)); + w.forget(move |key| closed.contains(&key.0)); if w.poll().is_none() { self.worker = None; } @@ -536,6 +535,37 @@ mod tests { assert_eq!(done.key.0, next, "no worker keeps the closed document's image"); } + #[test] + fn a_document_opened_while_a_job_runs_after_a_close_gets_its_thumbnails() { + let ctx = egui::Context::default(); + let Some(worker) = ThumbWorker::start(&ctx, "test") else { return }; + let mut thumbs = Thumbs { worker: Some(worker), worker_started: true, ..Default::default() }; + thumbs.retain_open(&[1, 2]); + // A job of document 2 is running. + let (started, running) = std::sync::mpsc::channel::<()>(); + let (release, held) = std::sync::mpsc::channel::<()>(); + let blocker = (2, NodeId(1)); + let job: crate::thumb_worker::JobFn = Box::new(move |_| { + started.send(()).ok(); + held.recv().ok(); + None + }); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(blocker, (blocker, Weak::new(), 1), job))); + running.recv().expect("the job starts"); + // Document 1 closes, then document 3 opens and one of its rows is queued. + thumbs.retain_open(&[2]); + thumbs.retain_open(&[2, 3]); + let opened = (3, NodeId(2)); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(opened, (opened, Weak::new(), 1), Box::new(|_| None)))); + // A job of document 2 queued after it marks the end of the queue. + let last = (2, NodeId(3)); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(last, (last, Weak::new(), 1), Box::new(|_| None)))); + release.send(()).ok(); + let worker = thumbs.worker.as_ref().expect("the worker is kept"); + let order: Vec<(u64, NodeId)> = (0..3).map_while(|_| worker.wait().map(|d| d.key.0)).take_while(|key| *key != last).collect(); + assert_eq!(order, [blocker, opened], "document 3's job comes back before the last one"); + } + /// An open zigzag of `anchors` anchors. fn line(anchors: usize) -> PathData { let pts: Vec = (0..anchors).map(|i| Point::new(i as f64 * 0.01, (i % 2) as f64 * 50.0)).collect(); From 434c032f9e48c81f4ab99227fcb1c5fa88bd87a1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:26:11 +0700 Subject: [PATCH 05/93] Symbols: Direct Selection sets the solid fill and stroke of one object in a dynamic instance Squashed from #5962 onto main. --- crates/doc/src/hit.rs | 251 ++++++ crates/doc/src/hit/tests.rs | 125 +++ crates/doc/src/lib.rs | 2 +- crates/doc/src/selection.rs | 48 ++ crates/doc/src/selection/tests.rs | 76 ++ crates/doc/src/swatches.rs | 27 +- crates/doc/src/symbol_nine_slice.rs | 12 + crates/doc/src/symbol_overrides.rs | 40 + crates/engine/src/cmd.rs | 9 +- crates/engine/src/cmd/appearance.rs | 38 +- crates/engine/src/cmd/brushsym.rs | 12 +- crates/engine/src/cmd/brushsym/dynamic.rs | 67 +- crates/engine/src/cmd/brushsym/symbols.rs | 2 + crates/engine/src/cmd/distortcmds.rs | 11 +- crates/engine/src/cmd/edit.rs | 40 +- crates/engine/src/cmd/fileio/export.rs | 5 +- crates/engine/src/cmd/freeform.rs | 6 +- crates/engine/src/cmd/globaledit.rs | 5 +- crates/engine/src/cmd/gradient.rs | 6 +- crates/engine/src/cmd/graph/edit.rs | 8 +- crates/engine/src/cmd/intertwine.rs | 17 +- crates/engine/src/cmd/live/common.rs | 8 +- crates/engine/src/cmd/maskedit.rs | 21 +- crates/engine/src/cmd/object/transform.rs | 5 + crates/engine/src/cmd/objects_on_path.rs | 6 +- crates/engine/src/cmd/opacitymask.rs | 40 +- crates/engine/src/cmd/paint.rs | 130 ++- crates/engine/src/cmd/path.rs | 6 +- crates/engine/src/cmd/patterncmds.rs | 5 +- crates/engine/src/cmd/place.rs | 8 +- crates/engine/src/cmd/plugin.rs | 10 +- crates/engine/src/cmd/recolor.rs | 7 +- crates/engine/src/cmd/stroke.rs | 25 +- crates/engine/src/cmd/stylelib.rs | 3 + crates/engine/src/cmd/symboledit.rs | 4 + crates/engine/src/command_sets.rs | 5 + crates/engine/src/lib.rs | 4 + crates/engine/src/session.rs | 2 +- crates/engine/src/session/documents.rs | 1 + crates/engine/src/session/edit.rs | 2 +- crates/engine/src/session/execute.rs | 10 +- crates/engine/src/tests/dynamic_symbols.rs | 871 ++++++++++++++++++++- crates/engine/src/tooling.rs | 18 + crates/tools/src/direct.rs | 46 +- crates/tools/src/direct/tests.rs | 162 ++++ crates/tools/src/xform/gradient.rs | 5 + crates/tools/src/xform/gradient/tests.rs | 25 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/gaps.md | 6 +- docs/mcp.md | 30 +- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 2 +- 78 files changed, 2198 insertions(+), 106 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index b3b4394a2..ea3575bf6 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -936,6 +936,257 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option } } +/// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and +/// how the canvas places each object of it: through the symbol's 9-slice guides when the instance +/// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform. +struct InstanceArt { + art: Node, + map: Option, + xf: vectorcraft_geom::Affine, +} + +impl InstanceArt { + /// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that + /// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, + /// which [`Document::place_symbol_art`] does not place and the canvas does not draw. + fn of(doc: &Document, inst: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; + let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = crate::symbol_overrides::dynamic_art(&sym.art, inst); + let map = doc.symbol_slice_map(symbol, *xf); + if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) { + return None; + } + Some(Self { art, map, xf: *xf }) + } + + /// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the + /// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell + /// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides + /// object by object. + fn whole(&self, n: &Node, doc: &Document, whole: Option) -> Option { + whole.or_else(|| self.map.as_ref()?.one_cell(n, doc)) + } + + /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by + /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when + /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as + /// [`Document::place_symbol_art`] refuses it. + fn place(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } + + /// Object `n` of the art placed on the canvas without clip groups + /// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the + /// same limit ([`Document::place_symbol_art_unclipped`]). + fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } +} + +/// `n` transformed by `xf`, its strokes at their own weight. +fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { + let mut out = n.clone(); + out.transform(xf, false); + out +} + +/// The object of symbol instance `inst`'s art under `p`, for Direct Selection on a dynamic symbol's +/// instance: its path of child indices from the art's root ([`crate::symbol_overrides::part`]), the +/// topmost object hit inside the art's groups. With [`HitOptions::clip_outlines`], a press on the +/// outline of the clipping path of a clip group or a clipping layer picks that path before the art +/// it clips, as [`hit_test`] does outside symbols. Hidden, locked and template objects are passed +/// over, as [`hit_test`] passes over them when it looks for editable objects, and so are a Shaper +/// group's original shapes, which the canvas does not draw, and guide paths with +/// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice +/// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not +/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. +pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { + struct Find<'a> { + doc: &'a Document, + art: &'a InstanceArt, + p: Point, + opt: HitOptions, + symbols: &'a SymbolBoxes<'a>, + } + impl Find<'_> { + fn hit(&self, n: &Node, whole: Option) -> bool { + self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + } + fn skips_guide(&self, n: &Node) -> bool { + self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) + } + fn find(&self, n: &Node, path: &mut Vec, whole: Option) -> bool { + if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { + return false; + } + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) }; + if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { + return self.hit(n, whole); + } + let whole = self.art.whole(n, self.doc, whole); + if n.clips() + && let Some(first) = children.first() + { + let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; + if self.opt.clip_outlines + && first.visible + && !first.locked + && !self.skips_guide(first) + && clip_outline(&clip, self.p, self.opt).is_some() + { + path.push(0); + return true; + } + if !clip_contains(&clip, self.p) { + return false; + } + } + for (i, c) in children.iter().enumerate().rev() { + // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. + if shaper_hides(n, i, None) { + continue; + } + let Ok(i) = u32::try_from(i) else { continue }; + path.push(i); + if self.find(c, path, whole) { + return true; + } + path.pop(); + } + false + } + } + let art = InstanceArt::of(doc, inst)?; + let symbols = doc.symbol_boxes(); + let mut path = vec![]; + Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) +} + +/// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), +/// placed on the canvas as it is drawn: its paths' outlines, and the bounds of objects without a +/// path. Inside a clip group (an object drawn once in each 9-slice cell it reaches is one clip +/// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An +/// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut +/// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`] +/// picks it. +/// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies. +pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option { + let art = InstanceArt::of(doc, inst)?; + let part = crate::symbol_overrides::part(&art.art, path)?; + let mut out = BezPath::new(); + let mut limit = None; + let mut whole = None; + let mut n = &art.art; + for &i in path { + let children = n.children()?; + whole = art.whole(n, doc, whole); + if i != 0 + && n.clips() + && let Some(first) = children.first() + && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + { + match meet(Some(clip), limit) { + Some(r) => limit = Some(r), + None => return Some(out), + } + } + n = children.get(usize::try_from(i).ok()?)?; + } + outline_within(&art.place(part, doc, whole)?, limit, &mut out); + Some(out) +} + +/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one. +fn outline_within(n: &Node, limit: Option, out: &mut BezPath) { + use vectorcraft_geom::kurbo::Shape; + if let Some(pd) = n.path_data() { + match limit { + Some(r) => push_cut(out, &pd.to_bezpath(), r), + None => out.extend(pd.to_bezpath()), + } + } else if n.children().is_none() + && let Some(b) = n.geometric_bounds() + && let Some(b) = meet(Some(b), limit) + { + out.extend(b.to_path(0.1)); + } + let Some(children) = n.children() else { return }; + if n.clips() + && let Some((first, rest)) = children.split_first() + { + let inner = match first.geometric_bounds() { + Some(c) => match meet(Some(c), limit) { + Some(r) => Some(r), + None => return, + }, + None => limit, + }; + rest.iter().for_each(|c| outline_within(c, inner, out)); + return; + } + children.iter().for_each(|c| outline_within(c, limit, out)); +} + +/// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap. +fn meet(a: Option, b: Option) -> Option { + let a = a?; + let Some(b) = b else { return Some(a) }; + let r = Rect::new(a.x0.max(b.x0), a.y0.max(b.y0), a.x1.min(b.x1), a.y1.min(b.y1)); + (r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r) +} + +/// The parts of path `p`, flattened to lines, that lie inside `r`, appended to `out`. +fn push_cut(out: &mut BezPath, p: &BezPath, r: Rect) { + use vectorcraft_geom::kurbo::PathEl; + let (mut at, mut start, mut last) = (Point::ZERO, Point::ZERO, None::); + vectorcraft_geom::kurbo::flatten(p.iter(), 0.1, |el| { + let to = match el { + PathEl::MoveTo(q) => { + (at, start, last) = (q, q, None); + return; + } + PathEl::LineTo(q) => q, + PathEl::ClosePath => start, + _ => return, + }; + if let Some((a, b)) = cut_segment(at, to, r) { + if last != Some(a) { + out.move_to(a); + } + out.line_to(b); + last = Some(b); + } + at = to; + }); +} + +/// The part of segment `a` to `b` inside `r`, `None` when none of it is. +fn cut_segment(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { + let d = b - a; + let (mut t0, mut t1) = (0.0_f64, 1.0_f64); + for (p, q) in [(-d.x, a.x - r.x0), (d.x, r.x1 - a.x), (-d.y, a.y - r.y0), (d.y, r.y1 - a.y)] { + if p.abs() < 1e-12 { + if q < 0.0 { + return None; + } + } else if p < 0.0 { + t0 = t0.max(q / p); + } else { + t1 = t1.min(q / p); + } + } + (t0 <= t1).then(|| (a + d * t0, a + d * t1)) +} + /// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is /// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an /// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`. diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 9a5c40478..495dc227b 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1373,3 +1373,128 @@ fn cull_box_holds_the_reach_of_paths_and_compound_paths() { assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}"); assert_eq!(checked, 201); } + +/// In a dynamic symbol's art, a layer with a clipping mask clips its objects as a clip group does: +/// Direct Selection picks an object only where the mask shows it, and outlines it there. +#[test] +fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mask, art) = half_clipped(&mut d); + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![Arc::new(mask), Arc::new(art)]; + } + layer.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "where the mask shows the art"); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), None, "where the mask hides it"); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0, 1]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the outline is cut to the mask: {b:?}"); + // Its outline inside the group around the layer is cut too. + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}"); +} + +/// In a dynamic symbol's art that placing through its 9-slice guides could make more than +/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the +/// canvas draws nothing of it. At the art's natural size the same art is picked. +#[test] +fn symbol_art_past_the_object_limit_picks_nothing() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + // Drawn in six cells, each with its own copy of the mask's 3,240,000 squares. + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let plain = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(plain), Arc::new(big)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Big".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Big", Some(crate::SymbolNineSlice::default())); + let scaled = d.alloc_id(); + let natural = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(scaled, NodeKind::SymbolInstance { symbol: "Big".into(), xf })).unwrap(); + d.insert(Some(l), 0, Node::new(natural, NodeKind::SymbolInstance { symbol: "Big".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + assert!(d.place_symbol_art("Big", &d.symbols[0].art, xf).is_none(), "the art is past the limit"); + let opt = HitOptions::default(); + let n = d.node(scaled).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 110.0), opt), None, "the plain square"); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 40.0), opt), None, "the masked path"); + assert!(symbol_part_outline(&d, n, &[0]).is_none()); + assert!(symbol_part_outline(&d, n, &[1]).is_none()); + let n = d.node(natural).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 75.0), opt), Some(vec![0])); + assert!(symbol_part_outline(&d, n, &[0]).is_some()); +} + +/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it +/// included, so an object in it is picked and outlined there even when placing that object through +/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects. +#[test] +fn objects_of_a_clip_group_in_one_cell_are_placed_with_it() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(big.clone())]); + group.set_clips(true); + let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Corner".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Corner", Some(crate::SymbolNineSlice::default())); + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Corner".into(), xf })).unwrap(); + let m = d.symbol_slice_map("Corner", xf).unwrap(); + assert!(m.most_placed(&big, &d) > crate::MAX_SYMBOL_NODES, "the object alone is past the limit"); + assert!(d.place_symbol_art("Corner", &d.symbols[0].art, xf).is_some(), "the art is drawn"); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(10.0, 10.0), HitOptions::default()), Some(vec![1, 1])); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[1, 1]).unwrap()); + assert!((b.x1 - 20.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "cut to the clip in its corner: {b:?}"); +} + +/// In a dynamic symbol's art, Direct Selection does not pick a Shaper group's original shapes, +/// which the canvas does not draw: a click over an original shape whose face the group erased +/// picks nothing, and a click over the face it shows picks the face. +#[test] +fn a_shaper_groups_original_shapes_in_symbol_art_are_not_picked() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art()); + let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + sources.name = Some(crate::shaper::SOURCES.into()); + sources.opacity = 0.0; + let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]); + group.extras_mut().shaper = Some(Box::default()); + let root = Node::group(d.alloc_id(), vec![Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Shaped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(130.0, 50.0), opt), None, "over the erased original shape"); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "over the shown face"); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bff8e40f3..8eeb1786d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -109,7 +109,7 @@ pub use placed_document::{PageBox, PlacedDocument}; pub use profiles::ColorProfiles; pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; -pub use selection::{AnchorRef, SegmentRef, Selection}; +pub use selection::{AnchorRef, SegmentRef, Selection, SymbolPart}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index f03402791..643f81c36 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -54,6 +54,20 @@ pub struct Selection { /// the engine then sets the document's from it. Editing state, never saved. #[serde(skip)] pub new_art: Arc>, + /// An object of a dynamic symbol instance's art, selected with Direct Selection: the instance + /// is the one selected object, and solid fill and stroke changes go to its own appearance for + /// that object ([`crate::symbol_overrides`]). Any other selection change clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symbol_part: Option, +} + +/// See [`Selection::symbol_part`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolPart { + /// The symbol instance. + pub instance: NodeId, + /// The object in the symbol's art, as [`crate::symbol_overrides::part`] names it. + pub path: Vec, } impl Selection { @@ -79,6 +93,13 @@ impl Selection { self.slices.clear(); self.guides.clear(); self.key_guide = None; + self.symbol_part = None; + } + /// Select only instance `instance`, with the object at `path` of its symbol's art (see + /// [`Selection::symbol_part`]). + pub fn set_symbol_part(&mut self, instance: NodeId, path: Vec) { + self.set([instance]); + self.symbol_part = Some(SymbolPart { instance, path }); } /// Is there a key for Align: an object or a guide? pub fn has_key(&self) -> bool { @@ -93,6 +114,7 @@ impl Selection { pub fn add(&mut self, id: NodeId) { self.target = None; self.slices.clear(); + self.symbol_part = None; if !self.objects.contains(&id) { self.objects.push(id); } @@ -100,6 +122,7 @@ impl Selection { pub fn remove(&mut self, id: NodeId) { self.target = None; self.slices.clear(); + self.symbol_part = None; self.objects.retain(|x| *x != id); self.anchors.remove(&id); self.segments.remove(&id); @@ -128,6 +151,7 @@ impl Selection { /// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a /// Shift-click on a guide). pub fn toggle_guide(&mut self, i: usize) { + self.symbol_part = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); if self.key_guide == Some(i) { @@ -207,6 +231,27 @@ impl Selection { if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) { self.key_guide = None; } + // A selected part needs its instance selected alone, with no guides, its symbol dynamic + // and the object still in the symbol's art as a path, compound path or type, where neither + // it nor a group or layer around it is hidden, locked or a template. + let editable_part = |art: &crate::Node, path: &[u32]| { + crate::symbol_overrides::part(art, path).is_some_and(crate::symbol_overrides::takes_part_paint) + && (0..=path.len()) + .all(|k| crate::symbol_overrides::part(art, &path[..k]).is_some_and(|n| n.visible && !n.locked && !n.is_template())) + }; + let part_kept = self.symbol_part.as_ref().is_some_and(|sp| { + self.objects == [sp.instance] + && self.guides.is_empty() + && found.get(&sp.instance).and_then(|p| doc.node_at(p)).is_some_and(|n| match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => { + doc.symbols.iter().find(|s| s.name == *symbol).is_some_and(|s| s.dynamic && editable_part(&s.art, &sp.path)) + } + _ => false, + }) + }); + if !part_kept { + self.symbol_part = None; + } } /// Deselect the objects that are hidden or locked, themselves or through a group or layer /// around them (and so the key object when it is one): hidden or locked art can't stay @@ -238,6 +283,9 @@ impl Selection { } self.target = None; self.slices.clear(); + if self.symbol_part.as_ref().is_some_and(|sp| gone.contains(&sp.instance)) { + self.symbol_part = None; + } self.objects.retain(|o| !gone.contains(o)); self.anchors.retain(|id, _| !gone.contains(id)); self.segments.retain(|id, _| !gone.contains(id)); diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index 5665f1140..7882fef73 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -41,6 +41,82 @@ fn guides_are_selected_on_their_own() { assert_eq!(s.guides, vec![0]); } +/// A picked object of a dynamic symbol's instance stays picked while it and the groups and layers +/// around it in the symbol's art are visible, unlocked and not a template. An art edit that hides +/// or locks it, or one around it, or makes a layer around it a template, drops the pick. +#[test] +fn a_picked_symbol_part_is_dropped_once_it_cannot_be_edited() { + let rect = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let leaf = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art()); + let group = Node::group(d.alloc_id(), vec![Arc::new(leaf)]); + let layer = Node::layer(d.alloc_id(), "Inner", crate::LayerColor::Preset(2)); + let root = Node::group(d.alloc_id(), vec![Arc::new(group), Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Parts".into(), art: Arc::new(root.clone()) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Parts".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + // The layer in the art holds a path too. + let in_layer = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art()); + let mut art = root; + if let Some(NodeKind::Layer { children, .. }) = art.children_mut().and_then(|c| c.get_mut(1)).map(|c| &mut Arc::make_mut(c).kind) { + children.push(Arc::new(in_layer)); + } + d.symbols[0].art = Arc::new(art.clone()); + let picked = |d: &Document, path: &[u32]| { + let mut s = Selection::default(); + s.set_symbol_part(inst, path.to_vec()); + s.prune(d); + s.symbol_part.is_some() + }; + assert!(picked(&d, &[0, 0]) && picked(&d, &[1, 0]), "editable parts stay picked"); + assert!(!picked(&d, &[0]) && !picked(&d, &[1]) && !picked(&d, &[]), "a group or layer does not stay picked"); + let edited = |change: &dyn Fn(&mut Node)| { + let mut d = d.clone(); + let mut art = art.clone(); + change(&mut art); + d.symbols[0].art = Arc::new(art); + d + }; + let child = |art: &mut Node, path: &[usize]| -> Node { + let mut n = &*art; + for &i in path { + n = &n.children().unwrap()[i]; + } + n.clone() + }; + let set = |art: &mut Node, path: &[usize], n: Node| { + let mut at = art; + for &i in path { + at = Arc::make_mut(&mut at.children_mut().unwrap()[i]); + } + *at = n; + }; + let hidden = edited(&|art| { + let mut n = child(art, &[0, 0]); + n.visible = false; + set(art, &[0, 0], n); + }); + assert!(!picked(&hidden, &[0, 0]), "the part is hidden"); + let locked_group = edited(&|art| { + let mut n = child(art, &[0]); + n.locked = true; + set(art, &[0], n); + }); + assert!(!picked(&locked_group, &[0, 0]), "the group around it is locked"); + assert!(picked(&locked_group, &[1, 0]), "a part outside the locked group stays"); + let template = edited(&|art| { + let mut n = child(art, &[1]); + if let NodeKind::Layer { template, .. } = &mut n.kind { + *template = true; + } + set(art, &[1], n); + }); + assert!(!picked(&template, &[1, 0]), "the layer around it is a template"); + let hidden_root = edited(&|art| art.visible = false); + assert!(!picked(&hidden_root, &[0, 0]), "the art is hidden"); +} + #[test] fn guides_show_and_lock_with_their_layer() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 5caada836..5aa6147fa 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -176,7 +176,7 @@ impl Document { pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) { let mut tree = |n: &Node| { walk_with_masks(n, &mut |m| { - node_paints(m).for_each(&mut *f); + node_paints(m).chain(override_paints(m)).for_each(&mut *f); if let NodeKind::Mesh(mesh) = &m.kind { mesh.points.iter().for_each(|p| f(&Paint::solid(p.color))); } @@ -275,10 +275,11 @@ fn map_style_paint(attrs: &mut serde_json::Map, key: } /// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs -/// and gradient stops (with their link), then mesh points (unlinked), in paint order. +/// and gradient stops (with their link), a dynamic symbol instance's own paints +/// ([`override_paints`]), then mesh points (unlinked), in paint order. pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { n.walk(&mut |m| { - for p in node_paints(m) { + for p in node_paints(m).chain(override_paints(m)) { match p { Paint::Solid { color, swatch, .. } => f(color, swatch.as_deref()), Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, s.swatch.as_deref())), @@ -315,6 +316,12 @@ pub fn node_paints(n: &Node) -> impl Iterator { n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])).chain(n.extras().colorize.as_deref()) } +/// The paints a dynamic symbol instance keeps for objects of its art +/// ([`crate::symbol_overrides`]), which [`node_paints`] leaves out. +pub fn override_paints(n: &Node) -> impl Iterator { + n.extras().symbol_overrides.iter().flat_map(|o| [o.fill.as_ref(), o.stroke.as_ref()]).flatten() +} + /// Mutable [`node_paints`], in the same order. pub fn node_paints_mut(n: &mut Node) -> impl Iterator { let runs = match &mut n.kind { @@ -353,6 +360,20 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { } out = Some(m); } + // A dynamic symbol instance's own paints for objects of its art (`symbol_overrides`). + if !n.extras().symbol_overrides.is_empty() { + let mut overrides = n.extras().symbol_overrides.clone(); + let mut k = 0; + for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() { + if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) { + k += 1; + } + } + if k > 0 { + out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides; + count += k; + } + } if let Some(ch) = n.children() { for (i, c) in ch.iter().enumerate() { if let Some((new, k)) = map_node(c, f) { diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index acc682872..995445f57 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -451,6 +451,18 @@ impl NineSliceMap { self.most_at(art, &boxes, 0) } + /// The affine map that [`Self::place`] and [`Self::place_unclipped`] move object `n` by as one + /// piece, with everything in it: the map of the cell its box lies in, or of the cell at the + /// origin when it has no box. `None` when its box crosses a guide. + pub fn one_cell(&self, n: &Node, doc: &Document) -> Option { + let boxes = doc.symbol_boxes().quick(); + let Some(b) = n.geometric(Some(&boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { + return Some(self.cell_at(Point::ZERO)); + }; + let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1)); + (cols[0] == cols[1] && rows[0] == rows[1]).then(|| self.cell_xf(cols[0], rows[0])) + } + /// [`Self::most_placed`] for [`Self::place_unclipped`]: the most objects it makes of `art`. pub fn most_placed_unclipped(&self, art: &Node, doc: &Document) -> u64 { Self { clips: false, ..*self }.most_placed(art, doc) diff --git a/crates/doc/src/symbol_overrides.rs b/crates/doc/src/symbol_overrides.rs index b4e3a726f..33f0fd769 100644 --- a/crates/doc/src/symbol_overrides.rs +++ b/crates/doc/src/symbol_overrides.rs @@ -49,6 +49,10 @@ impl SymbolOverride { r.style.fill = p.clone(); } if let Some(p) = &self.stroke { + // A painted stroke on characters without a weight gets 1 pt, as a path's does. + if !p.is_none() && r.style.stroke_width == 0.0 { + r.style.stroke_width = 1.0; + } r.style.stroke = p.clone(); } } @@ -57,6 +61,14 @@ impl SymbolOverride { n.appearance.set_fill(p.clone()); } if let Some(p) = &self.stroke { + // A stroke without paint has no weight; painting it gives it 1 pt. + if !p.is_none() + && let Some(s) = n.appearance.stroke_mut() + && s.paint.is_none() + && s.width == 0.0 + { + s.width = 1.0; + } n.appearance.set_stroke(p.clone()); } } @@ -69,6 +81,13 @@ impl SymbolOverride { } } +/// Can Direct Selection select `n`, an object of a dynamic symbol's art, and change its fill and +/// stroke for one instance? Paths, compound paths and type can; groups, images and every other +/// object cannot. +pub fn takes_part_paint(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) +} + /// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file /// of one endless path stays small once read. fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { @@ -180,6 +199,27 @@ mod tests { assert_eq!(kept.len(), 1); } + #[test] + fn a_stroke_on_type_without_a_weight_gets_one_point() { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let style = crate::CharStyle { stroke: Paint::None, stroke_width: 0.0, ..crate::CharStyle::default() }; + let text = Node::new(NodeId(2), NodeKind::Text(Box::new(crate::TextObject::point(vectorcraft_geom::Point::ZERO, "Hi", style)))); + let mut a = Node::group(NodeId(1), vec![Arc::new(text)]); + apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]); + let Some(NodeKind::Text(t)) = part(&a, &[0]).map(|n| &n.kind) else { panic!("type") }; + assert!(t.runs.iter().all(|r| r.style.stroke == red && r.style.stroke_width == 1.0), "the stroke draws at 1 pt"); + + // A path whose stroke has neither paint nor weight gets 1 pt too. + let mut ap = Appearance::default_art(); + if let Some(s) = ap.stroke_mut() { + (s.paint, s.width) = (Paint::None, 0.0); + } + let path = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap); + let mut a = Node::group(NodeId(1), vec![Arc::new(path)]); + apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]); + assert_eq!(part(&a, &[0]).unwrap().appearance.stroke_width(), 1.0); + } + #[test] fn a_path_deeper_than_the_cap_does_not_read() { let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a26c75da2..c88f47c79 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -590,11 +590,16 @@ pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result Result> { match named_ids(p)? { Some(ids) => Ok(ids), - None => Ok(s.doc()?.selection.objects.clone()), + None => { + edit::refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + } } } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 30e48d4fc..6e369f67b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -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). 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 and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -1136,6 +1136,42 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`")); } let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; + // An object of a dynamic symbol instance's art selected with Direct Selection takes the clicked + // object's solid fill and stroke, as the Eyedropper Options allow, for this instance only. A + // gradient or pattern sampled for it, a source that is not a path, compound path or type (a + // symbol instance's own appearance is empty), and options that pick up neither color are + // refused, and the paint defaults stay as they are. A path of a compound path stands for the + // compound path, which holds the paint. + if !reverse + && !append + && !defaults_only + && p.get("ids").is_none() + && let Some((id, path)) = super::brushsym::selected_part(s)? + { + let d = &s.doc()?.doc; + let src = Arc::new(d.node(super::path::compound_or_self(d, clicked)).ok_or(EngineError::NoNode(clicked))?.clone()); + let (fill, stroke) = (super::paint::proxy_paint(&src, false, None), super::paint::proxy_paint(&src, true, None)); + let a = attrs.appearance; + if !vectorcraft_doc::symbol_overrides::takes_part_paint(&src) + || !(a.fill.color || a.stroke.color) + || (a.fill.color && !super::paint::part_takes(&fill)) + || (a.stroke.color && !super::paint::part_takes(&stroke)) + { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, "Eyedropper", |o| { + if a.fill.color { + o.fill = Some(fill); + } + if a.stroke.color { + o.stroke = Some(stroke); + } + })?; + // The clicked object becomes the Eyedropper's sample, and new art takes its paint. + Picked::of(&src).to_defaults(s, &attrs.appearance); + s.eyedropper_sample = Some(src); + return Ok(json!({ "ids": [id.0] })); + } let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 8cf3a2195..5dcb82136 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -36,7 +36,8 @@ use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; +pub(crate) use dynamic::{edit_part, part_only, selected_part}; +use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; use symbols::selected_instances; pub(crate) use symbols::{ expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol, @@ -266,6 +267,15 @@ pub fn specs() -> Vec { has_selection, symbol_instance_set_appearance ), + cmd!( + "symbol.instance.selectPart", + "Select Symbol Instance Part", + [], + None, + "{id, path: [i…] (as symbol.instance.setAppearance)} select one path, compound path or type object of a dynamic symbol instance's art, as a Direct Selection click on it does: the instance is selected alone, and until the selection changes paint.setFill and paint.setStroke with a solid color or none, paint.invert and paint.complement on a solid color (without ids, id or item; type whose characters differ in color is refused) and appearance.copyFrom with a solid fill and stroke (the Eyedropper, without ids, reverse or append; options that pick up neither color are refused) change that object's fill or stroke for this instance only; gradients and patterns are refused, and so are the commands that change the selected objects without ids (transparency.set, the gradient commands, object.group, edit.cut and edit.clear among them); edit.copy copies the instance whole, and selection exports, artboard.fitToSelection, object.liquify, object.isolate, library.add and select.save use the instance (docs/mcp.md); selectPart itself is refused for a static symbol, a path naming no object, a group, image or other kind of object, a hidden, locked or template object or one inside such a group, and a hidden or locked instance → {id, path}", + has_doc, + symbol_instance_select_part + ), cmd!( "symbol.instance.reset", "Reset Symbol Instance", diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index baa721c45..6197e57be 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -61,6 +61,62 @@ fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc Result)>> { + Ok(s.doc()?.selection.symbol_part.as_ref().map(|sp| (sp.instance, sp.path.clone()))) +} + +/// Change instance `id`'s own appearance for the object at `path` of its art by `f`, as one undo +/// step named `label`; nothing is recorded when the change leaves it as it was. Replies how many +/// objects the instance changes. +pub(crate) fn edit_part(s: &mut Session, id: NodeId, path: &[u32], label: &str, f: impl FnOnce(&mut SymbolOverride)) -> Result { + let list = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.extras().symbol_overrides.clone(); + let old = list.iter().find(|o| o.path == path).cloned().unwrap_or_else(|| SymbolOverride { path: path.to_vec(), ..Default::default() }); + let mut new = old.clone(); + f(&mut new); + if new == old { + return Ok(list.len()); + } + s.edit(label, |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut e = n.extras_mut(); + let list = &mut e.symbol_overrides; + list.retain(|o| o.path != path); + if !new.is_empty() { + list.push(new); + } + Ok(list.len()) + }) +} + +/// The refusal of anything but a solid color fill or stroke change while an object of a symbol +/// instance's art is selected ([`selected_part`]), and of selecting any other object of the art. +pub(crate) fn part_only() -> EngineError { + EngineError::Other("Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change".into()) +} + +pub(super) fn symbol_instance_select_part(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.instance.selectPart"; + if p.get("id").is_none() { + return Err(bad(C, "missing id")); + } + let (id, art) = dynamic_instance(s, p, C)?; + let path = path_param(p, &art, C)?; + // Direct Selection passes over hidden, locked and template objects, and over everything in a + // group or layer that is: the instance in the document, and the object in the symbol's art. + let editable = |n: &Node| n.visible && !n.locked && !n.is_template(); + if !s.doc()?.doc.is_editable(id) || !(0..=path.len()).all(|k| symbol_overrides::part(&art, &path[..k]).is_some_and(editable)) { + return Err(bad(C, "the object is hidden or locked")); + } + if !symbol_overrides::part(&art, &path).is_some_and(symbol_overrides::takes_part_paint) { + return Err(part_only()); + } + let shown = json!(path); + s.select(|_, sel| sel.set_symbol_part(id, path))?; + Ok(json!({ "id": id.0, "path": shown })) +} + /// The `path` param: child indices from the art's root, naming an object of `art`. fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { let path: Option> = @@ -117,16 +173,7 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu 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)?; - let count = s.edit("Symbol Instance Appearance", |d, _| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let mut e = n.extras_mut(); - let list = &mut e.symbol_overrides; - list.retain(|o| o.path != path); - if !new.is_empty() { - list.push(new); - } - Ok(list.len()) - })?; + 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/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 69bfcb66e..60353921e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -888,6 +888,8 @@ pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result { let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; let alt = switch_or(p, "alt", false, C)?; + // Refused while an object of a dynamic instance's art is selected: the instance stays as it is. + crate::cmd::edit::refuse_symbol_part(s)?; let st = s.doc()?; let color = match color_param(p, "color", C)? { Some(c) => crate::cmd::paint::new_color(s, p, c), diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index d54238925..bcaa59426 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with}; -use super::edit::{duplicate_in, outermost, selected_roots}; +use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots}; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets, @@ -730,8 +730,13 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result { if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) { return Err(bad(C, "pins must be finite")); } - let ids = - if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) }; + // While an object of a dynamic symbol instance's art is selected, the selection is refused, as + // `selected_roots` refuses it. + let selected = || { + refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + }; + let ids = if rest { puppet_ids(p, selected)? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) }; let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); let angles: Vec> = match p.get("angles") { None | Some(Value::Null) => vec![None; pins.len()], diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 21bd4f9bc..8c7de8bdc 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)", + "{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", 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 → {copied, guides}", + "{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 ), @@ -220,7 +220,7 @@ pub fn specs() -> Vec { "Clear", ["Edit"], Some("Delete"), - "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides)", + "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides); without ids, refused while an object of a symbol instance's art is selected", has_selection_or_guides, clear ), @@ -829,16 +829,38 @@ fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate:: } /// Selected top-level objects in paint order, dropping any whose ancestor is also selected. +/// +/// Refused with [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art +/// is selected ([`super::brushsym::selected_part`]): only the paint commands change that object, +/// and only its solid fill and stroke, so a command that gathers the selection here leaves the +/// instance as it is. Copy copies the instance whole ([`copy_cut_targets`]). pub(crate) fn selected_roots(s: &Session) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc))) } +/// [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art is selected: +/// for the commands that read the selected objects without [`selected_roots`]. A command that +/// changes nothing in the document ([`Session::reading`]) reads the instance instead. +pub(crate) fn refuse_symbol_part(s: &Session) -> Result<()> { + if !s.reading && s.doc()?.selection.symbol_part.is_some() { Err(super::brushsym::part_only()) } else { Ok(()) } +} + +/// [`refuse_symbol_part`] as a command's `enabled` check: the command is dimmed, and refused with +/// [`super::brushsym::part_only`]'s message, while an object of a dynamic symbol instance's art is +/// selected. +pub(crate) fn no_symbol_part(s: &Session) -> std::result::Result<(), String> { + if !s.reading && s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { Err(super::brushsym::part_only().to_string()) } else { Ok(()) } +} + /// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a /// path of a compound path selected on its own (Group Selection, or in the isolated compound path) /// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the -/// rest still go to the compound path, which holds the paint. +/// rest still go to the compound path, which holds the paint. Refused while an object of a symbol +/// instance's art is selected, as [`selected_roots`] is: it moves with its instance only. pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_, _| true)) } @@ -847,8 +869,10 @@ pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { /// ([`super::inside_isolation`]): `root` itself and objects outside it are left out before the /// objects held by another selected object are dropped. A selected path of a compound path stands /// for its compound unless the compound is `root` or `members` is set (as in -/// [`selected_roots_to_transform`]). +/// [`selected_roots_to_transform`]). Refused while an object of a symbol instance's art is +/// selected, as [`selected_roots`] is. pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; let ids = st.selection.in_paint_order(&st.doc).into_iter().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect(); Ok(roots_within(&st.doc, ids, |compound, _| members || compound == root)) @@ -925,6 +949,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false); } + // With an object of a dynamic symbol instance's art selected, the instance is copied. let (direct, targets) = copy_cut_targets(s)?; let st = s.doc()?; // Selected ruler guides come along (#3226), as canvas guides: their layer and artboard stay @@ -965,6 +990,7 @@ fn cut(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true); } + refuse_symbol_part(s)?; // Ruler guides selected on their own: Copy takes them, Clear removes them. if s.doc()?.selection.is_empty() { copy(s, p)?; @@ -1038,6 +1064,10 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { fn clear(s: &mut Session, p: &Value) -> Result { let guides = switch_or(p, "guides", false, "edit.clear")?; let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None }; + // An object of a symbol instance's art can't be removed from the instance alone. + if explicit.is_none() && s.doc()?.selection.symbol_part.is_some() { + return Err(super::brushsym::part_only()); + } // Direct-selected anchors or segments: remove those, and any objects selected whole with them. if explicit.is_none() && (!s.doc()?.selection.anchors.is_empty() || !s.doc()?.selection.segments.is_empty()) { return super::path::delete_anchors(s, p); diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 13564614b..0869ad850 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -214,10 +214,11 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom:: } /// The selected objects (their top-most selected ancestors) that are artwork: those on template -/// layers are guides and are left out. +/// layers are guides and are left out. With an object of a dynamic symbol instance's art selected, +/// the instance is exported whole. pub(crate) fn selected_artwork(s: &Session) -> Result> { - let ids = edit::selected_roots(s)?; let st = s.doc()?; + let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)); let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 21b5245ae..0f72b066a 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -92,8 +92,12 @@ impl Session { /// The freeform gradient behind the Fill (`stroke` false) or Stroke proxy, its points in /// document coordinates, and the length its spreads are fractions of there (see - /// [`Node::proxy_freeform`]). + /// [`Node::proxy_freeform`]). `None` while an object of a symbol instance is selected + /// ([`vectorcraft_doc::Selection::symbol_part`]): its points are in the symbol's coordinates. pub fn proxy_freeform(&self, stroke: bool) -> Option<(Freeform, f64)> { + if self.active()?.selection.symbol_part.is_some() { + return None; + } let (n, item) = self.proxy_source(stroke)?; n.proxy_freeform(stroke, item) } diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 2b0e214cc..ec0778364 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -354,8 +354,10 @@ fn close(a: f64, b: f64) -> bool { } /// The members a Global Edit started on the one selected object would take, with `o`: that -/// object first, then the others in paint order. +/// object first, then the others in paint order. Refused while an object of a dynamic symbol +/// instance's art is selected. fn matches(s: &Session, o: &Options) -> Result> { + super::edit::refuse_symbol_part(s)?; let st = s.doc()?; let d = &st.doc; let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) }; @@ -423,6 +425,7 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> { if st.global_edit.is_some() { return Ok(()); } + super::edit::no_symbol_part(s)?; let [id] = st.selection.objects.as_slice() else { return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); }; diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 47989a29c..c73a23d6c 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Select Gradient Stop", [], None, - "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on → {index}", + "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on; refused while an object of a symbol instance's art is selected → {index}", always, select_stop ), @@ -59,6 +59,10 @@ pub(crate) fn active_gradient(s: &Session) -> Option { fn select_stop(s: &mut Session, p: &Value) -> Result { const C: &str = "gradient.selectStop"; + // No document is needed for the default paint's gradient, so this does not use `refuse_symbol_part`. + if s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { + return Err(super::brushsym::part_only()); + } let index = match p.get("index") { None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")), Some(Value::Null) => None, diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index 1ea621c63..c33be78d5 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -58,12 +58,16 @@ pub(super) fn create(s: &mut Session, p: &Value) -> Result { } /// The graph `id` names (or the graph containing it), else the selected graph (or the graph -/// containing the selection). +/// containing the selection). Refused while an object of a dynamic symbol instance's art is +/// selected. pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result { let st = s.doc()?; let start = match named_id(p)? { Some(id) => vec![id], - None => st.selection.objects.clone(), + None => { + crate::cmd::edit::refuse_symbol_part(s)?; + st.selection.objects.clone() + } }; for id in start { let mut cur = Some(id); diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index b19206c39..9841d2abb 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -76,8 +76,9 @@ pub fn specs() -> Vec { // ---------- predicates ---------- /// The Intertwine groups of the selection: selected ones, and those around a selected object. +/// None while an object of a dynamic symbol instance's art is selected. fn selected_groups(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { let group = st.doc.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| st.doc.node(*a).is_some_and(is_intertwine)); @@ -92,6 +93,7 @@ fn selected_groups(s: &Session) -> Vec { fn has_intertwine(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_groups(s).is_empty() { Err("select an Intertwine group".into()) } else { Ok(()) } } @@ -378,11 +380,14 @@ fn arrange(s: &mut Session, p: &Value) -> Result { } id } - None => match selected_groups(s).as_slice() { - [g] => *g, - [] => return Err(bad(C, "select an Intertwine group")), - _ => return Err(bad(C, "select one Intertwine group")), - }, + None => { + super::edit::refuse_symbol_part(s)?; + match selected_groups(s).as_slice() { + [g] => *g, + [] => return Err(bad(C, "select an Intertwine group")), + _ => return Err(bad(C, "select one Intertwine group")), + } + } }; let at = match p.get("at") { Some(_) => Some(point_param(p, "at").filter(|q| q.x.is_finite() && q.y.is_finite()).ok_or_else(|| bad(C, "at must be [x, y]"))?), diff --git a/crates/engine/src/cmd/live/common.rs b/crates/engine/src/cmd/live/common.rs index 226a57a0d..80c9d714a 100644 --- a/crates/engine/src/cmd/live/common.rs +++ b/crates/engine/src/cmd/live/common.rs @@ -33,9 +33,10 @@ pub(super) fn roots_param(s: &Session, p: &Value) -> Result> { selected_roots(s) } -/// Selected nodes of a kind (the selection itself or an ancestor). +/// Selected nodes of a kind (the selection itself or an ancestor). None while an object of a +/// dynamic symbol instance's art is selected. pub(super) fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { @@ -64,16 +65,19 @@ pub(super) fn is_mesh(n: &Node) -> bool { pub(super) fn has_blend(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_blend).is_empty() { Err("select a blend".into()) } else { Ok(()) } } pub(super) fn has_envelope(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_envelope).is_empty() { Err("select an envelope".into()) } else { Ok(()) } } pub(super) fn has_mesh(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_mesh).is_empty() { Err("select a gradient mesh".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 01721471c..f2d322a39 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode and in pattern editing mode → {layer}", - can_edit_mask, + can_enter, enter ), cmd!( @@ -79,6 +79,13 @@ pub(crate) fn can_edit_mask(s: &Session) -> std::result::Result<(), String> { mask_editing_refused(st).map_or(Ok(()), |why| Err(why.into())) } +/// Edit Opacity Mask: mask editing can be entered ([`can_edit_mask`]), and outside it no object of a +/// dynamic symbol instance's art is selected, since its instance's mask is not edited from it. +fn can_enter(s: &Session) -> std::result::Result<(), String> { + can_edit_mask(s)?; + if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) } +} + /// A document is open and no opacity mask is being edited: isolation mode and pattern editing mode /// are not entered while an opacity mask is edited. pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> { @@ -117,12 +124,17 @@ fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.mask_edit.is_some() { return Err(bad(C, "already editing an opacity mask")); } - let id = object_param(p, "id", C)?.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let given = object_param(p, "id", C)?; + if given.is_none() { + super::edit::refuse_symbol_part(s)?; + } + let id = given.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; let layer = start(s, id)?; Ok(json!({ "layer": layer.0 })) } -/// The first selected object with an opacity mask. +/// The first selected object with an opacity mask. Callers refuse first while an object of a +/// dynamic symbol instance's art is selected ([`super::edit::refuse_symbol_part`]). fn first_masked(st: &crate::DocState) -> Option { st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.extras().mask.is_some())) } @@ -145,6 +157,9 @@ fn view_mask(s: &mut Session, p: &Value) -> Result { st.revision += 1; return Ok(json!({ "on": false, "id": id.map(|i| i.0) })); } + if given.is_none() && editing.is_none() { + super::edit::refuse_symbol_part(s)?; + } let id = given.or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; match editing { Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index b400f637a..061f4fb9d 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -242,6 +242,11 @@ pub(super) fn transform(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?; check_switches(p, &["anchors", "releaseOutside"], "object.transform")?; let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; + // An object selected in a symbol instance's art stays where the symbol puts it: the selection + // is left as it is, and the reply's warning goes to the status bar. + if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() { + return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() })); + } let (ids, anchors) = match ids_param(p, "ids") { Some(v) => (outermost(&s.doc()?.doc, v), false), None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index e2bd0957d..93e2bd929 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -82,9 +82,10 @@ fn groups_of(d: &Document, ids: &[NodeId]) -> Vec { out } -/// The Objects on Path groups of the selection ([`groups_of`]). +/// The Objects on Path groups of the selection ([`groups_of`]). None while an object of a dynamic +/// symbol instance's art is selected. fn selected_groups(s: &Session) -> Vec { - s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default() + s.active().filter(|st| st.selection.symbol_part.is_none()).map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default() } /// The groups a command acts on: those of `ids` when given, otherwise those of the selection. @@ -102,6 +103,7 @@ fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result> { fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 7089770e4..b8b0138c5 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?|ids?} put the mask art back above each masked object (default: the selection, or the object whose mask is being edited, which leaves mask editing)", - has_doc, + can_change_mask, release ), cmd!( @@ -43,25 +43,22 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?|ids?} keep the mask but stop applying it", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Disable Opacity Mask", &json!({ "disabled": true })) ), - cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags( - s, - p, - "Enable Opacity Mask", - &json!({ "disabled": false }) - )), + cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| { + set_flags(s, p, "Enable Opacity Mask", &json!({ "disabled": false })) + }), cmd!( "transparency.unlinkOpacityMask", "Unlink Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?} the object moves without its mask", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Unlink Opacity Mask", &json!({ "linked": false })) ), - cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags( + cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| set_flags( s, p, "Link Opacity Mask", @@ -73,7 +70,7 @@ pub fn specs() -> Vec { [], None, "{id?|ids?, clip?, invert?, disabled?, linked?} change the opacity masks of `ids`, the selection, or the object whose mask is being edited", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Opacity Mask Options", p) ), cmd!( @@ -152,9 +149,21 @@ pub(crate) fn percent(o: f64) -> f32 { (o / 100.0).clamp(0.0, 1.0) as f32 } -/// The [`transparency_targets`] that have an opacity mask. +/// Refuses a command that would change [`transparency_targets`] when they are the selection, without +/// `id` or `ids` and outside mask editing, while an object of a dynamic symbol instance's art is +/// selected: the instance's transparency and mask change only when the instance is selected. +pub(crate) fn refuse_symbol_part_unnamed(s: &Session, p: &Value) -> Result<()> { + if p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_none() { + super::edit::refuse_symbol_part(s)?; + } + Ok(()) +} + +/// The [`transparency_targets`] that have an opacity mask, refused as +/// [`refuse_symbol_part_unnamed`] says. fn masked_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; + refuse_symbol_part_unnamed(s, p)?; let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.extras().mask.is_some())).collect(); if ids.is_empty() { return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into())); @@ -162,6 +171,13 @@ fn masked_targets(s: &Session, p: &Value) -> Result> { Ok(ids) } +/// The commands that change an existing opacity mask: a document is open, and outside mask editing +/// no object of a dynamic symbol instance's art is selected ([`masked_targets`]). +fn can_change_mask(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) } +} + /// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty /// mask and takes into mask editing, is where mask editing can be entered /// ([`super::maskedit::can_edit_mask`]: not in isolation mode or pattern editing mode). The diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index bbb5f35d3..53a592492 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1,5 +1,7 @@ //! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. +use std::borrow::Cow; + use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; @@ -19,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -28,7 +30,7 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke", + "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused", has_doc, |s, p| { set_paint(s, p, false) } ), @@ -86,7 +88,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with nothing selected, invert the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -95,7 +97,7 @@ pub fn proxy_specs() -> Vec { "Complement", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item as paint.invert does; with nothing selected, the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model()) ), @@ -172,7 +174,7 @@ impl Session { /// new art. pub fn proxy_paints(&self) -> (Paint, Paint) { let show = |stroke: bool| match self.proxy_source(stroke) { - Some((n, item)) => proxy_paint(n, stroke, item), + Some((n, item)) => proxy_paint(&n, stroke, item), None if stroke => self.paint.stroke.clone(), None => self.paint.fill.clone(), }; @@ -180,17 +182,22 @@ impl Session { } /// The object whose fill (or stroke) the proxy shows, with the Appearance panel's active item - /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. - pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option)> { + /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. With + /// an object of a dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that + /// object as the instance draws it. + pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(Cow<'_, Node>, Option)> { let st = self.active()?; + if let Some(part) = st.selected_symbol_part() { + return Some((Cow::Owned(part), None)); + } let first = st.doc.node(*st.selection.subjects().first()?)?; let item = self.appearance_item(); if first.appearance.item_of_kind(item, !stroke).is_some() { - return Some((first, item)); + return Some((Cow::Borrowed(first), item)); } let mut nodes = vec![]; painted(first, true, &mut nodes); - nodes.first().map(|n| (*n, None)) + nodes.first().map(|n| (Cow::Borrowed(*n), None)) } pub(crate) fn remember_paint_now(&mut self, p: &Paint) { @@ -257,6 +264,9 @@ impl DocState { if self.selection.is_empty() { return (false, false); } + if self.selection.symbol_part.is_some() { + return (false, false); + } let ids: std::collections::HashSet = self.selection.objects.iter().copied().collect(); let (mut selected, mut nodes) = (vec![], vec![]); self.doc.walk(|n| { @@ -275,6 +285,18 @@ impl DocState { }; (differ(false), differ(true)) } + + /// The object of a dynamic symbol instance's art selected with Direct Selection + /// ([`vectorcraft_doc::Selection::symbol_part`]), with the instance's own appearance changes + /// applied, in the symbol's coordinates. `None` without one. + pub(crate) fn selected_symbol_part(&self) -> Option { + let sp = self.selection.symbol_part.as_ref()?; + let inst = self.doc.node(sp.instance)?; + let NodeKind::SymbolInstance { symbol, .. } = &inst.kind else { return None }; + let sym = self.doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst); + vectorcraft_doc::symbol_overrides::part(&art, &sp.path).cloned() + } } impl Session { @@ -465,6 +487,26 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) s.remember_paint(&paint); return Ok(json!({"objects": ids.len()})); } + // An object of a dynamic symbol instance's art selected with Direct Selection: a solid color or + // none changes that object for this instance only, and anything else is refused. + if let Some((id, path)) = selected_part_target(s, p, cmd)? { + if !part_takes(&paint) { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, if fill { "Fill Color" } else { "Stroke Color" }, |o| { + if fill { + o.fill = Some(paint.clone()); + } else { + o.stroke = Some(paint.clone()); + } + })?; + paint_new_art(s, &paint, fill); + if focus { + s.fill_active = fill; + } + s.remember_paint(&paint); + return Ok(json!({"objects": 1})); + } // Resolve the requested targets before mutating defaults or the active proxy. let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; @@ -553,15 +595,7 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) }, )?; // Rejected edits leave the next shape's appearance and active proxy untouched. - // New art takes an unplaced gradient so that it fits the new object's own bounds. - if fill { - s.paint.fill = unplaced(&paint); - } else { - if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 { - s.paint.stroke_width = 1.0; - } - s.paint.stroke = unplaced(&paint); - } + paint_new_art(s, &paint, fill); if focus { s.fill_active = fill; } @@ -569,6 +603,39 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) Ok(json!({"objects": ids.iter().collect::>().len()})) } +/// The instance whose object is selected with Direct Selection, and that object's path +/// ([`super::brushsym::selected_part`]), when `p` names no `ids`, `id` or `item`: the paint commands +/// then change that object. A bad `target` or `aligned` is refused as it is for any other selection. +fn selected_part_target(s: &Session, p: &Value, cmd: &str) -> Result)>> { + if p.get("ids").is_some() || p.get("id").is_some() || p.get("item").is_some() { + return Ok(None); + } + let part = super::brushsym::selected_part(s)?; + if part.is_some() { + item_target(s, p, cmd)?; + } + Ok(part) +} + +/// Can an object of a dynamic symbol instance's art selected with Direct Selection take `paint`? A +/// solid color or none can; a gradient or pattern cannot. +pub(crate) fn part_takes(paint: &Paint) -> bool { + matches!(paint, Paint::Solid { .. } | Paint::None) +} + +/// Make `paint` the fill or stroke of new art. New art takes an unplaced gradient so that it fits +/// the new object's own bounds, and a painted stroke on art that had none gets the 1 pt default. +fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) { + if fill { + s.paint.fill = unplaced(paint); + } else { + if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 { + s.paint.stroke_width = 1.0; + } + s.paint.stroke = unplaced(paint); + } +} + /// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets /// would each fit to their own bounds can span them: one with its own vector (`start`/`end`), /// a freeform one or a gradient swatch already has its placement. @@ -824,6 +891,32 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { /// active item of the proxy's kind is recoloured in each selected object's own stack instead. fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { let stroke = stroke_param(s, p, cmd)?; + // An object of a dynamic symbol instance's art selected with Direct Selection: the solid color + // it shows is recolored for this instance only. A gradient or pattern is refused, and so is type + // whose characters differ in that paint, since one change holds one paint. + if let Some((id, path)) = selected_part_target(s, p, cmd)? { + let part = s.doc()?.selected_symbol_part(); + let mut paint = part.as_ref().map_or(Paint::None, |n| proxy_paint(n, stroke, None)); + let runs_differ = match part.as_ref().map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.runs.iter().any(|r| !same_in_proxy(if stroke { &r.style.stroke } else { &r.style.fill }, &paint)), + _ => false, + }; + if runs_differ || !part_takes(&paint) { + return Err(super::brushsym::part_only()); + } + let changed = super::colorcmds::map_paint(&mut paint, f); + if changed { + super::brushsym::edit_part(s, id, &path, label, |o| { + if stroke { + o.stroke = Some(paint.clone()); + } else { + o.fill = Some(paint.clone()); + } + })?; + s.remember_paint(&paint); + } + return Ok(json!({ "changed": changed as usize })); + } let ids = match super::given_objects(s, p, cmd)? { Some(ids) => ids, None => super::edit::selected_roots(s)?, @@ -863,6 +956,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( fn transparency(s: &mut Session, p: &Value) -> Result { const C: &str = "transparency.set"; + super::opacitymask::refuse_symbol_part_unnamed(s, p)?; let ids = super::opacitymask::transparency_targets(s, p)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e0de6bd4a..4aeeed95c 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Move Anchors", [], None, - "{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected)", + "{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected); refused while an object of a symbol instance's art is selected", has_selection, move_anchors ), @@ -226,6 +226,8 @@ fn close(s: &mut Session, p: &Value) -> Result { pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result { let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?); check_switches(p, &["patterns"], "path.moveAnchors")?; + // An object selected in a symbol instance's art stays where the symbol puts it. + super::edit::refuse_symbol_part(s)?; // Objects selected whole move as objects, and their pattern tiles move with them when // `patterns` (default: General › Transform Pattern Tiles) is on. let whole = s.doc()?.selection.whole(); @@ -341,7 +343,7 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { } /// The compound path enclosing `id`, or `id` itself if it is not inside a compound path. -pub(super) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { +pub(crate) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id) } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 614a37d21..1e7f88d2b 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -189,8 +189,10 @@ fn has_selection_or_ids(s: &Session) -> std::result::Result<(), String> { has_doc(s) } +/// The repeats of the selection: selected ones, and those around a selected object. None while +/// an object of a dynamic symbol instance's art is selected. fn selected_repeats(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { @@ -207,6 +209,7 @@ fn selected_repeats(s: &Session) -> Vec { fn has_repeat(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_repeats(s).is_empty() { Err("select a repeat".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 0de33a22c..930559db3 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -534,7 +534,7 @@ impl Session { if st.doc.node(old) != read.job.replaced.as_ref() { return Err(bad(PLACE, "replace: the object to replace was changed or deleted")); } - if st.selection.objects != [old] { + if st.selection.objects != [old] || st.selection.symbol_part.is_some() { self.execute("select.set", &json!({ "ids": [old.0] }))?; } } @@ -565,6 +565,10 @@ fn prepare(s: &Session, p: &Value) -> Result { if corners.iter().flatten().any(|c| !(c.x.abs() <= MAX_COORD && c.y.abs() <= MAX_COORD)) { return Err(bad(PLACE, "at/rect lie outside the canvas")); } + // An object selected in a symbol instance's art can't be swapped, and its instance stays. + if replace { + super::edit::refuse_symbol_part(s)?; + } let st = s.doc()?; let old = match (replace, &st.selection.objects[..]) { (false, _) => None, diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 6593f7a79..dd091fb71 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -139,7 +139,15 @@ impl FilterRun { let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); let may = m.permissions(); let st = s.doc()?; - let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); + // 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") { + Some(ids) => ids, + None => { + super::edit::refuse_symbol_part(s)?; + st.selection.objects.clone() + } + }; // Without `doc.read` a filter is given no objects: it can only add new ones. let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; let parent = st.insertion_parent(); diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index f4996f4ad..00b87c649 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink}; use vectorcraft_color::{Color, Paint, Swatch}; -use vectorcraft_doc::swatches::{node_colors, node_paints}; +use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::colorcmds::{Recolor, Scope}; @@ -99,7 +99,7 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet>) -> read(n, only, &mut nodes); } for m in nodes { - for p in node_paints(m) { + for p in node_paints(m).chain(override_paints(m)) { match p { Paint::Solid { color, swatch, .. } => count(color, swatch.as_deref()), Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| count(&s.color, s.swatch.as_deref())), @@ -522,6 +522,9 @@ fn apply(s: &mut Session, p: &Value) -> Result { }; let row_swatches: Vec> = rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect(); + // While an object of a dynamic symbol instance's art is selected, the selection is refused, as + // `selected_roots` refuses it. + super::edit::refuse_symbol_part(s)?; let ids = s.doc()?.selection.objects.clone(); let group = text_param(p, "group", C)?; if ids.is_empty() && group.is_none() { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 4937ff200..c9df27002 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -343,10 +343,15 @@ impl Session { /// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the /// Stroke proxy picks its paint: the Appearance panel's active item when it is a stroke, else /// the first selected object's (a group's first painted object's) top stroke, type's first - /// run's character stroke; with nothing selected, the next object drawn's - /// ([`Session::new_art_stroke`]). None without a document or the selection's stroke. + /// run's character stroke; with an object of a dynamic symbol instance selected + /// ([`DocState::selected_symbol_part`]), that object's as the instance draws it; with nothing + /// selected, the next object drawn's ([`Session::new_art_stroke`]). None without a document or + /// the selection's stroke. pub fn shown_stroke(&self) -> Option { let st = self.active()?; + if let Some(part) = st.selected_symbol_part() { + return node_stroke(&part); + } let Some(first) = st.selection.objects.first() else { return Some(self.new_art_stroke()) }; let first = st.doc.node(*first)?; if let Some(i) = first.appearance.item_of_kind(self.appearance_item(), false) { @@ -360,13 +365,19 @@ impl Session { impl DocState { /// Which Stroke panel values the selected objects' strokes (type: every run's) don't share, - /// and whether Align Stroke applies to them (no open path among them). One walk of the - /// selection, so callers that ask every frame cache it by revision. + /// and whether Align Stroke applies to them (no open path among them). With an object of a + /// dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that object alone is + /// read. One walk of the selection, so callers that ask every frame cache it by revision. pub fn stroke_mixed(&self) -> StrokeMixed { + let part = self.selected_symbol_part(); let mut nodes = vec![]; - for id in &self.selection.objects { - if let Some(n) = self.doc.node(*id) { - painted(n, true, &mut nodes); + if let Some(part) = &part { + painted(part, true, &mut nodes); + } else { + for id in &self.selection.objects { + if let Some(n) = self.doc.node(*id) { + painted(n, true, &mut nodes); + } } } let mut strokes = vec![]; diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 2864f574b..7b0cad576 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -156,6 +156,9 @@ fn add(s: &mut Session, p: &Value) -> Result { let (apply, add) = (switch_or(p, "apply", false, C)?, switch_or(p, "add", false, C)?); if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)?; + } else if apply { + // A selection that can't take a style is refused before any style is added. + super::appearance::appearance_targets(s, &json!({}))?; } let mut names = names_param(p, C)?; names.extend(text_param(p, "name", C)?.map(str::to_string)); diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 618ef2ee3..de262cf2b 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -78,6 +78,10 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { (name, xf, ids.first().copied()) } None => { + // Refused while an object of a dynamic instance's art is selected, unless `name` is given. + if str_param(p, "name").is_none() && st.selection.symbol_part.is_some() { + return Err(super::brushsym::part_only()); + } let picked = if str_param(p, "name").is_some() { vec![] } else { selected_instances(st) }; let symbols: Vec<(String, Affine)> = picked.iter().filter_map(|id| instance_of(&st.doc, *id)).collect(); match symbols.first() { diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index a115498e1..2224ddccc 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -73,6 +73,11 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { } } +/// Journaled commands that write a file and change nothing in the document: with an object of a +/// dynamic symbol instance's art selected, they read the instance as queries do +/// ([`crate::Session::execute`]). +pub(crate) const JOURNALED_READS: &[&str] = &["css.export"]; + /// Commands that act on the selection and take `ids` by selecting them first (`select.set`, with /// its checks), then running as on the selection: the result and the selection afterwards are the /// menu command's (#2130). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 399a4ed11..79aa06891 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -164,6 +164,10 @@ pub struct Session { /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest /// history step takes the colors the batch finally left. pub(crate) batch_paint_docs: Vec, + /// 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. + pub(crate) reading: bool, /// While [`Session::place_read`] runs `file.place`: the file read for it, which the command /// places instead of reading the file; `None` otherwise. pub(crate) place_hand_off: Option, diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1eaf15b88..27976ff51 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,4 +1,4 @@ -use super::command_sets::{SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; +use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; use super::doc_state::DocState; use super::error::{EngineError, Result}; use super::history::NamedStep; diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index ec9fdbac1..d9ef5740b 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -81,6 +81,7 @@ impl Session { batch_stash: None, batch_steps: None, batch_paint_docs: Vec::new(), + reading: false, place_hand_off: None, queue_hand_off: None, place_queue_roots: None, diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index da285eb3d..e3fae1030 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -340,7 +340,7 @@ impl Session { preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0), ); st.keep_interaction(it); - if preview.0 == "object.transform" { + if preview.0 == "object.transform" && changed { let m = cmd::matrix_param(&preview.1, "matrix"); if let Some(m) = m { // The options are the last preview's params with the values it noted, without the diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 21822583d..71f90e7e4 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,5 +1,6 @@ use super::{ - CommandSpec, EngineError, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, strip_replay_only, + CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, + strip_replay_only, }; impl Session { @@ -35,6 +36,13 @@ impl Session { /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; + let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id)); + let r = self.execute_spec(spec, id, params); + self.reading = outer; + r + } + + fn execute_spec(&mut self, spec: &'static CommandSpec, id: &str, params: &Value) -> Result { // A key the command's params description doesn't name is a parameter error (#1442), before // anything runs, for the commands other commands run too. A `command.batch` or an // `action.batch` checks its steps before the first one runs, and each step is checked again diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 1c46d5302..9ec00cd24 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -1,10 +1,14 @@ //! Dynamic symbols (#4938): an instance changes the appearance of objects in its symbol's art and -//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. +//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. Direct +//! Selection picks one path, compound path or type object of an instance's art, and a solid color +//! Fill or Stroke changes it. use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; +use crate::tooling::{UiRequest, ViewInfo}; use crate::*; fn session() -> Session { @@ -46,6 +50,9 @@ fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] { const RED: [u8; 4] = [255, 0, 0, 255]; const BLUE: [u8; 4] = [0, 0, 255, 255]; +/// The refusal of anything but a solid fill or stroke on a picked object says so. +const REFUSED: &str = "solid color fill and stroke"; + #[test] fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() { let mut s = session(); @@ -58,6 +65,10 @@ fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() { assert_eq!(pixel(&s, 80, 30), RED, "the changed object"); assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); assert_eq!(overrides(&s, other), 0, "other instances keep the symbol's look"); + // The same value again records nothing. + let undo = undo_steps(&s); + assert_eq!(s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap()["overrides"], 1); + assert_eq!(undo_steps(&s), undo, "no undo step"); // A later change to the same object merges with the first; null takes one back. s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "opacity": 50})).unwrap(); assert_eq!(node(&s, inst).extras().symbol_overrides[0].opacity, Some(0.5)); @@ -176,6 +187,666 @@ fn break_link_bakes_the_changes_and_edit_symbol_shows_the_symbols_own_art() { assert_eq!(overrides(&s, other), 1, "the instance keeps its changes"); } +/// Press the Direct Selection tool at (`x`, `y`), drag to `to` and release, with `m` held: what the +/// drag asked of the UI. +fn direct(s: &mut Session, (x, y): (f64, f64), to: (f64, f64), m: Mods) -> Vec { + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("directSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, x, y).with_mods(m), v).unwrap(); + let mut out = vec![]; + if to != (x, y) { + out = s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m), v).unwrap(); + out +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The fills of the symbol's own art. +fn symbol_fills(s: &Session) -> Vec { + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + art.children().unwrap().iter().map(|c| c.appearance.fill_paint().color().unwrap().to_hex()).collect() +} + +#[test] +fn direct_selection_paints_one_object_of_one_dynamic_instance() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.objects, [inst]); + assert_eq!(sel.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] })); + let undo = undo_steps(&s); + // The Swatches panel, the Color panel and the Control bar set the fill with paint.setFill. + assert_eq!(s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap()["objects"], 1); + assert_eq!(undo_steps(&s), undo + 1, "one undo step"); + assert_eq!(pixel(&s, 80, 30), RED, "the picked object"); + assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); + assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look"); + assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"], "the symbol's art is unchanged"); + assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#ff0000".into()), "new art takes the fill"); + // The same paint again records nothing. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(undo_steps(&s), undo + 1); + // Stroke goes to the same object. + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(s.paint.stroke.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the stroke"); + assert!(s.paint.stroke_width > 0.0, "a painted stroke has a weight"); + let o = node(&s, inst).extras().symbol_overrides[0].clone(); + assert_eq!(o.path, vec![1]); + assert!(o.fill.is_some() && o.stroke.is_some()); + // The Eyedropper's click on another object gives the selected object its paint. + let green = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap()["ids"], json!([inst.0])); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255], "the eyedropper's paint"); + assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"]); + for _ in 0..7 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(overrides(&s, inst), 0, "undo takes each change back"); + assert_eq!(pixel(&s, 80, 30), BLUE); +} + +#[test] +fn a_drag_of_a_picked_object_moves_nothing_and_says_why() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let (before, undo) = (node(&s, inst).kind.clone(), undo_steps(&s)); + let ui = direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default()); + assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("symbol instance"))), "{ui:?}"); + assert_eq!(node(&s, inst).kind, before, "the instance stays put"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + assert!(s.execute("object.nudge", &json!({"dx": 1})).is_err(), "the arrow keys too"); + assert!(s.execute("object.rotate", &json!({"angle": 10})).is_err()); + // The Selection tool picks the instance whole again, and it moves. + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + s.execute("object.nudge", &json!({"dx": 1})).unwrap(); + assert_ne!(node(&s, inst).kind, before); +} + +/// The Stroke panel and the Control bar show the stroke of the object picked in a dynamic instance. +#[test] +fn the_stroke_panel_shows_a_picked_objects_stroke() { + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("paint.setStroke", &json!({"ids": [a.0, b.0], "color": "#00ff00"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [a.0], "weight": 2})).unwrap(); + s.execute("stroke.set", &json!({"ids": [b.0], "weight": 7})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, b.0], "type": "dynamic"})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(7.0), "the picked object's weight"); + assert!(!s.doc().unwrap().stroke_mixed().weight, "the instance's art has two weights, the object one"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap(); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(2.0)); +} + +#[test] +fn delete_and_cut_of_a_picked_object_remove_nothing() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + for cmd in ["edit.cut", "edit.clear"] { + let e = s.execute(cmd, &json!({})).unwrap_err().to_string(); + assert!(e.contains("symbol instance"), "{cmd}: {e}"); + assert!(s.doc().unwrap().doc.node(inst).is_some(), "{cmd} keeps the instance"); + } + assert_eq!(undo_steps(&s), undo, "no undo step"); + // Named by id, the instance is removed. + s.execute("edit.clear", &json!({"ids": [inst.0]})).unwrap(); + assert!(s.doc().unwrap().doc.node(inst).is_none()); +} + +#[test] +fn commands_on_the_selection_leave_a_picked_objects_instance_alone() { + // A dynamic instance with an opacity mask, a plain square and the Desaturate plug-in, with an + // object of the instance picked. + let setup = || { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = square(&mut s, 150.0); + let desaturate = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); + let plugin = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(desaturate)})).unwrap(); + let mask = square(&mut s, 20.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [inst.0, mask.0]})).unwrap(); + assert!(node(&s, inst).extras().mask.is_some()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + (s, inst, other, plugin["id"].clone()) + }; + let (mut s, inst, other, plugin) = setup(); + let doc = s.doc().unwrap().doc.clone(); + // Each command runs on its own copy of the setup, compared with that copy as it was, so every + // command that fails is reported. + let mut failures = Vec::new(); + for (cmd, p) in [ + ("object.puppetWarp", json!({"pins": [[80, 30]], "moved": [[90, 40]], "rest": true})), + ("recolor.apply", json!({"map": {"#0000ff": "#00ff00"}})), + ("plugin.run", json!({"id": plugin, "params": {"amount": 100}})), + ("object.group", json!({})), + ("object.ungroup", json!({})), + ("object.hide", json!({})), + ("object.lock", json!({})), + ("object.arrange.bringToFront", json!({})), + ("object.expand", json!({})), + ("object.rotate", json!({"angle": 45})), + ("symbol.instance.reset", json!({})), + ("symbol.instance.setAppearance", json!({"path": [1], "opacity": 50})), + ("symbol.adjust", json!({"tool": "shift", "points": [[80, 30], [95, 30]], "radius": 200})), + ("symbol.edit", json!({})), + ("select.globalEdit", json!({})), + ("object.blend.release", json!({})), + ("object.repeat.release", json!({})), + ("object.intertwine.release", json!({})), + ("object.objectsOnPath.expand", json!({})), + ("transparency.set", json!({"opacity": 50})), + ("transparency.set", json!({"blend": "multiply"})), + ("transparency.releaseOpacityMask", json!({})), + ("transparency.setOpacityMask", json!({"invert": true, "clip": false})), + ("transparency.disableOpacityMask", json!({})), + ("transparency.unlinkOpacityMask", json!({})), + ("transparency.editOpacityMask", json!({})), + ("transparency.viewOpacityMask", json!({"on": true})), + ("paint.editGradient", json!({"angle": 30})), + ("paint.setGradientGeom", json!({"start": [62, 30], "end": [98, 30]})), + ("paint.swap", json!({})), + ("stroke.set", json!({"weight": 3})), + ("path.moveAnchors", json!({"dx": 5, "dy": 5})), + ( + "file.place", + json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(b""), "replace": true}), + ), + ("object.intertwine.arrange", json!({"at": [80, 30]})), + ("transparency.editOpacityMask", json!({"id": null})), + ("transparency.viewOpacityMask", json!({"on": true, "id": null})), + ] { + let (mut t, ..) = setup(); + let (doc, undo) = (t.doc().unwrap().doc.clone(), undo_steps(&t)); + match t.execute(cmd, &p) { + Ok(r) => failures.push(format!("{cmd}: ran ({r})")), + Err(e) if !e.to_string().contains("symbol instance") => failures.push(format!("{cmd}: {e}")), + Err(_) => {} + } + if t.doc().unwrap().selection.symbol_part.is_none() { + failures.push(format!("{cmd}: the object is no longer picked")); + } + if t.doc().unwrap().doc != doc { + failures.push(format!("{cmd}: the document changed")); + } + if undo_steps(&t) != undo { + failures.push(format!("{cmd}: an undo step was added")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + // Copy copies the instance whole. + s.execute("edit.copy", &json!({})).unwrap(); + assert!(matches!(&s.clipboard.nodes[..], [n] if matches!(n.kind, NodeKind::SymbolInstance { .. }))); + // The picked object's paint still changes. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_some()); + // A command given ids acts on them. + s.execute("object.rotate", &json!({"ids": [other.0], "angle": 45})).unwrap(); + assert_ne!(node(&s, other).kind, doc.node(other).unwrap().kind, "the named object turns"); + s.execute("transparency.setOpacityMask", &json!({"id": inst.0, "invert": true})).unwrap(); + assert!(node(&s, inst).extras().mask.as_ref().is_some_and(|m| m.invert), "the named instance's mask changes"); +} + +#[test] +fn the_commands_without_a_check_for_a_picked_object_use_its_instance() { + let setup = || { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + (s, inst) + }; + // Export Selection: the whole instance, x 10..100, and not the object at 60..100. + let (mut s, _) = setup(); + let r = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap(); + assert_eq!(r["bounds"], json!([10.0, 10.0, 90.0, 40.0])); + // Fit to Selection: the whole instance too. + let (mut s, _) = setup(); + s.execute("artboard.fitToSelection", &json!({})).unwrap(); + let r = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((r.x0, r.x1), (10.0, 100.0)); + // Liquify: the instance is its target, skipped and left as it is. + let (mut s, inst) = setup(); + let doc = s.doc().unwrap().doc.clone(); + let r = s.execute("object.liquify", &json!({"tool": "pucker", "points": [[80, 30]], "diameter": 150, "intensity": 1})).unwrap(); + assert_eq!((&r["ids"], &r["skipped"]), (&json!([]), &json!([inst.0]))); + assert!(s.doc().unwrap().doc == doc); + // Isolate: the instance, with the selection cleared. + let (mut s, inst) = setup(); + s.execute("object.isolate", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(inst)); + assert!(s.doc().unwrap().selection.is_empty()); + // Add to Library: a copy of the whole instance, 90 pt wide, and not the object's paint. + let (mut s, _) = setup(); + let lib = s.execute("library.add", &json!({"kind": "graphic"})).unwrap()["library"].clone(); + let got = s.execute("library.get", &json!({"library": lib})).unwrap(); + assert_eq!(got["graphics"][0]["width"], 90.0); + assert_ne!(s.execute("library.add", &json!({"kind": "fillColor"})).unwrap()["name"], "#ff0000"); + // Save Selection: the instance, which Recall selects whole. + let (mut s, inst) = setup(); + s.execute("select.save", &json!({"name": "Saved"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.saved_selections[0].objects, vec![inst]); + s.execute("select.recall", &json!({"name": "Saved"})).unwrap(); + let sel = &s.doc().unwrap().selection; + assert_eq!((&sel.objects, &sel.symbol_part), (&vec![inst], &None)); +} + +#[test] +fn a_blend_around_a_picked_objects_instance_is_left_alone() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = square(&mut s, 150.0); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [inst.0, other.0], "steps": 1})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let (doc, undo) = (s.doc().unwrap().doc.clone(), undo_steps(&s)); + let e = s.execute("object.blend.release", &json!({})).unwrap_err().to_string(); + assert!(e.contains("symbol instance"), "{e}"); + assert!(s.doc().unwrap().doc == doc, "the blend stays"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + // With the blend selected, it is released. + s.execute("select.set", &json!({"ids": [blend.0]})).unwrap(); + s.execute("object.blend.release", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(blend).is_none()); +} + +/// Dynamic symbol "Photo" of a gray 2 × 1 image and a blue square, made in place: its instance and +/// the image's path in the art. +fn image_symbol(s: &mut Session) -> (NodeId, Value) { + let mut png = Vec::new(); + let pixels = image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()); + pixels.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let im = s + .edit("Place", |d, _| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 40, + height: 40, + xf: vectorcraft_geom::Affine::translate((10.0, 10.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + Ok(id) + }) + .unwrap(); + let sq = square(s, 60.0); + let inst = id(&s.execute("symbol.new", &json!({"name": "Photo", "ids": [im.0, sq.0], "type": "dynamic"})).unwrap()); + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + let i = art.children().unwrap().iter().position(|c| matches!(c.kind, NodeKind::Image(_))).unwrap(); + (inst, json!([i])) +} + +#[test] +fn only_paths_compound_paths_and_type_of_the_art_are_picked() { + let mut s = session(); + // A compound path, type, a blend and a static symbol's instance beside a path, in a group. + let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0)); + let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap()); + let text = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + let (c, d) = (square(&mut s, 60.0), square(&mut s, 150.0)); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [c.0, d.0], "steps": 1})).unwrap()); + let inner = pair(&mut s, "static"); + let plain = square(&mut s, 100.0); + let group = id(&s.execute("object.group", &json!({"ids": [plain.0]})).unwrap()); + let ids = [compound.0, text.0, blend.0, inner.0, group.0]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Mixed", "ids": ids, "type": "dynamic"})).unwrap()); + let art = s.doc().unwrap().doc.symbols.iter().find(|y| y.name == "Mixed").unwrap().art.clone(); + let at = |kind: &dyn Fn(&NodeKind) -> bool| art.children().unwrap().iter().position(|c| kind(&c.kind)).unwrap(); + let compound = at(&|k| matches!(k, NodeKind::Compound { .. })); + let text = at(&|k| matches!(k, NodeKind::Text(_))); + let blend = at(&|k| matches!(k, NodeKind::Blend { .. })); + let inner = at(&|k| matches!(k, NodeKind::SymbolInstance { .. })); + let group = at(&|k| matches!(k, NodeKind::Group { .. })); + for path in [json!([compound]), json!([text]), json!([group, 0])] { + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + } + assert_eq!(overrides(&s, inst), 3); + let undo = undo_steps(&s); + s.execute("select.none", &json!({})).unwrap(); + for path in [json!([]), json!([group]), json!([blend]), json!([inner])] { + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{path}: {e}"); + } + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked"); + assert_eq!((overrides(&s, inst), undo_steps(&s)), (3, undo), "nothing changes"); +} + +/// A Direct Selection press on an image of the art is refused with the status message, and a drag +/// from it does not move what was selected. +#[test] +fn a_press_on_an_image_of_the_art_is_refused_and_moves_nothing() { + let mut s = session(); + let (inst, path) = image_symbol(&mut s); + let other = square(&mut s, 150.0); + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + let before = node(&s, other).kind.clone(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("directSelection", v).unwrap(); + let e = s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 30.0), v).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 60.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 60.0), v).unwrap(); + assert_eq!(node(&s, other).kind, before, "what was selected stays put"); + assert_eq!(s.doc().unwrap().selection.objects, [other], "and stays selected"); + assert_eq!(overrides(&s, inst), 0); +} + +/// A gradient or pattern for a picked object is refused and changes nothing, new art's paint +/// included. An object that shows a gradient takes a solid color. +#[test] +fn gradients_and_patterns_on_a_picked_object_are_refused() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("swatch.new", &json!({"name": "Ramp", "gradient": g})).unwrap(); + let tile = square(&mut s, 150.0); + s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap(); + // An object to sample a gradient from. + let far = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [far.0], "gradient": g})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone()); + for (cmd, p) in [ + ("paint.setFill", json!({"gradient": g})), + ("paint.setStroke", json!({"gradient": g})), + ("paint.setFill", json!({"gradient": g, "span": "selection"})), + ("paint.setFill", json!({"swatch": "Ramp"})), + ("paint.setFill", json!({"swatch": "Dots"})), + ("appearance.copyFrom", json!({"source": far.0})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd} {p}: {e}"); + } + assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes"); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "new art keeps its paint"); + // An object whose art has a gradient takes a solid color; Invert, Complement and selecting one + // of its stops are refused. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": {"gradient": g}})).unwrap(); + for cmd in ["paint.invert", "paint.complement"] { + let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd}: {e}"); + } + s.fill_active = true; + for index in [json!(1), Value::Null] { + let e = s.execute("gradient.selectStop", &json!({"index": index})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "gradient.selectStop {index}: {e}"); + } + assert_eq!(s.selected_stop(), None); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + // The Eyedropper's options can leave the gradient out: the fill stays. + s.execute("appearance.copyFrom", &json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}}}})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + // Options that pick up neither color leave nothing to change, so the click is refused. + let (undo, fill) = (undo_steps(&s), s.paint.fill.clone()); + let neither = json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}, "stroke": {"color": false}}}}); + let e = s.execute("appearance.copyFrom", &neither).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + assert_eq!((undo_steps(&s), &s.paint.fill), (undo, &fill), "nothing changes"); +} + +#[test] +fn select_part_refuses_hidden_and_locked_objects_of_the_art() { + let mut s = session(); + let (a, b, c) = (square(&mut s, 10.0), square(&mut s, 60.0), square(&mut s, 110.0)); + let group = id(&s.execute("object.group", &json!({"ids": [b.0, c.0]})).unwrap()); + let inst = id(&s.execute("symbol.new", &json!({"name": "Three", "ids": [a.0, group.0], "type": "dynamic"})).unwrap()); + // The art's first square is hidden and its group locked. + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + let children = art.children_mut().unwrap(); + std::sync::Arc::make_mut(&mut children[0]).visible = false; + std::sync::Arc::make_mut(&mut children[1]).locked = true; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + for path in [json!([0]), json!([1]), json!([1, 0])] { + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "{path}: {e}"); + } + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked"); + // With the art's group unlocked, a locked instance's objects are still not picked. + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + std::sync::Arc::make_mut(&mut art.children_mut().unwrap()[1]).locked = false; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + s.execute("object.lock", &json!({"ids": [inst.0]})).unwrap(); + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "locked instance: {e}"); + s.execute("object.unlockAll", &json!({})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap(); +} + +#[test] +fn choosing_the_selection_tool_again_picks_the_instance_whole() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + let before = node(&s, inst).kind.clone(); + let revision = s.doc().unwrap().revision; + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert_ne!(s.doc().unwrap().revision, revision, "panels cached by revision read the instance"); + s.execute("object.nudge", &json!({"dx": 1})).unwrap(); + assert_ne!(node(&s, inst).kind, before, "the instance moves"); +} + +#[test] +fn the_eyedropper_sample_taken_for_a_picked_object_is_kept() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let green = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap(); + let target = square(&mut s, 150.0); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap(); + assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the sample's fill"); + // A reverse Eyedropper click with nothing selected applies that sample. + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute("appearance.copyFrom", &json!({"source": target.0, "reverse": true})).unwrap()["ids"], json!([target.0])); + assert_eq!(node(&s, target).appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); +} + +#[test] +fn invert_and_complement_recolor_a_picked_object_for_its_instance_only() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + let r = s.execute("paint.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert_eq!(undo_steps(&s), undo + 1, "one undo step"); + assert_eq!(symbol_part_fill(&s, inst), "#ffff00", "the picked object's own blue, inverted"); + assert_eq!(overrides(&s, inst), 1, "only the picked object changes"); + assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look"); + assert!(*s.doc().unwrap().doc.symbols[0].art == *art, "the symbol's art stays"); + assert_eq!(pixel(&s, 80, 30), [255, 255, 0, 255], "the changed object"); + assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); + s.execute("paint.complement", &json!({"stroke": false})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#0000ff", "the complement of yellow"); + assert_eq!(undo_steps(&s), undo + 2, "one undo step"); +} + +/// One change holds one paint, so Invert and Complement are refused for type whose characters +/// differ in color, and each character's color stays. A fill gives all of them one color. +#[test] +fn invert_and_complement_are_refused_for_picked_type_whose_characters_differ() { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [t.0], "color": "#ff0000"})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap()); + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + let NodeKind::Text(tx) = &mut art.kind else { panic!("type: {art:?}") }; + let mut second = tx.runs[0].clone(); + second.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + tx.runs.push(second); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap(); + let undo = undo_steps(&s); + for cmd in ["paint.invert", "paint.complement"] { + let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd}: {e}"); + } + assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes"); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("paint.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#ff00ff", "one color inverts"); +} + +/// The color of the fill instance `inst`'s first change sets. +fn symbol_part_fill(s: &Session, inst: NodeId) -> String { + let o = node(s, inst).extras().symbol_overrides[0].clone(); + o.fill.as_ref().and_then(|p| p.color()).map(|c| c.to_hex()).unwrap_or_else(|| panic!("a solid fill: {o:?}")) +} + +#[test] +fn a_stroke_on_a_picked_type_object_without_a_weight_draws() { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + s.execute("paint.setStroke", &json!({"ids": [t.0], "none": true})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let part = s.doc().unwrap().selected_symbol_part().unwrap(); + let NodeKind::Text(tx) = &part.kind else { panic!("type: {part:?}") }; + assert!(tx.runs.iter().all(|r| r.style.stroke_width == 1.0 && !r.style.stroke.is_none()), "{:?}", tx.runs); +} + +/// The colors the Fill and Stroke proxies show, as `paint.proxies` replies them. +fn proxy_colors(s: &mut Session) -> (Option, Option) { + let r = s.execute("paint.proxies", &json!({})).unwrap(); + assert_eq!((r["fillMixed"].clone(), r["strokeMixed"].clone()), (json!(false), json!(false)), "{r}"); + let (fill, stroke) = s.proxy_paints(); + assert_eq!((r["fill"].clone(), r["stroke"].clone()), (json!(fill), json!(stroke))); + (fill.color().map(|c| c.to_hex()), stroke.color().map(|c| c.to_hex())) +} + +#[test] +fn the_fill_and_stroke_proxies_show_the_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None), "the symbol's own paint"); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#ff0000".into()), Some("#00ff00".into())), "the object's own paint"); + // The other object of the same instance shows the symbol's paint. + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None)); +} + +#[test] +fn selecting_a_guide_too_lets_go_of_the_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = s.execute("guide.add", &json!({"vertical": true, "pos": 150})).unwrap()["index"].as_u64().unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("guide.select", &json!({"indexes": [g], "toggle": true})).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.objects, sel.guides.len(), sel.symbol_part), (vec![inst], 1, None)); + // Paint now goes to the instance, not to the object. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(overrides(&s, inst), 0); + // Pruning a selection with a guide in it lets go of the object as well. + let mut sel = s.doc().unwrap().selection.clone(); + sel.symbol_part = Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] }); + sel.prune(&s.doc().unwrap().doc); + assert_eq!(sel.symbol_part, None); +} + +/// A dynamic symbol with 9-slice guides at 25%: a 100 × 100 frame at (50, 50), a 10 × 10 square +/// in its top-left corner and one in its center; its instance resized to 300 × 100. +fn sliced_panel() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let rect = + |s: &mut Session, x: f64, y: f64, w: f64| id(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap()).0; + let ids = [rect(&mut s, 50.0, 50.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0)]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Panel", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap()); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap(); + (s, inst) +} + +#[test] +fn direct_selection_picks_and_outlines_the_object_where_a_9_slice_instance_draws_it() { + let (mut s, inst) = sliced_panel(); + let part_box = |s: &Session, path: &[u32]| { + let d = &s.doc().unwrap().doc; + let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), path).unwrap(); + vectorcraft_geom::kurbo::Shape::bounding_box(&o) + }; + let close = |a: Rect, b: Rect| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6); + // The center cell (75..125 of the art) is drawn at 75..325, so the center square at 175..225; + // placed by the instance's transform alone it would be at 185..215. + assert!(close(part_box(&s, &[2]), Rect::new(175.0, 95.0, 225.0, 105.0)), "{:?}", part_box(&s, &[2])); + assert!(close(part_box(&s, &[1]), Rect::new(50.0, 50.0, 60.0, 60.0)), "the corner keeps its size: {:?}", part_box(&s, &[1])); + direct(&mut s, (177.0, 100.0), (177.0, 100.0), Mods::default()); + assert_eq!(s.doc().unwrap().selection.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![2] })); + direct(&mut s, (165.0, 100.0), (165.0, 100.0), Mods::default()); + let picked = s.doc().unwrap().selection.symbol_part.clone().map(|p| p.path); + assert_ne!(picked, Some(vec![2]), "where the square is not drawn"); +} + +/// An object that is drawn once in each 9-slice cell it reaches, rather than cut at the guides +/// (here area type), is outlined where its copies are drawn. +#[test] +fn the_outline_of_an_object_drawn_in_each_9_slice_cell_matches_what_is_drawn() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let frame = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()).0; + let area = json!({"width": 80, "height": 10}); + let text = id(&s.execute("text.create", &json!({"x": 60, "y": 95, "area": area, "size": 6, "text": "Hi"})).unwrap()).0; + let ids = [frame, text]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Bar", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap()); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap(); + let d = &s.doc().unwrap().doc; + let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), &[1]).unwrap(); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&o); + // The left copy is drawn at 60..75, the center one (placed whole it reaches 0..400) at 75..325 + // and the right one at 325..340. + let want = Rect::new(60.0, 95.0, 340.0, 105.0); + assert!([(b.x0, want.x0), (b.y0, want.y0), (b.x1, want.x1), (b.y1, want.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6), "{b:?}"); +} + +#[test] +fn direct_selection_picks_a_static_instance_whole_as_before() { + let mut s = session(); + let inst = pair(&mut s, "static"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.objects, sel.symbol_part), (vec![inst], None)); + assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).is_err(), "a static symbol"); + // A dynamic one: Alt picks the instance whole, and a path naming no object is refused. + s.execute("symbol.options", &json!({"name": "Pair", "type": "dynamic"})).unwrap(); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..Mods::default() }); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [2]})).is_err()); + assert!(s.execute("symbol.instance.selectPart", &json!({"path": [1]})).is_err(), "no id"); + // Making the symbol static again lets go of the picked object. + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("symbol.options", &json!({"name": "Pair", "type": "static"})).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); +} + #[test] fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before() { let mut s = session(); @@ -191,3 +862,201 @@ fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before assert_eq!(back.node(inst).unwrap().extras().symbol_overrides, doc.node(inst).unwrap().extras().symbol_overrides); assert_eq!(back.symbols, doc.symbols); } + +#[test] +fn a_swatch_edit_recolors_a_picked_objects_own_paints() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap(); + let fill = s.doc().unwrap().selected_symbol_part().unwrap().appearance.fill_paint(); + assert_eq!(fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "{fill:?}"); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]); +} + +#[test] +fn a_swatch_or_color_only_a_picked_objects_paint_uses_counts_as_used() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let inst = pair(&mut s, "dynamic"); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].clone(); + assert!(unused(&mut s).as_array().unwrap().contains(&json!("Brand"))); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + assert!(!unused(&mut s).as_array().unwrap().contains(&json!("Brand")), "only the object's own paint uses it"); + let colors = s.execute("recolor.colors", &json!({"ids": [inst.0]})).unwrap(); + assert!(colors["colors"].as_array().unwrap().iter().any(|c| c["hex"] == "#ff0000" && c["swatch"] == "Brand"), "{colors}"); + // A swatch edit maps the same paint once. + s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#00ff00"); +} + +/// The Eyedropper takes a picked object's paints only from a path, compound path or type. +#[test] +fn the_eyedropper_refuses_a_source_that_is_not_painted_art() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + let (a, b) = (square(&mut s, 150.0), square(&mut s, 150.0)); + let group = id(&s.execute("object.group", &json!({"ids": [a.0, b.0]})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let (fill, stroke, own, undo) = (s.paint.fill.clone(), s.paint.stroke.clone(), node(&s, inst).extras().symbol_overrides.clone(), undo_steps(&s)); + s.eyedropper_sample = None; + for source in [inst, other, group] { + let e = s.execute("appearance.copyFrom", &json!({"source": source.0})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + } + // A click with the tool on an instance, the picked one included. + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("eyedropper", v).unwrap(); + let e = s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 30.0), v).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 30.0), v).unwrap(); + assert_eq!(node(&s, inst).extras().symbol_overrides, own, "the object keeps its paints"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "the defaults for new art stay"); + assert!(s.eyedropper_sample.is_none(), "nothing was sampled"); + assert_eq!(pixel(&s, 80, 30), RED); +} + +/// A path of a compound path gives the paint of the compound path, which holds it. +#[test] +fn the_eyedropper_samples_a_compound_path_through_its_member() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let (a, b) = (square(&mut s, 120.0), square(&mut s, 170.0)); + let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [compound.0], "color": "#00ff00"})).unwrap(); + let member = node(&s, compound).children().unwrap()[0].id; + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": member.0})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#00ff00"); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]); +} + +/// A Cmd-drag lends the Selection tool, or with Alt Group Selection, which act on the whole instance. +#[test] +fn a_lent_selection_tool_moves_the_instance_of_a_picked_object() { + let cmd = Mods { cmd: true, ..Mods::default() }; + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + let before = node(&s, inst).kind.clone(); + direct(&mut s, (80.0, 30.0), (100.0, 40.0), cmd); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert_ne!(node(&s, inst).kind, before, "the instance moves"); + + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..cmd }); + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "Group Selection selects the instance whole"); + assert_eq!(s.doc().unwrap().selection.objects, [inst]); + // A plain Direct Selection click picks the object again once the tool is back. + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); +} + +/// The commands that change nothing in the document read the instance of a picked object, so the +/// Attributes and CSS Properties panels and the others keep showing it. +#[test] +fn read_only_commands_read_the_instance_of_a_picked_object() { + let mut failures = Vec::new(); + for spec in crate::cmd::command_specs().iter().filter(|c| !c.journal) { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + if let Err(e) = s.execute(spec.id, &json!({})) + && e.to_string().contains("symbol instance") + { + failures.push(format!("{}: {e}", spec.id)); + } + if s.doc().is_ok_and(|st| st.doc != doc) { + failures.push(format!("{}: the document changed", spec.id)); + } + // A command run after one of these is refused as before. + if s.doc().is_ok_and(|st| st.selection.symbol_part.is_some()) && s.execute("object.hide", &json!({})).is_ok() { + failures.push(format!("{}: the refusal stays off afterward", spec.id)); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("css.selection", &json!({})).unwrap(); + s.execute("attributes.info", &json!({})).unwrap(); +} + +/// Export CSS writes a file and changes nothing in the document, so it reads the instance of a +/// picked object as it reads an instance selected whole, and the next edit is refused as before. +#[test] +fn css_export_reads_the_instance_of_a_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + let whole = s.execute("css.export", &json!({})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let picked = s.execute("css.export", &json!({})).unwrap(); + assert_eq!(picked["rules"], 1); + assert_eq!(picked["data"], whole["data"]); + assert!(s.doc().unwrap().doc == doc, "the document changed"); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + assert!(s.execute("object.hide", &json!({})).is_err()); +} + +/// A drag that moved nothing leaves Transform Again repeating the last real transformation. +#[test] +fn a_refused_drag_of_a_picked_object_is_not_kept_for_transform_again() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default()); + assert!(s.doc().unwrap().last_transform.is_none(), "{:?}", s.doc().unwrap().last_transform); + assert_eq!(s.doc().unwrap().selection.objects, [inst]); +} + +/// A bad `target` or `aligned` on a paint command is refused with an object picked too. +#[test] +fn bad_paint_parameters_leave_a_picked_object_alone() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + for (cmd, p) in [ + ("paint.setFill", json!({"color": "#ff0000", "target": "bogus"})), + ("paint.setStroke", json!({"color": "#ff0000", "target": "bogus"})), + ("paint.invert", json!({"target": "bogus"})), + ("paint.complement", json!({"target": "bogus"})), + ("paint.setFill", json!({"color": "#ff0000", "aligned": "yes"})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("`target` must") || e.contains("aligned"), "{cmd} {p}: {e}"); + assert_eq!(overrides(&s, inst), 0, "{cmd} {p}"); + } + assert_eq!(undo_steps(&s), undo, "no undo step"); +} + +/// An object picked while Replace read its file: the instance selected when Place started is +/// selected again first, so a replay of the journal replaces it. +#[test] +fn a_replace_that_finishes_after_an_object_was_picked_selects_the_instance_again() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + let svg = ""; + let p = json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes()), "replace": true}); + let read = s.prepare_place(&p).unwrap().read().unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.place_read(read).unwrap(); + assert!(s.doc().unwrap().doc.node(inst).is_none()); + let last: Vec<_> = s.journal.iter().rev().take(2).rev().map(|(id, _)| id.clone()).collect(); + assert_eq!(last, ["select.set", "file.place"]); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c1c4df67d..fa389583f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -117,6 +117,8 @@ impl Session { // `select_tool` calls its [`Tool::chosen_again`] (the Blend tool finishes its blend). let again = self.lender.as_ref().unwrap_or(&self.tool).id() == id; self.switch_tool(id, view)?; + // `switch_tool` returns early for the tool already active. + self.select_whole_instance(id); if again { let acts = self.with_tool_cx(view, |t, cx| t.chosen_again(cx)); self.apply_actions(acts)?; @@ -147,10 +149,24 @@ impl Session { } if vectorcraft_tools::catalog::is_selection_tool(id) { self.last_selection_tool = Some(self.tool.id()); + self.select_whole_instance(id); } Ok(()) } + /// The selection tools other than Direct Selection pick whole objects: with one of them chosen, + /// an object selected in a symbol instance's art gives way to the instance. The revision + /// changes with it, so panels that cache by revision show the instance. + fn select_whole_instance(&mut self, id: &str) { + if id != "directSelection" + && vectorcraft_tools::catalog::is_selection_tool(id) + && let Some(st) = self.active_mut() + && st.selection.symbol_part.take().is_some() + { + st.revision += 1; + } + } + /// The selection tool Cmd lends tool `id` for a drag: the one chosen last; until one is, /// Direct Selection to the tools that edit anchors (the Pen) and Selection to the others. /// Cmd+Alt lends Group Selection while Direct Selection is active. Cmd still lends the last @@ -200,6 +216,8 @@ impl Session { // Fails only without a document, and then there is no typing to keep. let _ = crate::cmd::settle_interaction(self); } + // The Selection and Group Selection tools act on the instance whole. + self.select_whole_instance(id); let lent = self.make_tool(id); self.lender = Some(std::mem::replace(&mut self.tool, lent)); PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev } diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index d27a9f2a4..a2ffbbf87 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -41,6 +41,11 @@ //! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]). //! A press on a selected object's centre point (Attributes panel › Show Center) selects the object //! whole, and a drag from it moves the object. +//! A click on an instance of a dynamic symbol selects the object of the symbol's art under the +//! pointer (`symbol.instance.selectPart`), outlined: the solid color Fill, Stroke, Color, Swatches +//! and Eyedropper changes then go to that object for this instance only, and a drag moves nothing. +//! The engine refuses any object but a path, compound path or type. Alt and Shift pick the instance +//! whole, as for other objects. //! //! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors //! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else @@ -50,8 +55,8 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at}; -use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection}; +use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at, symbol_part_at, symbol_part_outline}; +use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection, symbol_overrides}; use vectorcraft_geom::{Anchor, PathData, PathSeg, Point, Rect, Vec2}; use crate::bbox::move_delta; @@ -667,6 +672,12 @@ fn next_group(cx: &ToolContext, ancestry: &[NodeId]) -> Option { .find(|id| !cx.selection.contains(*id)) } +/// Is an object of instance `id`'s art selected ([`vectorcraft_doc::Selection::symbol_part`]), +/// rather than the instance whole? +fn part_picked(cx: &ToolContext, id: NodeId) -> bool { + cx.selection.symbol_part.as_ref().is_some_and(|sp| sp.instance == id) +} + /// The segment of path `id` nearest to `p`, where the hit test found its stroke. fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option { let pd = cx.doc.node(id)?.path_data()?; @@ -984,6 +995,11 @@ impl Tool for DirectSelectionTool { let mut out = self.spine_overlays(cx); out.extend(self.mesh.overlays(cx, &|id| cx.selection.contains(id))); if !self.group { + if let Some(sp) = &cx.selection.symbol_part + && let Some(path) = cx.doc.node(sp.instance).and_then(|n| symbol_part_outline(cx.doc, n, &sp.path)) + { + out.push(Overlay::Path { path, color: cx.doc.layer_color(sp.instance), width: 1.0, dashed: false }); + } out.extend(frame_overlays(cx, &self.frame_anchors(cx))); let drag = if let State::Bracket(b) = &self.state { Some(b) } else { None }; out.extend(pathtype::overlays(cx, self.bracket_hover, drag)); @@ -1309,7 +1325,7 @@ impl DirectSelectionTool { if ev.mods.shift { return vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]; } - if cx.selection.partial(id).is_some() || cx.selection.segments.contains_key(&id) { + if cx.selection.partial(id).is_some() || cx.selection.segments.contains_key(&id) || part_picked(cx, id) { return vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; } vec![] @@ -1319,6 +1335,26 @@ impl DirectSelectionTool { h.ancestry.push(part); h.leaf = part; } + // On an instance of a dynamic symbol a click picks the object of its art under the pointer; + // a drag from it moves nothing (`object.transform` refuses while it is selected). + if !ev.mods.alt + && !ev.mods.shift + && let Some(n) = cx.doc.node(h.leaf) + && let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && let Some(sym) = cx.doc.symbols.iter().find(|s| s.name == *symbol && s.dynamic) + && let Some(path) = symbol_part_at(cx.doc, n, p, cx.selection_hit_options()) + { + self.frame.clear(); + // The engine refuses any other object with a status message, and the selection stays, + // so a drag from the press does not move it. + if symbol_overrides::part(&sym.art, &path).is_some_and(symbol_overrides::takes_part_paint) { + self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None }; + } + if cx.selection.symbol_part.as_ref().is_some_and(|sp| sp.instance == h.leaf && sp.path == path) { + return vec![]; + } + return vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": h.leaf.0, "path": path}))]; + } // On a gradient mesh a click picks the point, the mesh line's two ends or the four points // around the patch under it (Shift toggles them), and selects the mesh; a drag moves the // picked points. @@ -1340,7 +1376,7 @@ impl DirectSelectionTool { // A segment pick keeps its endpoints available for movement without selecting // the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks // the whole path (and Alt-drag copies it), as with Group Selection. - let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); + let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none() && !part_picked(cx, h.leaf); if !ev.mods.alt && matches!(h.kind, HitKind::Stroke | HitKind::Outline) && let Some(s) = segment_at(cx, h.leaf, p) @@ -1376,7 +1412,7 @@ impl DirectSelectionTool { return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; } self.frame.clear(); - if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() { + if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() || part_picked(cx, h.leaf) { return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))]; } vec![] diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index a8507e693..42396e241 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -2147,3 +2147,165 @@ fn selected_path_handle_over_background_rect_is_draggable() { let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); assert_eq!(acts, vec![Action::Begin("Reshape".into())]); } + +/// On an instance of a dynamic symbol, a Direct Selection click picks the object of the symbol's +/// art under the pointer, which is then outlined; Alt and Shift, a static symbol and Group +/// Selection pick the instance whole, as before. +#[test] +fn a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |id, x: f64| { + Arc::new(Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default_art())) + }; + let art = Node::group(NodeId(0), vec![square(0, 0.0), square(0, 20.0)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Pair".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)) * vectorcraft_geom::Affine::scale(2.0); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Pair".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |d: &vectorcraft_doc::Document, sel: &Selection, group: bool, x: f64, y: f64, m: Mods| { + DirectSelectionTool::new(group).pointer(&cx(d, sel, &p), &PointerEvent::new(PointerKind::Down, x, y).with_mods(m)) + }; + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + // The second square is drawn at 340..360. + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods::default()), part(json!([1]))); + assert_eq!(down(&d, &none, false, 310.0, 310.0, Mods::default()), part(json!([0]))); + let whole = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods { alt: true, ..Mods::default() }), whole, "Alt picks the instance"); + let shift = down(&d, &none, false, 350.0, 310.0, Mods { shift: true, ..Mods::default() }); + assert_eq!(shift, vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]); + assert_eq!(down(&d, &none, true, 350.0, 310.0, Mods::default()), whole, "Group Selection picks the instance"); + // The part selected: a click on it again keeps it, and it is outlined. + let mut sel = Selection::default(); + sel.set_symbol_part(id, vec![1]); + assert_eq!(down(&d, &sel, false, 350.0, 310.0, Mods::default()), vec![]); + let outline = DirectSelectionTool::new(false).overlays(&cx(&d, &sel, &p)); + let drawn = + outline.iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::kurbo::Shape::bounding_box(path)) } else { None }); + assert_eq!(drawn, Some(Rect::new(340.0, 300.0, 360.0, 320.0))); + // With the part selected, an Alt-click on the instance, or a press on its center point, picks + // the instance whole again. + assert_eq!(down(&d, &sel, false, 350.0, 310.0, Mods { alt: true, ..Mods::default() }), whole, "Alt with the part selected"); + d.node_mut(id).unwrap().edit_attrs(|a| a.show_center = Some(true)); + assert_eq!(down(&d, &sel, false, 330.0, 310.0, Mods::default()), whole, "the center point with the part selected"); + // A static symbol: the instance is picked whole. + d.symbols[0].dynamic = false; + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods::default()), whole); +} + +/// In a dynamic symbol's art, a Direct Selection press passes over a locked object: it picks the +/// unlocked object beneath it, and where only a locked object is under the pointer it picks the +/// instance whole. +#[test] +fn a_press_on_a_dynamic_instance_passes_over_locked_objects() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |x: f64, locked: bool| { + let mut n = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default_art()); + n.locked = locked; + Arc::new(n) + }; + // The unlocked square at 0..10 has a locked square over it; the square at 20..30 is locked. + let art = Node::group(NodeId(0), vec![square(0.0, false), square(0.0, true), square(20.0, true)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Locked".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Locked".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(down(305.0, 305.0), vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": [0]}))]); + assert_eq!(down(325.0, 305.0), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + // A locked clipping path still clips, but a press on its outline picks the art it clips. + let mut clip = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + clip.locked = true; + let art = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(-10.0, -10.0, 30.0, 30.0)), Appearance::default_art()); + let masked = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + let mut d = d.clone(); + d.symbols[0].art = Arc::new(masked); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(down(319.0, 310.0), vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": [1]}))]); +} + +/// In a dynamic symbol's art, a Direct Selection press passes over a guide path while guides are +/// hidden or locked and picks the square beneath it; with guides shown and unlocked it picks the +/// guide path. The same goes for a press on the edge of a guide used as a clipping path. +#[test] +fn a_press_on_a_dynamic_instance_passes_over_guides_while_they_cannot_be_picked() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |r: Rect, is_guide: bool| { + let mut n = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(r), Appearance::default_art()); + let NodeKind::Path { guide, .. } = &mut n.kind else { panic!("Node::path makes a path") }; + *guide = is_guide; + Arc::new(n) + }; + let small = Rect::new(0.0, 0.0, 10.0, 10.0); + let art = Node::group(NodeId(0), vec![square(small, false), square(small, true)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Guided".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Guided".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + let down = |d: &vectorcraft_doc::Document, guides: bool, x: f64, y: f64| { + let c = ToolContext { guides, ..cx(d, &none, &p) }; + DirectSelectionTool::new(false).pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)) + }; + assert_eq!(down(&d, false, 305.0, 305.0), part(json!([0])), "guides off: the square beneath"); + assert_eq!(down(&d, true, 305.0, 305.0), part(json!([1])), "guides on: the guide"); + // A guide gives a clip group no clip region, so nothing in the group is hit away from its edge, + // as outside symbols. With guides off a press on its edge picks the instance whole. + let clip = Rect::new(0.0, 0.0, 20.0, 20.0); + let masked = + Node::new(NodeId(0), NodeKind::Group { children: vec![square(clip, true), square(Rect::new(-10.0, -10.0, 30.0, 30.0), false)], clip: true }); + d.symbols[0].art = Arc::new(masked); + assert_eq!( + down(&d, false, 319.0, 310.0), + vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))], + "guides off: the clipping path's edge" + ); + assert_eq!(down(&d, true, 319.0, 310.0), part(json!([0])), "guides on: the clipping path's edge"); +} + +/// In a dynamic symbol's art, a Direct Selection press on the outline of a clip group's clipping +/// path picks the clipping path, also where the art it clips covers the path's edge; inside the +/// mask away from the edge the art is picked. +#[test] +fn a_press_on_a_clipping_path_in_a_dynamic_instance_picks_it() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let rect = |r: Rect| Arc::new(Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())); + let art = Node::new( + NodeId(0), + NodeKind::Group { children: vec![rect(Rect::new(0.0, 0.0, 20.0, 20.0)), rect(Rect::new(-10.0, -10.0, 30.0, 30.0))], clip: true }, + ); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Masked".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Masked".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + // The clipping path's right edge is at x = 320; the art covers it. + assert_eq!(down(321.0, 310.0), part(json!([0])), "just outside the mask, on its edge"); + assert_eq!(down(319.0, 310.0), part(json!([0])), "just inside the mask, on its edge"); + assert_eq!(down(310.0, 310.0), part(json!([1])), "inside the mask, the art"); + // Clipped art from 10 to 30 picked: its outline is cut to the clipping path's bounds (0 to 20), + // where it is picked. + let art = Node::new( + NodeId(0), + NodeKind::Group { children: vec![rect(Rect::new(0.0, 0.0, 20.0, 20.0)), rect(Rect::new(10.0, 10.0, 30.0, 30.0))], clip: true }, + ); + d.symbols[0].art = Arc::new(art); + let mut sel = Selection::default(); + sel.set_symbol_part(id, vec![1]); + let outline = DirectSelectionTool::new(false).overlays(&cx(&d, &sel, &p)); + let drawn = + outline.iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::kurbo::Shape::bounding_box(path)) } else { None }); + assert_eq!(drawn, Some(Rect::new(310.0, 310.0, 320.0, 320.0))); +} diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 7a9b59e2d..d984504e1 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -444,6 +444,11 @@ impl GradientTool { let id = paint_owner(cx.doc, h.leaf); let Some(v) = cx.selection.objects.contains(&id).then(|| click_vector(cx, id)).flatten() else { return vec![] }; let mut params = Self::geom_params(cx, p, p + v); + // Without `ids`, the engine refuses the gradient while an object of a dynamic symbol + // instance's art is selected. + if cx.selection.symbol_part.is_some() { + return vec![Action::Exec("paint.setGradientGeom".into(), params)]; + } params["ids"] = json!([id.0]); // With `ids` the engine edits the topmost fill or stroke unless an item is named. if let Some(i) = cx.doc.node(id).and_then(|n| n.appearance.item_of_kind(cx.appearance_item, cx.fill_active)) { diff --git a/crates/tools/src/xform/gradient/tests.rs b/crates/tools/src/xform/gradient/tests.rs index 2542ec7c5..c6582152b 100644 --- a/crates/tools/src/xform/gradient/tests.rs +++ b/crates/tools/src/xform/gradient/tests.rs @@ -268,6 +268,31 @@ fn a_click_inside_the_art_applies_the_gradient_from_there() { assert!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(400.0, 400.0))).is_empty()); } +/// With an object of a dynamic symbol instance selected with Direct Selection, a click on the +/// instance sends the gradient without `ids`, which the engine refuses. +#[test] +fn a_click_on_a_dynamic_instance_with_a_picked_object_names_no_object() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, Symbol}; + let square = + Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let art = Node::group(NodeId(0), vec![Arc::new(square)]); + let (mut d, _) = doc_with_rect(); + d.symbols.push(Symbol { dynamic: true, name: "One".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "One".into(), xf: Affine::translate((300.0, 300.0)) })).unwrap(); + let mut s = Selection::default(); + s.set_symbol_part(id, vec![0]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(305.0, 305.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(305.0, 305.0))); + let (c, v) = exec(&a[0]); + assert_eq!(c, "paint.setGradientGeom"); + assert!(v.get("ids").is_none() && v.get("item").is_none(), "{v}"); +} + #[test] fn double_click_on_a_stop_opens_its_popover() { let (d, s) = graded(two()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 981975808..6cc8fb578 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6229,6 +6229,7 @@ breakText Glyphs Глифи Reset Symbol Instance Нулиране на екземпляра на символ @msg type must be static or dynamic, not {v} type трябва да е static или dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` е статичен символ: първо задайте типа на символа на „Динамичен“ +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change На път, съставен път или текст, избран в екземпляр на символ, могат да се променят само плътните цветове на запълването и контура @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 diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 4fd69733d..39a73d96a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6278,6 +6278,7 @@ shape Open Otvoren Reset Symbol Instance Vrati instancu simbola na početno @msg type must be static or dynamic, not {v} type mora biti static ili dynamic, a ne {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` je statički simbol: prvo postavite vrstu simbola na Dinamički +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Putanji, složenoj putanji ili tekstu odabranom u instanci simbola mogu se mijenjati samo pune boje ispune i poteza @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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f2a467fc5..3f61c46c3 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6389,6 +6389,7 @@ breakText Glyphs Glifs Reset Symbol Instance Restableix la instància de símbol @msg type must be static or dynamic, not {v} type ha de ser static o dynamic, no {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` és un símbol estàtic: primer establiu el tipus de símbol a Dinàmic +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change D'un traçat, un traçat compost o un text seleccionat en una instància de símbol només es pot canviar el color sòlid de l'emplenament i el traç @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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 15cd33ed0..9291741e3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6406,6 +6406,7 @@ breakText Glyphs Glyfy Reset Symbol Instance Obnovit instanci symbolu @msg type must be static or dynamic, not {v} type musí být static nebo dynamic, ne {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` je statický symbol: nejprve nastavte jeho typ symbolu na Dynamický +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change U cesty, složené cesty nebo textu vybraného v instanci symbolu lze měnit jen plnou barvu výplně a tahu @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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 978a0971a..5f5d188ed 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6243,6 +6243,7 @@ breakText Glyphs Glyffer Reset Symbol Instance Nulstil symbolforekomst @msg type must be static or dynamic, not {v} type skal være static eller dynamic, ikke {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` er et statisk symbol: indstil først symboltypen til Dynamisk +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change For en kurve, sammensat kurve eller tekst, der er markeret i en symbolforekomst, kan kun ensfarvet fyld og streg ændres @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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e61e26c81..e42790f99 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5992,6 +5992,7 @@ breakText Glyphs Glyphen Reset Symbol Instance Symbolinstanz zurücksetzen @msg type must be static or dynamic, not {v} type muss static oder dynamic sein, nicht {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` ist ein statisches Symbol: Stellen Sie zuerst den Symboltyp auf „Dynamisch“ ein +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Bei einem in einer Symbolinstanz ausgewählten Pfad, zusammengesetzten Pfad oder Text lässt sich nur die Vollfarbe von Fläche und Kontur ändern @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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bad5543ba..ddc22b961 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6358,6 +6358,7 @@ breakText Glyphs Γλύφοι Reset Symbol Instance Επαναφορά στιγμιότυπου συμβόλου @msg type must be static or dynamic, not {v} το type πρέπει να είναι static ή dynamic, όχι {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first το `{name}` είναι στατικό σύμβολο: ορίστε πρώτα τον τύπο συμβόλου σε Δυναμικό +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Σε διαδρομή, σύνθετη διαδρομή ή κείμενο που έχει επιλεγεί σε στιγμιότυπο συμβόλου αλλάζει μόνο το συμπαγές χρώμα του γεμίσματος και της πινελιάς @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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 651886e05..531aaf18e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6348,6 +6348,7 @@ breakText Glyphs Glifos Reset Symbol Instance Restablecer instancia de símbolo @msg type must be static or dynamic, not {v} type debe ser static o dynamic, no {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` es un símbolo estático: primero establezca su tipo de símbolo en Dinámico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change De un trazado, trazado compuesto o texto seleccionado en una instancia de símbolo solo se puede cambiar el color sólido del relleno y del trazo @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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f79d04cea..6ae295376 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6436,6 +6436,7 @@ breakText Glyphs Merkit Reset Symbol Instance Palauta symboli-ilmentymä @msg type must be static or dynamic, not {v} type-arvon on oltava static tai dynamic, ei {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` on staattinen symboli: aseta ensin sen symbolin tyypiksi Dynaaminen +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Symboli-ilmentymästä valitusta polusta, yhdistelmäpolusta tai tekstistä voi muuttaa vain täytön ja viivan yhtenäistä väriä @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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b9c9c1165..fdad1035b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6401,6 +6401,7 @@ breakText Glyphs Glyphes Reset Symbol Instance Réinitialiser l'instance de symbole @msg type must be static or dynamic, not {v} type doit être static ou dynamic, et non {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` est un symbole statique : définissez d'abord son type de symbole sur Dynamique +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Seules les couleurs unies du fond et du contour d'un tracé, d'un tracé transparent ou d'un texte sélectionné dans une instance de symbole peuvent changer @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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9f6c6807f..efff1b054 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5473,6 +5473,7 @@ @msg `{name}` is a built-in workspace; choose another name `{name}` एक अंतर्निहित वर्कस्पेस है; एक और नाम चुनें @msg `{name}` is a process colour: tints apply to global and spot colours `{name}` एक प्रक्रिया रंग है: टिंट वैश्विक और स्पॉट रंगों पर लागू होते हैं @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` एक स्थिर सिंबल है: अपने सिंबल प्रकार को पहले गतिशील करने के लिए सेट करें +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change सिंबल इंस्टेंस में चुने गए पाथ, कंपाउंड पाथ या टाइप का केवल ठोस रंग वाला फ़िल और स्ट्रोक बदला जा सकता है @msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` `{base}` का एक टिंट है: `{base}` संपादित करें (केवल एक टिंट swatch केवल एक नया नाम लेता है) @msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` वर्किंग स्पेस है: पहले edit.colorSettings से दूसरा चुनें @msg `{name}` is built in and stays `{name}` बनाया गया है और रहता है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2de1183cc..3153fae2a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6390,6 +6390,7 @@ breakText Glyphs Glifák Reset Symbol Instance Szimbólumpéldány visszaállítása @msg type must be static or dynamic, not {v} a type értéke static vagy dynamic lehet, nem {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` statikus szimbólum: előbb állítsa a szimbólum típusát Dinamikusra +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Egy szimbólumpéldányban kijelölt útvonalnak, összetett útvonalnak vagy szövegnek csak a kitöltés és a körvonal egyszínű színe módosítható @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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bffe798c5..9ba667353 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6243,6 +6243,7 @@ breakText Glyphs Glif Reset Symbol Instance Atur ulang instans simbol @msg type must be static or dynamic, not {v} type harus static atau dynamic, bukan {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` adalah simbol statis: setel dulu jenis simbolnya ke Dinamis +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Hanya warna solid isian dan goresan jalur, jalur majemuk, atau teks yang dipilih dalam instans simbol yang dapat diubah @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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 49f8130ae..bfef006c9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6026,6 +6026,7 @@ breakText Glyphs Glifi Reset Symbol Instance Reimposta istanza di simbolo @msg type must be static or dynamic, not {v} type deve essere static o dynamic, non {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` è un simbolo statico: imposta prima il tipo di simbolo su Dinamico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Di un tracciato, un tracciato composto o un testo selezionato in un'istanza di simbolo si può modificare solo il colore pieno di riempimento e traccia @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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 94b2e408a..07ce883e6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6091,6 +6091,7 @@ breakText Glyphs 字形 Reset Symbol Instance シンボルインスタンスをリセット @msg type must be static or dynamic, not {v} type は static または dynamic にしてください({v} は無効です) @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` はスタティックシンボルです。先にシンボルの種類をダイナミックに設定してください +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change シンボルインスタンス内で選択したパス、複合パス、テキストは、塗りと線の単色のみ変更できます @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 のいずれかを指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 77b99070f..5d049fb9e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6403,6 +6403,7 @@ breakText Glyphs გლიფები Reset Symbol Instance სიმბოლოს ეგზემპლარის ჩამოყრა @msg type must be static or dynamic, not {v} type უნდა იყოს static ან dynamic და არა {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` სტატიკური სიმბოლოა: ჯერ სიმბოლოს ტიპად დააყენეთ „დინამიკური“ +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change სიმბოლოს ეგზემპლარში მონიშნულ ტრაექტორიას, შედგენილ ტრაექტორიას ან ტექსტს მხოლოდ შევსებისა და კონტურის მთლიანი ფერი შეეცვლება @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 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca570d3cc..5cf97c257 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5987,6 +5987,7 @@ breakText Glyphs 글리프 Reset Symbol Instance 심볼 인스턴스 재설정 @msg type must be static or dynamic, not {v} type은 static 또는 dynamic이어야 합니다({v}는 안 됨) @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}`은(는) 정적 심볼입니다. 먼저 심볼 유형을 동적으로 설정하세요 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 심볼 인스턴스에서 선택한 패스, 컴파운드 패스 또는 문자는 칠과 선의 단색만 변경할 수 있습니다 @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를 지정하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bfbdef06c..5a16c754c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6289,6 +6289,7 @@ breakText Glyphs Glyphs Reset Symbol Instance Symboolinstantie opnieuw instellen @msg type must be static or dynamic, not {v} type moet static of dynamic zijn, niet {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` is een statisch symbool: stel eerst het symbooltype in op Dynamisch +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Van een pad, samengesteld pad of tekst die in een symboolinstantie is geselecteerd, kan alleen de effen kleur van vulling en lijn veranderen @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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 668a55ed7..e218ac53c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6261,6 +6261,7 @@ breakText Glyphs Glify Reset Symbol Instance Resetuj wystąpienie symbolu @msg type must be static or dynamic, not {v} type musi mieć wartość static lub dynamic, a nie {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` jest symbolem statycznym: najpierw ustaw jego typ symbolu na Dynamiczny +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change W ścieżce, ścieżce złożonej lub tekście zaznaczonym w wystąpieniu symbolu można zmienić tylko jednolity kolor wypełnienia i obrysu @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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 73cf2c8ee..af16149fe 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6324,6 +6324,7 @@ breakText Glyphs Glifos Reset Symbol Instance Redefinir instância de símbolo @msg type must be static or dynamic, not {v} type deve ser static ou dynamic, não {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` é um símbolo estático: defina primeiro o tipo de símbolo como Dinâmico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change De um caminho, caminho composto ou texto selecionado em uma instância de símbolo só é possível alterar a cor sólida do preenchimento e do traçado @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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9b462f1b9..2ff6c5873 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6383,6 +6383,7 @@ breakText Glyphs Глифы Reset Symbol Instance Сбросить экземпляр символа @msg type must be static or dynamic, not {v} type должен быть static или dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` — статический символ: сначала задайте для него тип символа «Динамический» +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change У контура, составного контура или текста, выделенного в экземпляре символа, можно изменить только сплошной цвет заливки и обводки @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 diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 44dfd2563..ea0550be3 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6458,6 +6458,7 @@ breakText Glyphs Glyfer Reset Symbol Instance Återställ symbolinstans @msg type must be static or dynamic, not {v} type måste vara static eller dynamic, inte {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` är en statisk symbol: ställ först in symboltypen på Dynamisk +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change För en bana, sammansatt bana eller text som är markerad i en symbolinstans kan bara enfärgad fyllning och linje ändras @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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6ce1191c8..cf5eea934 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6363,6 +6363,7 @@ breakText Glyphs Glifler Reset Symbol Instance Sembol Örneğini Sıfırla @msg type must be static or dynamic, not {v} type static veya dynamic olmalıdır, {v} değil @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` statik bir semboldür: önce Sembol Türünü Dinamik olarak ayarlayın +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Bir sembol örneğinde seçilen yolun, bileşik yolun veya yazının yalnızca dolgu ve konturunun düz rengi değiştirilebilir @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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 81d168096..b8d3c44c0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6006,6 +6006,7 @@ breakText Glyphs Гліфи Reset Symbol Instance Скинути екземпляр символу @msg type must be static or dynamic, not {v} type має бути static або dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` — статичний символ: спершу встановіть для нього тип символу «Динамічний» +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change У контуру, складеного контуру або тексту, виділеного в екземплярі символу, можна змінити лише суцільний колір заливки й обведення @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 diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3b1169439..7a736a1b4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6007,6 +6007,7 @@ breakText Glyphs Glyph Reset Symbol Instance Đặt lại đối tượng ký hiệu @msg type must be static or dynamic, not {v} type phải là static hoặc dynamic, không phải {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` là ký hiệu tĩnh: trước tiên hãy đặt Kiểu ký hiệu thành Động +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Chỉ có thể thay đổi màu đặc của màu tô và nét của đường dẫn, đường dẫn phức hoặc chữ được chọn trong một đối tượng ký hiệu @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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9fa2f182..347f1a47a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6247,6 +6247,7 @@ breakText Glyphs 字形 Reset Symbol Instance 重置符号实例 @msg type must be static or dynamic, not {v} type 必须是 static 或 dynamic,而不是 {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` 是静态符号:请先将其符号类型设为动态 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 在符号实例中选中的路径、复合路径或文字只能更改填色和描边的纯色 @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 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6a34a843a..663a94c06 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6044,6 +6044,7 @@ breakText Glyphs 字符 Reset Symbol Instance 重設符號實體 @msg type must be static or dynamic, not {v} type 必須是 static 或 dynamic,而不是 {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` 是靜態符號:請先將其符號類型設為動態 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 在符號實體中選取的路徑、複合路徑或文字只能變更填色和筆畫的純色 @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 diff --git a/docs/gaps.md b/docs/gaps.md index a73d82c73..8369b2573 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -624,9 +624,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall as a `.vectorcraft` library into the user library folder (listed under User Defined) or a file (`brush.libraries`, `brush.addFromLibrary`, `brush.saveLibrary`, `brush.loadLibrary`). - **Missing:** the Art brush's Overlap; more built-in brush, symbol and graphic-style libraries (original, - generated in code, never Adobe's); dynamic symbols' on-canvas editing (Direct Selection of an object inside a - dynamic instance, with the Fill/Stroke controls and Appearance panel then setting its change) and their `.ai` - read and write; Global Edit of groups holding type or + generated in code, never Adobe's); dynamic symbols' on-canvas editing beyond a solid fill and stroke on a path, + compound path or type object inside a dynamic instance (Direct Selection selects it, and the Fill/Stroke controls set + its change) and their `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches, Shift-click to leave a match out and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). - **Estimate:** 14–24 h. diff --git a/docs/mcp.md b/docs/mcp.md index 284a5e887..3e43b8e60 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1790,7 +1790,35 @@ already walks Image Trace. Short agent entry points: 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. `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties + 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 + color, all without `ids`, `id` or `item`, and the Eyedropper's `appearance.copyFrom` with a solid fill and stroke + change that object's fill or stroke for this instance only, each as one undo step. A painted stroke on type without a + weight gets 1 pt. A gradient or pattern for the object is refused with a status message, and so are `paint.invert` and + `paint.complement` on type whose characters differ in color, Eyedropper options that pick up neither color, an + Eyedropper source that is not a path, compound path or type (a symbol instance or a group, for which the paint defaults + stay as they are too), and a click + or `symbol.instance.selectPart` on any other object of the art (a group, image, blend or nested instance among them). + The commands that change the selected objects without `ids` are refused the same way and change nothing, among them + opacity and blending mode, moving and transforming, `path.moveAnchors`, `file.place` with `replace`, Puppet Warp, Delete + and Cut, Group, Hide, Lock, Arrange, Expand, + Recolor Artwork, plug-in filters, effects, the gradient commands, `gradient.selectStop`, the Appearance panel's rows and + `symbol.instance.setAppearance`. A Cmd-drag that lends the Selection or Group Selection tool selects the instance whole + first and moves it. `swatch.unused` and `recolor.colors` count the fills and strokes an instance keeps for objects of its + art. Commands that change nothing in the document, such as `attributes.info`, `css.selection`, `css.export`, + `effect.list` and `object.expand.info`, read the instance. These commands do not check for a selected object and use the instance: + `edit.copy` copies the instance whole; `document.exportSelection`, `document.export` with `selectionOnly` and + selection-mode `document.exportForScreens` export it whole; `artboard.fitToSelection` fits the artboard to the whole instance; + `object.liquify` without `ids` takes the instance as its target and, since it reshapes paths only, leaves it as it is + and lists it in `skipped` when the brush reaches it; `object.isolate` without `id` isolates the instance and clears the + selection; `library.add` without `ids` adds a copy of the whole instance as a `graphic`, and its fill and stroke kinds + read the instance rather than the selected object; `select.save` saves the instance, and `select.recall` then selects it + whole. Direct Selection passes over hidden, locked and template objects of the art, and + `symbol.instance.selectPart` refuses a path to one or to an object inside one, and an instance that is hidden or + locked or sits in a hidden or locked group or layer. Choosing another selection tool selects the instance whole again. + `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties panel's Reset). Redefining the symbol (`symbol.update`) reshapes every instance and keeps each change whose path the new art still has; `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c388ee2fd..49b266520 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -721,6 +721,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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 ``, 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`. - 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`. - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. @@ -1754,7 +1755,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: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and 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 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. ## Document Tools add-on: Export All Open Documents (2026-11-01) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b7b2250c5..fca9b2354 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -385,7 +385,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Symbol libraries: Window › Symbol Libraries (Basic Shapes, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; only one built-in library (original, generated in code) | | Brush libraries: Window › Brush Libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; four built-in libraries (original, generated in code) | | Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (scaled instances keep their corners; in symbol editing mode the guides are drawn over the art and the Selection tool drags them); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | -| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; on-canvas Direct Selection of a part inside an instance (with the Fill/Stroke controls then setting its change) remains | +| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; Direct Selection of a path, compound path or type object inside an instance, with the Fill/Stroke controls setting its solid fill or stroke, is on canvas; on-canvas editing beyond a solid fill and stroke, and `.ai` read and write, remain | | Pathfinder Trim, Merge and Crop remove strokes; Merge joins same-fill objects whatever their strokes | done | #1570 (#1572) | | Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) | | Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains | From a409f34ebe0fe43e1a3859c3174543bdeba29839 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:26:34 +0700 Subject: [PATCH 06/93] .ai import: placed PDF and .ai pages open as placed documents Squashed from #6083 onto main. --- crates/engine/src/cmd/links.rs | 13 +- crates/engine/src/cmd/place.rs | 6 +- crates/engine/src/cmd/place/document.rs | 4 +- crates/engine/src/tests/placed_document.rs | 92 +++++++++ crates/engine/tests/import_fuzz.rs | 6 +- crates/eps/src/import/ai.rs | 4 +- crates/eps/src/import/ai/read.rs | 19 +- crates/eps/src/import/ai/read/art.rs | 67 ++++++- crates/eps/src/import/ai/read/document.rs | 2 + crates/eps/src/import/ai/read/types.rs | 41 ++++- crates/eps/src/import/ai/tests.rs | 205 +++++++++++++++++++++ crates/eps/src/import/native/build.rs | 53 ++++++ crates/eps/src/import/tests_native.rs | 97 ++++++++++ docs/file-format-parity.md | 17 ++ docs/target-app-parity.md | 1 + 15 files changed, 609 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 89492ad01..28d8be2ee 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -849,9 +849,10 @@ impl Resolved { /// The linked files of document `d`, just read from `doc_path`: those found unchanged are read /// again (their images had the preview), modified ones are read again when `update`, links found -/// away from their path take the new path. A link nothing is known of whose image shows a preview -/// (a file another app wrote kept only that) was never read: its file is read now, into the -/// image's bounds, which isn't a change to report. Nothing here is an undo step. +/// away from their path take the new path. A link nothing is known of whose image, or placed +/// document, shows a preview (a file another app wrote kept only that) was never read: its file is +/// read now, into the object's bounds, which isn't a change to report. A placed document whose +/// file's bytes are gone isn't showing a preview. Nothing here is an undo step. pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved { let mut out = Resolved::default(); // The image (with its preview) and hash of each file read for a preview here, by path: images @@ -863,7 +864,11 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![]; for g in groups(d, None) { let row = rows(std::iter::once(&g), doc_path); - let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key); + let shows_preview = match g.document { + None => previewing(d, &g.key), + Some(_) => d.images.get(&g.key).is_some_and(ImageBlob::is_proxy), + }; + let unread = g.link.hash.is_none() && g.link.size.is_none() && shows_preview; let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d); match probe(&g, doc_path) { (Status::Missing, _) => out.missing.extend(row), diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 930559db3..40d176ab8 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -308,8 +308,10 @@ pub(crate) fn pdf_art( read = Err(e); } let (mut doc, warnings, alone) = fileio::page_or_editing_data(src, bytes, &o, index, read).map_err(|e| bad(cmd, format!("{name}: {e}")))?; - if alone { - // Linked images show their files, found from its folder. + // Linked images show their files, found from its folder. Its placed documents can place this + // file again: one already being read further up, or past the most nested reads, keeps its links + // as stored, as a VectorCraft document's do. + if alone && let Some(_reading) = document::Reading::enter(bytes) { super::links::resolve(&mut doc, path, false, false); } doc.drop_edit_modes(); diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 9bc52c89b..bea33fcf3 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -157,11 +157,11 @@ std::thread_local! { } /// A document's links being resolved ([`READING`]): the entry goes when this does. -struct Reading; +pub(super) struct Reading; impl Reading { /// `None` when the document of `bytes` is already being read, or [`MAX_NESTED_READS`] are. - fn enter(bytes: &[u8]) -> Option { + pub(super) fn enter(bytes: &[u8]) -> Option { let hash = hash_bytes(bytes); READING.with_borrow_mut(|r| (r.len() < MAX_NESTED_READS && !r.contains(&hash)).then(|| r.push(hash))).map(|()| Reading) } diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 7616a4529..5f989db78 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -474,6 +474,26 @@ fn update_links_on_open_reads_a_modified_file_again() { assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); } +/// A placed document whose link keeps no size or hash and whose file's bytes are gone shows no +/// preview: resolving its links reports the file as modified and leaves the object as it is. +#[test] +fn a_placed_document_without_its_bytes_or_a_hash_is_reported_not_read() { + let dir = Folder::new("placed-no-hash"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let key = placed(&s, id).key; + let mut d = (*s.doc().unwrap().doc).clone(); + d.images.remove(&key); + let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("a placed document") }; + p.link.hash = None; + p.link.size = None; + let r = crate::cmd::links::resolve(&mut d, None, false, false).to_json(); + assert_eq!(r["modifiedLinks"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(!d.images.contains_key(&key), "not read"); +} + #[test] fn relink_points_a_placed_document_at_another_file() { let dir = Folder::new("placed-relink"); @@ -663,6 +683,78 @@ fn saving_and_reopening_keeps_a_linked_pdf_pages_number_and_box() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); } +/// A placed PDF page in a `.ai` file's editing data is a placed document of the page and box the +/// dictionary after it names. Its file missing, it shows the preview the file keeps, in the same +/// place. Its file found in the folder kept relative to the document, it shows that page. +#[test] +fn a_pdf_page_placed_in_an_ai_files_editing_data_opens_as_a_placed_document() { + use vectorcraft_testkit::ai; + let dir = Folder::new("placed-pdf-in-ai"); + let place = "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 600 4 4 0\n(/no/such/folder/src/flyer.pdf)`\n\ + %%IncludeFile:/no/such/folder/src/flyer.pdf\n%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n(src/)\n\ + %AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n%AI26_BeginPlacedObjectPreview\n\ + 2 1 8 32 17 \x7f\x0a\x0a\x73\x7f\x0a\x0a\x73\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n\ + %_/ArtDictionary :\n%_2 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n"; + let doc_path = dir.file("poster.ai"); + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", place)); + // The page draws what the half-covering preview draws over white. + write(&doc_path, &ai::ai(data.as_bytes(), "0.54 0.54 0.95 rg 20 20 60 60 re f")); + let shown = |s: &Session| { + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true); + let [r, g, b, _] = img.pixel(50, 50); + [r, g, b] + }; + let one = |s: &Session| { + let mut found = vec![]; + s.doc().unwrap().doc.visit_placed(|n, p| found.push((n.id, p.clone()))); + let [(id, p)] = &found[..] else { panic!("one placed document: {found:?}") }; + (*id, p.clone(), s.doc().unwrap().doc.images[&p.key].is_proxy()) + }; + let mut s = session(); + + let r = open(&mut s, &doc_path); + assert_eq!(r["missingLinks"][0]["name"].as_str(), Some("flyer.pdf"), "{r}"); + let (id, p, proxy) = one(&s); + assert!(proxy, "the preview"); + assert_eq!((p.link.page, p.page_box, p.bounding), (Some(2), vectorcraft_doc::PageBox::Media, false)); + assert_eq!(p.link.relative.as_deref(), Some("src/flyer.pdf")); + let place = bounds(&s, id); + assert!((place.x0 - 20.0).abs() < 0.01 && (place.width() - 60.0).abs() < 0.01 && (place.height() - 60.0).abs() < 0.01, "{place:?}"); + assert!(near(shown(&s), [138, 138, 243]), "the preview shows: {:?}", shown(&s)); + + pdf_source(&dir.file("src/flyer.pdf"), "pdf", &[BLUE, RED], 0); + let r = open(&mut s, &doc_path); + assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}"); + let (id, p, proxy) = one(&s); + assert!(!proxy, "the file's bytes, not the preview"); + assert_eq!((p.link.page, p.page_box, p.bounding), (Some(2), vectorcraft_doc::PageBox::Media, false)); + assert!(p.link.hash.is_some() && p.link.size.is_some(), "{:?}", p.link); + assert_eq!(bounds(&s, id), place, "the same place"); + assert!(near(shown(&s), RED), "page 2 of the file shows: {:?}", shown(&s)); +} + +/// A `.ai` saved without PDF compatibility (its page only text) that places itself as a page is +/// read once inside itself: the copy inside keeps its link as stored, and the file opens. +#[test] +fn an_ai_file_that_places_itself_opens() { + use vectorcraft_testkit::ai; + let dir = Folder::new("ai-places-itself"); + let doc_path = dir.file("poster.ai"); + let at = doc_path.replace('\\', "/"); + let place = format!( + "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 600 4 4 0\n({at})`\n%%IncludeFile:{at}\n%AI26_BeginPlacedObjectPreview\n\ + 2 1 8 32 17 \x7f\x0a\x0a\x73\x7f\x0a\x0a\x73\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n\ + %_/ArtDictionary :\n%_1 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n" + ); + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n{place}", ai::rect(120.0, 20.0, 180.0, 80.0)))); + write(&doc_path, &ai::ai(data.as_bytes(), "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET")); + let mut s = session(); + let r = open(&mut s, &doc_path); + let mut found = vec![]; + s.doc().unwrap().doc.visit_placed(|_, p| found.push(p.link.path.replace('\\', "/"))); + assert_eq!(found, [at], "{r}"); +} + #[test] fn a_pdf_compatible_ai_file_places_linked_like_a_pdf() { let dir = Folder::new("placed-ai-linked"); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 81d75ca70..d0bcedb73 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3572,7 +3572,8 @@ proptest! { %_/ArtDictionary :\n%_/Mask :\n%_X=\n%_0 g\n%_0 0 m\n%_5 0 l\n%_5 -5 l\n%_f\n%_X+\n%_ (Art) ,\n%_; (AI9_Mask) ,\n%_/XMLUID : (Photo) ; (AI10_ArtUID) ,\n%_;\n%_\n"; let place = "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -8000 8000] 8000 8000 8002 8001 4 4 0\n(/tmp/p.png)`\n%%IncludeFile:/tmp/p.png\n\ %AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n()\n%AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n\ - %AI17_Begin_Content_if_version_gt:24 5\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 @@\n%AI26_EndPlacedObjectPreview\n%AI17_End_Versioned_Content\n~\n%AI5_EndPlace\n"; + %AI17_Begin_Content_if_version_gt:24 5\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 @@\n%AI26_EndPlacedObjectPreview\n%AI17_End_Versioned_Content\n~\n%AI5_EndPlace\n\ + %_/ArtDictionary :\n%_2 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n"; let text = ai::editing_data(100.0, 100.0, &ai::layer("L", &format!("{raster}{place}"))); let samples: &[u8] = &[255, 0, 0, 0, 0, 255, 128, 0]; let preview: &[u8] = &[255, 255, 0, 0, 0, 0, 0, 0]; @@ -4644,6 +4645,9 @@ fn arb_ai_token() -> impl Strategy { "\n%AI26_EndPlacedObjectPreview\n", "[1 0 0 -1 0 0] 0 0 2 1 4 4 0 (a.png) `", "[1e308 0 0 1e308 0 0] 0 0 -1e308 1e308 4 4 0 (a.png) `", + "\n%_/ArtDictionary :\n%_2 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n", + "\n%_/ArtDictionary :\n%_-1e308 /Real (AI11 PlacedPDFPageIndex) ,\n%_99 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n", + "\n%_/ArtDictionary :\n%_(2) /String (AI11 PlacedPDFPageIndex) ,\n%_5 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n", "\n%AI28_BeginPlacedRelativePath\n", "\n%AI28_BeginPlacedRelativePath\n(src/)\n%AI28_EndPlacedRelativePath\n", "\n%AI28_BeginPlacedRelativePath\n(..\\\\..\\\\art\\\\)\n%AI28_EndPlacedRelativePath\n", diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 5537b5d06..b0359e172 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -41,8 +41,8 @@ pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS; pub use read::{ - BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, - read, read_beside, variables, + BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, + palette, read, read_beside, variables, }; pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member}; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 60ccd43ca..f49c598dc 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -46,6 +46,10 @@ //! box is as large as the file has pixels; the matrix takes them into art space from the canvas //! (y down, its centre the template box's), where they start at the point of art space's origin, //! the first row the top one. Each is a linked image showing the preview until its file is found. +//! The dictionary after a placed PDF or `.ai` file has the page shown, from 1, in +//! `AI11 PlacedPDFPageIndex` and its box in `AI11 PlacedPDFCropTo` (0 Art, 1 Crop, 2 Trim, 3 Bleed, +//! 4 Media, 5 Bounding Box): each is a placed document of that page and box, showing the preview +//! until its file is found. A value outside those leaves it a linked image. //! The version 7 form (§16) has no `%AI5_BeginPlace`: `[matrix] llx lly urx ury (path)` and //! `` ` ``, then `%%IncludeFile:` or the file between `%%BeginDocument:` and `%%EndDocument`, //! then `~`; its box is the file's `%%BoundingBox`. Newer files have three more numbers before @@ -112,7 +116,7 @@ use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::cmyk::Inks; use vectorcraft_doc::{ Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin, - LinkInfo, Node, NodeId, NodeKind, OpacityMask, PatternDef, StrokeLayer, Unit, + LinkInfo, Node, NodeId, NodeKind, OpacityMask, PageBox, PatternDef, StrokeLayer, Unit, }; use vectorcraft_geom::{AnchorKind, FillRule, PathData}; @@ -139,10 +143,10 @@ pub use document::{ }; pub(crate) use document::{slot_frame, slot_of}; pub(crate) use ops::read_art_style; -pub use types::IMAGE_TOO_LARGE; -use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead}; +use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead}; #[cfg(test)] pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; +pub use types::{IMAGE_TOO_LARGE, PlacedPage}; pub(super) use types::{blend_mode, blend_name}; #[path = "legacy_text.rs"] @@ -185,6 +189,8 @@ pub struct Structure { /// move, in its pixels, that places them there (the corner of the box they are written with, /// in place of the canvas point of art space's origin). pub placed_elsewhere: Vec<(NodeId, Affine)>, + /// The placed files that are pages of PDF or `.ai` files. + pub placed_pages: Vec, /// The live envelopes read from the editing data, by id, with the drawn art each took the place /// of: the importer puts that art back when the layers with the envelopes don't draw as the page. pub envelopes_drawn: Vec<(NodeId, Node)>, @@ -438,6 +444,9 @@ struct Reader<'a> { image_link: Option, /// The placed file being read (`%AI5_BeginPlace` … `~`). place: Option, + /// The placed files just read, until the dictionary after each: at most one for each depth of + /// frames, the innermost last. + placed_tails: Vec, /// The last object read is the last child of the innermost frame, and nothing started since. after_object: bool, /// The last object read is an image, and only the operators that end its raster (`XH`, `Xr`, @@ -554,6 +563,8 @@ struct Reader<'a> { placeholders: Vec, /// See [`Structure::placed_elsewhere`]. placed_elsewhere: Vec<(NodeId, Affine)>, + /// See [`Structure::placed_pages`]. + placed_pages: Vec, unread_elsewhere: Vec<(NodeId, Rect)>, /// CMYK samples converted, by value. cmyk_cache: HashMap<[u8; 4], [u8; 3]>, @@ -732,6 +743,8 @@ impl<'a> Reader<'a> { restyled: vec![], placeholders: vec![], placed_elsewhere: vec![], + placed_pages: vec![], + placed_tails: vec![], unread_elsewhere: vec![], mesh: None, cmyk_cache: HashMap::new(), diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index bc7b88155..7f0b75823 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -2,13 +2,14 @@ use super::containers::close; use super::document::TEXT_SLOT; use super::types::{ FLIP, Frame, IMAGE_TOO_LARGE, Kind, LINKED_PLACEHOLDERS, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, - NON_NATIVE_ART_UNREAD, Place, + NON_NATIVE_ART_UNREAD, PLACED_CROP, PLACED_PAGE, Place, PlacedPage, PlacedTail, }; use super::{ Affine, AnchorKind, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node, - NodeId, NodeKind, Obj, OpacityMask, Paint, PathData, PathEl, Point, Reader, Rect, StrokeLayer, V, ps, + NodeId, NodeKind, Obj, OpacityMask, PageBox, Paint, PathData, PathEl, Point, Reader, Rect, StrokeLayer, V, ps, }; use vectorcraft_doc::links::missing_placeholder; +use vectorcraft_doc::placed_document::key_for; impl<'a> Reader<'a> { /// A text object (`/AI11Text`): an empty group standing for it, named after its story. @@ -754,18 +755,59 @@ impl<'a> Reader<'a> { let bytes = Arc::new(png); let blob = ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }; let key = linked_key(&blob, &path); + let fresh = !self.doc.images.contains_key(&key); self.doc.images.entry(key.clone()).or_insert(blob); self.warn(LINKED_PREVIEWS); + let id = NodeId(self.doc.peek_next_id()); if let Some(shift) = elsewhere && self.placed_elsewhere.len() < MAX_PLACED_ELSEWHERE { - self.placed_elsewhere.push((NodeId(self.doc.peek_next_id()), shift)); + self.placed_elsewhere.push((id, shift)); } + // One waits at each depth: a placed file in another's opacity mask doesn't take the place of the outer one. + // An outer one waiting shares its blob with this one now: it no longer has it alone. + let depth = self.frames.len(); + self.placed_tails.retain(|t| t.depth < depth); + for t in self.placed_tails.iter_mut().filter(|t| t.key == key) { + t.fresh = false; + } + self.placed_tails.push(PlacedTail { id, key: key.clone(), size: (b.width(), b.height()), fresh, depth }); let mut link = LinkInfo::new(path); link.relative = folder.and_then(|f| relative_path(&f, link.name())); self.add_image(ImageObject { key, width: w, height: h, xf, link: Some(link), placement: Default::default(), pixels: None }, hidden) } + /// The dictionary `o` after the placed file just read: with a page or a box in it, that file is + /// a page of a PDF or `.ai` file. Its image keeps the preview under a key of its own for that + /// page and box, so that it doesn't share one with an image of another page of the file. + pub(super) fn placed_page(&mut self, o: &Obj) { + // The dictionaries of art read inside the placed file's own (its opacity mask's) are not its. + let depth = self.frames.len(); + self.placed_tails.retain(|t| t.depth <= depth); + if self.placed_tails.last().is_none_or(|t| t.depth != depth) { + return; + } + let Some(tail) = self.placed_tails.pop() else { return }; + let (page, crop) = (o.get(PLACED_PAGE).map(|_| o.nums(PLACED_PAGE)), o.get(PLACED_CROP).map(|_| o.nums(PLACED_CROP))); + // A placed document's box runs from (0, 0) to a positive size: a box written backward stays a linked image. + if (page.is_none() && crop.is_none()) || !(tail.size.0 > 0.0 && tail.size.1 > 0.0) { + return; + } + // An entry that is there but not a number leaves a linked image, as an unknown number does. + let page = page.map_or(Some(1), |v| v.first().copied().and_then(placed_page_number)); + let crop = crop.map_or(Some((false, PageBox::Crop)), |v| v.first().copied().and_then(placed_crop)); + let (Some(page), Some((bounding, page_box))) = (page, crop) else { return }; + let Some(NodeKind::Image(im)) = self.last_object().filter(|n| n.id == tail.id).map(|n| &mut n.kind) else { return }; + let old = im.key.clone(); + let key = key_for(&old, page, bounding, page_box); + im.key.clone_from(&key); + let blob = if tail.fresh { self.doc.images.remove(&old) } else { self.doc.images.get(&old).cloned() }; + if let Some(blob) = blob { + self.doc.images.entry(key).or_insert(blob); + } + self.placed_pages.push(PlacedPage { id: tail.id, page, bounding, page_box, size: tail.size }); + } + pub(super) fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 { self.warnings.push(w.to_string()); @@ -867,6 +909,25 @@ pub(super) fn preview_png(data: &[u8]) -> Option<(Vec, u32, u32)> { Some((png, width, height)) } +/// The page a placed file's dictionary names: a whole number from 1. +fn placed_page_number(v: f64) -> Option { + (v.fract() == 0.0 && v >= 1.0 && v <= f64::from(u32::MAX)).then_some(v as u32) +} + +/// The box a placed file's dictionary names: whether the art's bounds are shown, and the box of +/// the page otherwise. +fn placed_crop(v: f64) -> Option<(bool, PageBox)> { + match v { + 0.0 => Some((false, PageBox::Art)), + 1.0 => Some((false, PageBox::Crop)), + 2.0 => Some((false, PageBox::Trim)), + 3.0 => Some((false, PageBox::Bleed)), + 4.0 => Some((false, PageBox::Media)), + 5.0 => Some((true, PageBox::Crop)), + _ => None, + } +} + /// A placed file's path relative to the document's folder (`/` separators): its relative `folder` /// (`src/`, `..\art`) followed by its file `name`; the name alone when the folder is empty (the /// document's own folder). `None` when the folder is absolute or holds control characters, or diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 1cbadc044..8a79f53b1 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -612,6 +612,7 @@ impl<'a> Reader<'a> { head.registration = Some(*r); } self.constraint_art(o); + self.placed_page(o); // A blend's key object or spine. if let Some(kind) = o.text("ShapeBlendPathType") && let Some(id) = self.last_object().map(|n| n.id) @@ -1183,6 +1184,7 @@ impl<'a> Reader<'a> { restyled: self.restyled, placeholders: self.placeholders, placed_elsewhere: self.placed_elsewhere, + placed_pages: self.placed_pages, envelopes_drawn: self.envelopes_drawn, warped_type: super::super::envelope::WarpedType { looks: self.warped_type, diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 8b9607d18..5fe93923a 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -1,4 +1,6 @@ -use super::{Affine, BlendMode, Dash, FillRule, HAIRLINE, Knockout, LayerColor, LineCap, LineJoin, Named, Node, Obj, Paint, Rect, V}; +use super::{ + Affine, BlendMode, Dash, FillRule, HAIRLINE, Knockout, LayerColor, LineCap, LineJoin, Named, Node, NodeId, Obj, PageBox, Paint, Rect, V, +}; /// Deepest nesting of layers, groups and dictionaries. pub(super) const MAX_DEPTH: usize = 256; @@ -310,6 +312,43 @@ pub(super) struct Place { /// The longest relative folder of a placed file read (bytes). pub(super) const MAX_PLACED_FOLDER: usize = 4096; +/// The dictionary entry after a placed PDF or `.ai` file that holds the page shown (1-based). +pub(super) const PLACED_PAGE: &str = "AI11 PlacedPDFPageIndex"; +/// The dictionary entry after a placed PDF or `.ai` file that holds the box it is cropped to. +pub(super) const PLACED_CROP: &str = "AI11 PlacedPDFCropTo"; + +/// The placed file just read, until the dictionary after it: it may say the file is a page of a +/// PDF or `.ai` file. +#[derive(Debug)] +pub(super) struct PlacedTail { + /// Its linked image. + pub(super) id: NodeId, + /// Its image's key in the document's images. + pub(super) key: String, + /// The size of its box. + pub(super) size: (f64, f64), + /// Its image's blob was stored for it, not shared with an earlier image. + pub(super) fresh: bool, + /// The frames open when it was read: its dictionary closes at the same depth. + pub(super) depth: usize, +} + +/// A placed file the dictionary after it says is a page of a PDF or `.ai` file. Its linked image +/// becomes a placed document once the layers have been compared with the page. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlacedPage { + /// The linked image. + pub id: NodeId, + /// The page shown (1-based). + pub page: u32, + /// The art's bounds are shown, not `page_box`. + pub bounding: bool, + /// The box of the page shown. + pub page_box: PageBox, + /// The size of the placed file's box. + pub size: (f64, f64), +} + /// What the open result says of placed files: they show the preview the file keeps of them. pub(super) const LINKED_PREVIEWS: &str = "linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel"; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 831be2edd..59f9ecd9b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3288,6 +3288,211 @@ fn placed_files_of_other_paths_keep_images_of_their_own() { assert_eq!(r.doc.images.len(), 2); } +/// A placed file followed by the dictionary `numbers` (`value /Int (key)` lines, on `%_` lines). +fn placed_with(numbers: &[(&str, &str)]) -> Structure { + let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat(); + let entries: String = numbers.iter().map(|(key, v)| format!("%_{v} /Int ({key}) ,\r")).collect(); + let dictionary = format!("%_/ArtDictionary :\r{entries}%_;\r%_\r"); + read(&placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), &dictionary)).unwrap() +} + +#[test] +fn a_placed_files_dictionary_names_the_page_and_box_it_shows() { + use vectorcraft_doc::PageBox; + const PAGE: &str = "AI11 PlacedPDFPageIndex"; + const CROP: &str = "AI11 PlacedPDFCropTo"; + for (crop, bounding, page_box) in [ + ("0", false, PageBox::Art), + ("1", false, PageBox::Crop), + ("2", false, PageBox::Trim), + ("3", false, PageBox::Bleed), + ("4", false, PageBox::Media), + ("5", true, PageBox::Crop), + ] { + let r = placed_with(&[(PAGE, "3"), (CROP, crop)]); + let a = art(&r.doc); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert_eq!(r.placed_pages.len(), 1, "{crop}"); + let p = r.placed_pages[0]; + assert_eq!((p.id, p.page, p.bounding, p.page_box, p.size), (a[0].id, 3, bounding, page_box, (4.0, 2.0)), "{crop}"); + assert_eq!(r.doc.images.keys().collect::>(), [&im.key], "{crop}: the preview is stored once, under the image's key"); + assert!(r.doc.images[&im.key].is_proxy(), "{crop}"); + } + // One entry alone: the first page, or the page's crop box. + let r = placed_with(&[(CROP, "4")]); + assert_eq!((r.placed_pages[0].page, r.placed_pages[0].page_box), (1, PageBox::Media)); + let r = placed_with(&[(PAGE, "2")]); + assert_eq!((r.placed_pages[0].bounding, r.placed_pages[0].page_box), (false, PageBox::Crop)); +} + +#[test] +fn a_placed_files_dictionary_it_cant_read_leaves_a_linked_image() { + const PAGE: &str = "AI11 PlacedPDFPageIndex"; + const CROP: &str = "AI11 PlacedPDFCropTo"; + let plain = placed_with(&[]); + let plain_key = { + let a = art(&plain.doc); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + im.key.clone() + }; + assert!(plain.placed_pages.is_empty()); + for entries in [ + vec![(PAGE, "0"), (CROP, "4")], + vec![(PAGE, "-1"), (CROP, "4")], + vec![(PAGE, "1.5"), (CROP, "4")], + vec![(PAGE, "1e30"), (CROP, "4")], + vec![(PAGE, "2"), (CROP, "6")], + vec![(PAGE, "2"), (CROP, "-1")], + vec![(PAGE, "2"), (CROP, "0.5")], + vec![(PAGE, "(2)"), (CROP, "4")], + vec![(PAGE, "2"), (CROP, "(4)")], + vec![(PAGE, "(2)")], + ] { + let r = placed_with(&entries); + assert!(r.placed_pages.is_empty(), "{entries:?}"); + let a = art(&r.doc); + let NodeKind::Image(im) = &a[0].kind else { panic!("{entries:?}: {:?}", a[0].kind) }; + assert_eq!((im.key.as_str(), im.link.as_ref().and_then(|l| l.page)), (plain_key.as_str(), None), "{entries:?}"); + } +} + +/// The dictionaries of the art in a placed file's opacity mask, read inside its own dictionary, +/// don't speak for the placed file. +#[test] +fn a_placed_files_mask_art_leaves_its_page_to_the_placed_files_dictionary() { + let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat(); + let dictionary = "%_/ArtDictionary :\r%_/Mask :\r%_X=\r%_0 g\r%_0 0 m\r%_5 0 l\r%_5 -5 l\r%_f\r\ + %_/ArtDictionary :\r%_/XMLUID : (Inner) ; (AI10_ArtUID) ,\r%_;\r%_\r%_X+\r%_ (Art) ,\r%_; (AI9_Mask) ,\r\ + %_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_4 /Int (AI11 PlacedPDFCropTo) ,\r%_;\r%_\r"; + let r = read(&placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), dictionary)).unwrap(); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:?}"); + assert!(a[0].extras().mask.is_some(), "the mask is read"); + assert_eq!(r.placed_pages.len(), 1); + assert_eq!((r.placed_pages[0].id, r.placed_pages[0].page_box), (a[0].id, vectorcraft_doc::PageBox::Media)); +} + +/// A placed file in a placed file's opacity mask, read before the outer file's dictionary closes, +/// leaves the outer file's page to that dictionary. +#[test] +fn a_placed_file_in_a_placed_files_mask_leaves_the_outer_files_page() { + let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat(); + let mut inner = + b"%_%AI5_BeginPlace\r%_%%FileType: 1\r%_[5 0 0 -5 0 200] 10 20 14 22 4 4 0\r%_(/art/mask.pdf)`\r%_%AI26_BeginPlacedObjectPreview\r%_" + .to_vec(); + inner.extend_from_slice(&preview); + inner.extend_from_slice( + b"\r%_%AI26_EndPlacedObjectPreview\r%_~\r%_%AI5_EndPlace\r%_/ArtDictionary :\r%_5 /Int (AI11 PlacedPDFPageIndex) ,\r%_;\r%_\r", + ); + let mut dictionary = b"%_/ArtDictionary :\r%_/Mask :\r%_X=\r".to_vec(); + dictionary.extend_from_slice(&inner); + dictionary + .extend_from_slice(b"%_X+\r%_ (Art) ,\r%_; (AI9_Mask) ,\r%_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_4 /Int (AI11 PlacedPDFCropTo) ,\r%_;\r%_\r"); + let data = placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), "@@DICT@@"); + let at = data.windows(8).position(|x| x == b"@@DICT@@").unwrap(); + let data = [&data[..at], &dictionary[..], &data[at + 8..]].concat(); + let r = read(&data).unwrap(); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:?}"); + assert!(a[0].extras().mask.is_some(), "the mask is read"); + let outer: Vec<_> = r.placed_pages.iter().filter(|p| p.id == a[0].id).collect(); + assert_eq!(outer.len(), 1, "{:?}", r.placed_pages); + assert_eq!((outer[0].page, outer[0].page_box), (2, vectorcraft_doc::PageBox::Media)); + assert!(r.placed_pages.iter().any(|p| p.id != a[0].id && p.page == 5), "the inner one names its own page: {:?}", r.placed_pages); +} + +/// A placed file in a placed file's opacity mask that shows the same file with no page of its own +/// keeps the preview they share when the outer one's dictionary names a page. +#[test] +fn a_placed_file_in_a_placed_files_mask_keeps_the_preview_they_share() { + let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat(); + let mut dictionary = + b"%_/ArtDictionary :\r%_/Mask :\r%_X=\r%_%AI5_BeginPlace\r%_%%FileType: 1\r%_[5 0 0 -5 0 200] 10 20 14 22 4 4 0\r%_(/art/flyer.pdf)`\r\ + %_%AI26_BeginPlacedObjectPreview\r%_" + .to_vec(); + dictionary.extend_from_slice(&preview); + dictionary.extend_from_slice( + b"\r%_%AI26_EndPlacedObjectPreview\r%_~\r%_%AI5_EndPlace\r%_X+\r%_ (Art) ,\r%_; (AI9_Mask) ,\r\ + %_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_;\r%_\r", + ); + let data = placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), "@@DICT@@"); + let at = data.windows(8).position(|x| x == b"@@DICT@@").unwrap(); + let data = [&data[..at], &dictionary[..], &data[at + 8..]].concat(); + let r = read(&data).unwrap(); + let a = art(&r.doc); + assert_eq!(r.placed_pages.len(), 1, "{:?}", r.placed_pages); + let mask = a[0].extras().mask.clone().expect("the mask is read"); + let mut keys = vec![]; + mask.art.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + keys.push(im.key.clone()); + } + }); + let [inner] = &keys[..] else { panic!("{keys:?}") }; + assert!(r.doc.images.contains_key(inner), "the mask's image keeps its preview"); + let NodeKind::Image(outer) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert!(r.doc.images.contains_key(&outer.key) && outer.key != *inner); +} + +#[test] +fn a_placed_file_with_its_box_written_backward_stays_a_linked_image() { + let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat(); + let dictionary = "%_/ArtDictionary :\r%_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_4 /Int (AI11 PlacedPDFCropTo) ,\r%_;\r%_\r"; + let data = placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), dictionary); + let at = data.windows(11).position(|x| x == b"10 20 14 22").unwrap(); + let data = [&data[..at], b"14 22 10 20", &data[at + 11..]].concat(); + let r = read(&data).unwrap(); + assert!(r.placed_pages.is_empty()); + let a = art(&r.doc); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert_eq!(im.link.as_ref().and_then(|l| l.page), None); +} + +#[test] +fn only_the_dictionary_right_after_a_placed_file_names_its_page() { + let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat(); + let dictionary = "%_/ArtDictionary :\r%_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_4 /Int (AI11 PlacedPDFCropTo) ,\r%_;\r%_\r"; + // After another object: that object's dictionary. Art with such a dictionary stays art. + let after = format!("0 g\r0 0 m\r5 0 l\r5 5 l\rf\r{dictionary}"); + let r = read(&placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), &after)).unwrap(); + assert!(r.placed_pages.is_empty()); + assert_eq!(art(&r.doc).len(), 2); + // A second dictionary after the first: the first one spoke for the placed file. + let after = format!("%_/ArtDictionary :\r%_(Photo) /String (AIArtName) ,\r%_;\r%_\r{dictionary}"); + let r = read(&placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), &after)).unwrap(); + assert!(r.placed_pages.is_empty()); + // Without a placed file before it, nothing happens. + let r = read(stream(&layer(&format!("0 g\r0 0 m\r5 0 l\r5 5 l\rf\r{dictionary}"))).as_bytes()).ok(); + assert!(r.is_none_or(|r| r.placed_pages.is_empty())); +} + +/// Two pages of a file with the same preview don't share a key: reading one page's file in would +/// change the other's image. +#[test] +fn placed_pages_of_one_file_keep_images_of_their_own() { + let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat(); + let one = + placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), "%_/ArtDictionary :\r%_1 /Int (AI11 PlacedPDFPageIndex) ,\r%_;\r%_\r"); + let two = + placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), "%_/ArtDictionary :\r%_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_;\r%_\r"); + let find = |d: &[u8], w: &[u8]| d.windows(w.len()).position(|x| x == w).unwrap(); + let body = two[find(&two, b"%AI5_BeginPlace")..find(&two, b"LB\n%AI5_EndLayer")].to_vec(); + let mut both = one.clone(); + let at = find(&both, b"LB\n%AI5_EndLayer"); + both.splice(at..at, body); + let r = read(&both).unwrap(); + assert_eq!(r.placed_pages.len(), 2); + let keys: Vec = art(&r.doc) + .iter() + .map(|n| match &n.kind { + NodeKind::Image(im) => im.key.clone(), + k => panic!("{k:?}"), + }) + .collect(); + assert_ne!(keys[0], keys[1]); + assert!(keys.iter().all(|k| r.doc.images.contains_key(k)) && r.doc.images.len() == 2, "{keys:?}"); +} + #[test] fn placed_files_are_linked_images_showing_their_preview() { // A 2×1 preview (premultiplied ARGB, a padded row) of a file of 4×2 pixels. diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index bdb99e94c..7e6dd4cfc 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -9,6 +9,7 @@ use super::slots::{ use super::{ Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, VariableKind, Vec2, ai, }; +use vectorcraft_doc::PlacedDocument; /// Which box of the editing copy is the page. #[derive(Clone, Copy)] @@ -203,6 +204,7 @@ pub(super) fn build_from( restyled, placeholders, placed_elsewhere, + placed_pages, envelopes_drawn, styles_read, canvas_scale, @@ -491,6 +493,7 @@ pub(super) fn build_from( } doc.limit_depth_noting(&mut notes); bind_variables(&mut doc, &bindings, bindings_skipped, &xml_ids, &filled, &mut notes); + placed_documents(&mut doc, &placed_pages); if small.is_some() { scale_document(&mut doc, unit, styles_read); relayout_wrapped(&mut doc); @@ -498,6 +501,56 @@ pub(super) fn build_from( Ok((doc, notes)) } +/// The linked images of `pages` that are still in `doc`, opacity-mask art included, as placed +/// documents of their page and box, over the same place, each showing the same preview until its +/// file is read. An image bound to an image variable stays a linked image, the object an image +/// variable binds. +fn placed_documents(doc: &mut Document, pages: &[ai::PlacedPage]) { + if pages.is_empty() { + return; + } + let vars = &doc.variables; + let image_bound = |id: &NodeId| { + vars.bindings.get(id).is_some_and(|names| names.iter().any(|v| vars.variable(v).is_some_and(|v| v.kind == VariableKind::Image))) + }; + let pages: std::collections::HashMap = pages.iter().filter(|p| !image_bound(&p.id)).map(|p| (p.id, *p)).collect(); + for layer in &mut doc.layers { + to_placed_documents(Arc::make_mut(layer), &pages); + } +} + +/// [`placed_documents`] in `n` and everything in it. +fn to_placed_documents(n: &mut Node, pages: &std::collections::HashMap) { + // A box too small to take the image's pixels to (its transform not finite) keeps the linked image. + if let Some(page) = pages.get(&n.id) + && let NodeKind::Image(im) = &n.kind + && let Some(link) = &im.link + && let (width, height) = page.size + && let xf = im.xf * Affine::scale_non_uniform(f64::from(im.width) / width, f64::from(im.height) / height) + && xf.as_coeffs().iter().all(|v| v.is_finite()) + && xf.determinant().abs() > 1e-12 + { + let link = vectorcraft_doc::LinkInfo { page: Some(page.page), ..link.clone() }; + let placed = PlacedDocument { + link, + key: im.key.clone(), + bounding: page.bounding, + page_box: page.page_box, + width, + height, + xf, + placement: im.placement, + }; + n.kind = NodeKind::PlacedDocument(Box::new(placed)); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + to_placed_documents(Arc::make_mut(&mut m.art), pages); + } + for c in n.children_mut().into_iter().flatten() { + to_placed_documents(Arc::make_mut(c), pages); + } +} + /// Bind the objects of `doc` to the variables of its library that `bindings` binds them to, each /// object named by its XML id (`ids`: the object read with that id, or what `filled` made of a text /// object's slot: for a visibility variable every object that took the slot's place, for a text diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index a2d6ec2af..d3fe5ec64 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5329,6 +5329,103 @@ fn a_placed_file_at_its_boxs_corner_matches_the_page() { assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 70.0, b.y1 - 70.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); } +/// A placed file of [`a_placed_file_at_its_boxs_corner_matches_the_page`] whose dictionary names +/// page 2 and its media box: `%_` lines when `hidden` (art inside another object's dictionary). +fn placed_page_two(hidden: bool) -> String { + let lines = [ + "%AI5_BeginPlace".to_string(), + "%%FileType: 1".into(), + "[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0".into(), + "(/files/pages.pdf)`".into(), + "%AI26_BeginPlacedObjectPreview".into(), + format!("2 2 8 32 17 {}", "\x7f\0\0\0".repeat(4)), + "%AI26_EndPlacedObjectPreview".into(), + "~".into(), + "%AI5_EndPlace".into(), + ]; + let hide = if hidden { "%_" } else { "" }; + let place: String = lines.iter().map(|l| format!("{hide}{l}\n")).collect(); + format!( + "{place}%_/ArtDictionary :\n%_/XMLUID : (pages) ; (AI10_ArtUID) ,\n%_2 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n" + ) +} + +/// The placed document of `n`, with its bounds. +fn placed_document(n: &Node) -> (&vectorcraft_doc::PlacedDocument, vectorcraft_geom::Rect) { + let NodeKind::PlacedDocument(p) = &n.kind else { panic!("{:?}", n.kind) }; + (p, n.geometric_bounds().unwrap()) +} + +/// A placed PDF page comes in as a placed document of its page and box, where its linked image +/// would have gone: at its box's corner when that is where the layers draw as the page does. +#[test] +fn a_placed_pdf_page_is_a_placed_document_where_its_image_would_be() { + let data = native(&layer("Jig", true, &placed_page_two(false))).replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers"); + let mut page = Document::new(100.0, 100.0); + let id = page.alloc_id(); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)); + let grey = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); + let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(grey)], ..Default::default() }); + let l = page.layers[0].id; + page.insert(Some(l), 0, n).unwrap(); + let (d, w, read) = crate::layered_ai(&private(&data), page, vec![], false, false, 1.0); + assert!(read, "{w:?}"); + let (p, b) = placed_document(&d.layers[0].children().unwrap()[0]); + assert_eq!((p.link.path.as_str(), p.link.page, p.page_box, p.bounding), ("/files/pages.pdf", Some(2), vectorcraft_doc::PageBox::Media, false)); + assert_eq!((p.width, p.height), (40.0, 40.0)); + assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 70.0, b.y1 - 70.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); + assert!(d.images[&p.key].is_proxy(), "the preview until the file is read"); +} + +/// A placed PDF page in an object's opacity mask is a placed document there too. +#[test] +fn a_placed_pdf_page_in_an_opacity_mask_is_a_placed_document() { + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{}%_X+\n%_ (Art) ,\n%_; (AI9_Mask) ,\n%_;\n%_\n", placed_page_two(true)); + let data = native(&layer("Masked", true, &format!("{}\n{mask}", square(10, 10)))); + let (d, notes) = crate::ai_alone(&private(&data)).unwrap(); + let square = &d.layers[0].children().unwrap()[0]; + let mask = square.extras().mask.clone().unwrap_or_else(|| panic!("{notes:?}")); + let art = if let NodeKind::Group { children, .. } = &mask.art.kind { children[0].clone() } else { mask.art.clone() }; + let (p, _) = placed_document(&art); + assert_eq!((p.link.page, p.page_box), (Some(2), vectorcraft_doc::PageBox::Media)); +} + +/// A placed PDF page whose box is too small for a finite placement stays a linked image. +#[test] +fn a_placed_pdf_page_too_small_to_place_stays_a_linked_image() { + let place = placed_page_two(false).replace("[1 0 0 -1 -7970 8070] 8000 8000 8040 8040", "[1e308 0 0 -1 0 200] 0 0 1e-309 2"); + let data = native(&layer("Jig", true, &place)); + let (d, notes) = crate::ai_alone(&private(&data)).unwrap(); + let mut kinds = vec![]; + d.walk(|n| match &n.kind { + NodeKind::Image(im) => kinds.push(("image", im.xf.as_coeffs().iter().all(|v| v.is_finite()))), + NodeKind::PlacedDocument(p) => kinds.push(("placed", p.xf.as_coeffs().iter().all(|v| v.is_finite()))), + _ => {} + }); + assert_eq!(kinds, [("image", true)], "{notes:?}"); +} + +/// A placed PDF page bound to an image variable stays a linked image, the object an image variable +/// binds, so the binding is kept. +#[test] +fn a_placed_pdf_page_bound_to_an_image_variable_stays_a_linked_image() { + use vectorcraft_testkit::ai::{XmlValue, variable_library_data, xml_element, xml_node}; + let declared = vec![xml_node( + 1, + "variable", + XmlValue::Str(""), + &[("trait", XmlValue::Str("fileref")), ("varName", XmlValue::Unicode("v_file")), ("docRef", XmlValue::Raw("/XMLUIDREF : (pages) ;"))], + &[], + )]; + let set = xml_element("variableSet", &[("locked", "none")], &[xml_element("variables", &[], &declared)]); + let library = variable_library_data(&xml_element("variableSets", &[], &[set])); + let data = native(&(library + &layer("Jig", true, &placed_page_two(false)))); + let (d, notes) = crate::ai_alone(&private(&data)).unwrap(); + let n = &d.layers[0].children().unwrap()[0]; + assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_ref().is_some_and(|l| l.page.is_none())), "{:?} {notes:?}", n.kind); + assert_eq!(d.variables.objects_of("v_file"), [n.id], "{notes:?}"); +} + #[test] fn a_small_placed_file_goes_where_it_draws_closer_to_the_page() { // A 2 × 2 placed file (a small logo on a large page) whose box corner the matrix takes to diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9ab9f7fb2..9fc6094fd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -215,6 +215,23 @@ 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; 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 comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 49b266520..e0155747e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1313,6 +1313,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. Placed (linked) files in a `.ai`'s editing data come in 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 (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); 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 made taller with a warning in the open result (#1392). Placed (linked) files in a `.ai`'s editing data come in 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 (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); 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 made taller with a warning in the open result (#1392). + A placed PDF or `.ai` file in a `.ai`'s editing data comes in as a placed document instead of a linked image: it shows the page and Crop to box its dictionary names, and the preview the file keeps until its file is found, then the page as vectors. A placed file in opacity-mask art keeps showing its preview (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/placed_document.rs`). In a `.ai` or EPS opened from its editing data, and in a legacy `.ai`, each anchor is smooth or a corner as the case of the segment operator ending on it says (`l`, `c`, `v`, `y` smooth; `L`, `C`, `V`, `Y` corner), and a closed path's first anchor takes the case of the segment back to it; points and handles stay where they are. Paths, compound paths, clipping paths, guide paths, area type frames and type-on-a-path paths all keep it (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/legacy_ai/tests.rs`). In `.ai` and EPS editing-data import, a clipping group, layer or opacity mask's art with several clipping paths (`W`) clips to their intersection: each clipping path after the first clips a clipping group nested inside the one before it, and each keeps its own stroke and transparency. A compound path of clipping paths (`*u` … `*U`) is still one clipping path, and clipping paths that would nest the art past the document's 32 levels stop the editing data from being read (`crates/eps/src/import/ai/tests.rs`). `.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). From c0a8ebb70f6b9c622a26b51b6ecd7477cb429077 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:26:44 +0700 Subject: [PATCH 07/93] Graphs: graphic styles and Default and Swap Fill and Stroke update a series' stored paint Squashed from #6094 onto main. --- crates/engine/src/cmd/appearance.rs | 29 ++++ crates/engine/src/cmd/graph/tests.rs | 189 +++++++++++++++++++++++++++ crates/engine/src/cmd/paint.rs | 11 +- crates/engine/src/cmd/style.rs | 16 ++- docs/mcp.md | 29 ++-- docs/target-app-parity.md | 2 +- 6 files changed, 255 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 6e369f67b..4a1622c9d 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -661,6 +661,35 @@ 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 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))) + }; + let mut opacity_rows = top_opacity_changed(before, d, ids); + 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 +} + /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. fn top_row_kind(ap: &Appearance, at: usize) -> Option { let fill = ap.items.get(at)?.is_fill(); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 8b059c111..5825c5fc0 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1316,6 +1316,195 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { assert_eq!(spec(&s, id), kept); } +/// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless +/// `stroke`: the rectangle and the blue paint. +fn blue_style(s: &mut Session, name: &str, stroke: bool) -> (NodeId, Paint) { + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.setItem", &json!({"ids": [r.0], "index": top_row(s, r, true), "color": "#0000ff", "opacity": 50})).unwrap(); + if !stroke { + s.execute("appearance.removeItem", &json!({"ids": [r.0], "index": top_row(s, r, false)})).unwrap(); + } + s.execute("graphicStyle.new", &json!({"id": r.0, "name": name})).unwrap(); + let blue = s.doc().unwrap().doc.node(r).unwrap().appearance.fill_paint(); + (r, blue) +} + +/// Series 0's marks with a red 2 pt stroke stored from `marks[1]` and a 40% fill opacity stored from `marks[2]`. +fn stored_red_stroke() -> (Session, NodeId, Vec) { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[1], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], true), "opacity": 40})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke_width, kept.fill_opacity), (Some(2.0), Some(0.4))); + (s, id, marks) +} + +/// After the graph is drawn again, every mark of series 0 has `fill` at `opacity` and `stroke` at `weight`; two undos (the +/// data edit, then the command) bring back the paints the graph stored in `was`. +fn series_0_is(s: &mut Session, id: NodeId, (fill, opacity, stroke, weight): (&Paint, f32, &Paint, f64), was: &[vectorcraft_doc::SeriesPaint]) { + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + for m in series(&n, 0).children().unwrap() { + let f = m.appearance.fill().unwrap(); + assert_eq!((&f.paint, f.opacity), (fill, opacity)); + assert_eq!((&m.appearance.stroke_paint(), m.appearance.stroke_width()), (stroke, weight)); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was); +} + +/// A graphic style applied to a series mark is stored on the graph, its fill opacity included. A style without a stroke, +/// applied to a mark that shows one, leaves the series without a stroke. +#[test] +fn a_graphic_style_applied_to_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the style and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); +} + +/// A graphic style added on top of a series mark's appearance (Alt-click) stores the kinds of row the style shows: a style +/// with only a fill leaves the stroke the series stores. +#[test] +fn a_graphic_style_added_to_a_series_mark_keeps_only_its_own_kinds() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + let red = spec(&s, id).series_paints[0].stroke.clone().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0], "add": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0))); + series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was); +} + +/// A graphic style whose stroke row is hidden, added on top of a series mark (Alt-click), stores its fill and leaves the +/// stroke the series stores, although the mark shows a stroke of its own. +#[test] +fn a_graphic_style_with_a_hidden_stroke_added_to_a_series_mark_keeps_the_stored_stroke() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + let red = spec(&s, id).series_paints[0].stroke.clone().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + let (rect, blue) = blue_style(&mut s, "Blue", true); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, false), "visible": false})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Hidden stroke"})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Hidden stroke", "ids": [marks[0].0], "add": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0))); + series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was); +} + +/// Redefine Graphic Style restyles the series marks still linked to the style, and the graph stores the new look. +#[test] +fn redefining_a_graphic_style_updates_a_linked_series_mark() { + let (mut s, id, _, marks) = column_marks(); + let (rect, _) = blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap(); + let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), green, "the mark follows the style"); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(green.clone()), None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the redefinition and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + let (stroke, weight) = { + let r = s.doc().unwrap().doc.node(rect).unwrap().clone(); + (r.appearance.stroke_paint(), r.appearance.stroke_width()) + }; + series_0_is(&mut s, id, (&green, 1.0, &stroke, weight), &was); +} + +/// Redefine Graphic Style made from a series mark does not edit that mark, so the graph keeps the paint another mark of the +/// series stored after it: the graph drawn again shows the same marks with or without the redefinition, and undo brings +/// back the stored paint. +#[test] +fn redefining_a_graphic_style_from_a_series_mark_keeps_the_paint_another_mark_stored() { + let (mut s, id, _, marks) = column_marks(); + blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#00ff00"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); + let was = spec(&s, id).series_paints; + assert_eq!(was[0].fill, Some(red.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + let without = marks_of(&s, id, 0); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": marks[1].0})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "the source mark was not edited"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(marks_of(&s, id, 0), without); + assert!(without.iter().all(|m| m.0 == red)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph. +#[test] +fn default_fill_and_stroke_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + s.execute("paint.default", &json!({"ids": [marks[0].0]})).unwrap(); + let (white, black) = (Paint::solid(vectorcraft_color::Color::WHITE), Paint::solid(vectorcraft_color::Color::BLACK)); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(white.clone()), None)); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(black.clone()), Some(1.0))); + series_0_is(&mut s, id, (&white, 1.0, &black, 1.0), &was); +} + +/// Swap Fill and Stroke on a series mark stores the swapped paints and the stroke weight, and leaves the opacity the series +/// stores. +#[test] +fn swapping_a_series_marks_fill_and_stroke_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let (gray, green) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint()); + let was = spec(&s, id).series_paints; + s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(green.clone()), Some(0.4))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(gray.clone()), Some(3.0))); + series_0_is(&mut s, id, (&green, 0.4, &gray, 3.0), &was); +} + +/// Swap Fill and Stroke writes the topmost fill even when it is hidden: the visible fill under it does not replace the +/// fill the series stores. +#[test] +fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() { + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (_, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); + let red = spec(&s, id).series_paints[0].fill.clone(); + assert_eq!(red, Some(s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint())); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, red); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 53a592492..b1776c9a0 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Swap Fill and Stroke", [], Some("Shift+X"), - "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults", + "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)", has_doc, swap ), @@ -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", + "{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)", has_doc, default_paint ), @@ -851,7 +851,9 @@ fn swap(s: &mut Session, p: &Value) -> Result { restore_unpainted_stroke(&mut n.appearance, None, &f); n.appearance.set_stroke(f); } - Ok(super::appearance::count_changed(d, &before, &ids)) + // 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); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 { @@ -879,7 +881,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { _ => n.appearance = Appearance::default_art().into(), } } - Ok(super::appearance::count_changed(d, &before, &ids)) + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } super::newart::reset(s); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 36780707f..ae8f9f69d 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. 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 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}", 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 → {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 and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), @@ -333,6 +333,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { s.new_art_style(&g, add); return Ok(json!({ "newArt": true })); } + let before = s.doc()?.doc.clone(); s.edit("Apply Graphic Style", |d, _| { if add { let g = d.graphic_styles[i].clone(); @@ -344,12 +345,17 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { n.extras_mut().graphic_style = None; } } + // 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); } 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); } Ok(()) })?; @@ -456,6 +462,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let look = GraphicStyle::of(String::new(), captured(n), n); let name = d.graphic_styles[i].name.clone(); let chars = s.prefs.override_char_color; + let before = s.doc()?.doc.clone(); s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| { let id = d.graphic_style_id(i); let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); @@ -465,9 +472,10 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); - for nid in follow { - apply_style(d, nid, &g, chars); + for nid in &follow { + apply_style(d, *nid, &g, chars); } + super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/docs/mcp.md b/docs/mcp.md index 3e43b8e60..b5a6a2d3c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -566,18 +566,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` and `appearance.copyFrom` store the paint, -weight and opacity of the topmost visible fill and stroke on the graph. 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. 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 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`, `paint.swap`, `paint.default`, graphic styles and the recolor commands do not store a -series' paint. An `appearance.*` command given a series group's id edits the group's own rows, and the graph does not -store them. +`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 +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 +not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and +`graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store +them. `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e0155747e..326166c27 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -872,7 +872,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 or the Eyedropper, 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, 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). - **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. From e9ee87b7cda0a78119020ef9f0062bf9a048f164 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:27:03 +0700 Subject: [PATCH 08/93] i18n: error messages in every catalog keep true, false, null and the keys they name Squashed from #6100 onto main. --- crates/ui-egui/src/i18n/bg.tsv | 44 ++++----- crates/ui-egui/src/i18n/bs.tsv | 18 ++-- crates/ui-egui/src/i18n/ca.tsv | 24 ++--- crates/ui-egui/src/i18n/cs.tsv | 18 ++-- crates/ui-egui/src/i18n/da.tsv | 22 ++--- crates/ui-egui/src/i18n/de.tsv | 8 +- crates/ui-egui/src/i18n/el.tsv | 32 +++--- crates/ui-egui/src/i18n/es.tsv | 14 +-- crates/ui-egui/src/i18n/fi.tsv | 26 ++--- crates/ui-egui/src/i18n/fr.tsv | 16 +-- crates/ui-egui/src/i18n/hi.tsv | 88 ++++++++--------- crates/ui-egui/src/i18n/hu.tsv | 8 +- crates/ui-egui/src/i18n/id.tsv | 12 +-- crates/ui-egui/src/i18n/it.tsv | 16 +-- crates/ui-egui/src/i18n/ja.tsv | 14 +-- crates/ui-egui/src/i18n/ka.tsv | 14 +-- crates/ui-egui/src/i18n/ko.tsv | 18 ++-- crates/ui-egui/src/i18n/nl.tsv | 22 ++--- crates/ui-egui/src/i18n/pl.tsv | 24 ++--- crates/ui-egui/src/i18n/pt-br.tsv | 12 +-- crates/ui-egui/src/i18n/ru.tsv | 18 ++-- crates/ui-egui/src/i18n/sv.tsv | 24 ++--- crates/ui-egui/src/i18n/tests.rs | 145 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 18 ++-- crates/ui-egui/src/i18n/vi.tsv | 22 ++--- crates/ui-egui/src/i18n/zh-hans.tsv | 14 +-- crates/ui-egui/src/i18n/zh-hant.tsv | 14 +-- 28 files changed, 426 insertions(+), 281 deletions(-) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6cc8fb578..c83f7f43f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3886,7 +3886,7 @@ axis Both И двете Float Tool Group Плаваща група инструменти @msg no library `{id}` няма библиотека `{id}` @msg select some type first първо изберете текст -@msg kerning must be a number, "auto", or "optical" Кернингът трябва да е число, „auto“ или „optical“ +@msg kerning must be a number, "auto", or "optical" kerning трябва да е число, „auto“ или „optical“ @msg {_1} has no {_2} style {_1} няма стил {_2} @msg a layer couldn't be read слой не можа да бъде прочетен @msg it has more than one artboard има повече от една работна област @@ -4063,10 +4063,10 @@ axis Both И двете @msg already editing a pattern мострата вече се редактира @msg an object can't be read обектът не може да бъде прочетен @msg an object in perspective would leave the edited grid обект в перспектива би напуснал редактираната мрежа -@msg antiAlias `{id}`: none, art or type заглаждане `{id}`: няма, художествено или текстово +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art или type @msg artboard {0} does not exist работна област {0} не съществува @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard при този мащаб чертежът е {w} × {h} pt, повече отколкото платното побира: изберете по-малък мащаб или го вгонесете в работната област -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` оста трябва да бъде "vertical", "horizontal" или ъгъл в градуси, а не `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis трябва да бъде "vertical", "horizontal" или ъгъл в градуси, а не `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: прозрачен, бял, черен или цвят като "#ff8800" @msg bad color {v} грешен цвят {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio трябва да бъде число от 0,1 до 10 @@ -4075,9 +4075,9 @@ axis Both И двете @msg bad shortcut грешен клавишен бърз път @msg bad stream length грешна дължина на потока @msg bad trailer грешен край (trailer) -@msg balance must be between 0 and 100 балансът трябва да бъде между 0 и 100 +@msg balance must be between 0 and 100 balance трябва да бъде между 0 и 100 @msg batch step {_1} (`{id}`) failed: {e} стъпка от пакет {_1} (`{id}`) неуспешна: {e} -@msg brightness must be dark|mediumDark|mediumLight|light яркостта трябва да бъде dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness трябва да бъде dark|mediumDark|mediumLight|light @msg can't encode the document: {0} документът не може да бъде кодиран: {0} @msg can't parse shortcut `{c}` клавишният бърз път `{c}` не може да бъде разпознат @msg can't parse shortcut `{s}` for `{k}` клавишният бърз път `{s}` за `{k}` не може да бъде разпознат @@ -4102,7 +4102,7 @@ axis Both И двете @msg compound paths can only contain paths съставните пътища могат да съдържат само пътища @msg compressed (RLE) bitmaps can't be read yet компресираните (RLE) битови карти все още не могат да бъдат четени @msg damaged повреден -@msg decimals must be {_1}–{_2} (got {_3}) десетичните знаци трябва да бъдат {_1}–{_2} (получени {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals трябва да бъде {_1}–{_2} (получено {_3}) @msg divide into 1 to {MAX_PIECES} slices разделяне на 1 до {MAX_PIECES} парчета @msg effect {k} is `{_1}`, not `{id}` ефектът {k} е `{_1}`, а не `{id}` @msg empty brush stroke празен щрих с четка @@ -4120,9 +4120,9 @@ axis Both И двете @msg an image has no pixels and was left out изображение без пиксели беше пропуснато @msg image data `{_1}` is missing данните за изображение `{_1}` липсват @msg image would be too large; lower the resolution изображението ще бъде твърде голямо; намалете разделителната способност -@msg index must be a number or first, previous, next or last индексът трябва да е число или first, previous, next или last -@msg index must be a number or first, previous, next or last, not `{other}` индексът трябва да е число или first, previous, next или last, а не `{other}` -@msg index must be ≥ 0 индексът не може да е отрицателен +@msg index must be a number or first, previous, next or last index трябва да е число или first, previous, next или last +@msg index must be a number or first, previous, next or last, not `{other}` index трябва да е число или first, previous, next или last, а не `{other}` +@msg index must be ≥ 0 index трябва да е ≥ 0 @msg installing from a path is not available on the web; pass the module as `dataBase64` инсталирането от път не е налично в уеб; предайте модула като `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) вътрешна грешка в `{cmd}`: {msg} (документът беше оставен в състоянието си отпреди; моля, докладвайте тази грешка) @msg internal error: {msg} (please report this bug) вътрешна грешка: {msg} (моля, докладвайте тази грешка) @@ -4294,9 +4294,9 @@ axis Both И двете @msg replace: the object to replace was changed or deleted замяна: обектът за замяна е променен или изтрит @msg pattern missing липсва мостра @msg patterns nested too deeply are filled with mid-grey мострите, вложени прекалено дълбоко, се запълват със средно сиво -@msg percent must be above 0 and at most 6400, not {p} процентът трябва да е над 0 и най-много 6400, а не {p} +@msg percent must be above 0 and at most 6400, not {p} percent трябва да е над 0 и най-много 6400, а не {p} @msg pick a colour, gradient or pattern to save as a swatch изберете цвят, градиент или мостра за запазване като образец -@msg plane must be left, right or ground равнината трябва да е лява, дясна или земна +@msg plane must be left, right or ground plane трябва да е left, right или ground @msg plug-in ABI: {0} ABI на плъгин: {0} @msg plug-in exceeded its {0} плъгинът надвиши своя {0} @msg plug-in failed: {0} плъгинът се провали: {0} @@ -4315,8 +4315,8 @@ axis Both И двете @msg result would exceed the canvas (coordinates out of range) резултатът ще надвиши платното (координатите са извън обхвата) @msg objects can't be nested more than 32 deep обектите не могат да бъдат вложени на повече от 32 нива @msg the SVG's elements are nested more than {0} deep елементите на SVG са вложени на повече от {0} нива -@msg scale must be a number from {_1} to {_2}, not {_3} мащабът трябва да е число от {_1} до {_2}, а не {_3} -@msg scale must be a number from {_1} to {_2}, not {s} мащабът трябва да е число от {_1} до {_2}, а не {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale трябва да е число от {_1} до {_2}, а не {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale трябва да е число от {_1} до {_2}, а не {s} @msg select (or target) objects whose appearance isn't basic изберете (или насочете към) обекти, чийто изглед не е основен @msg select a Live Paint group изберете група за интерактивно оцветяване (Live Paint) @msg select a blend изберете преход (blend) @@ -4767,24 +4767,24 @@ effect Soft Меко Resize panels Преоразмеряване на панели Resize panel window Преоразмеряване на прозореца на панела @msg Invalid panel layout; restored the default dock Невалидно оформление на панела; възстановено е подразбиращото се разположение -@msg action is required необходимо е действие -@msg anchor must name a registered panel опорната точка трябва да указва регистриран панел -@msg before must name a panel параметърът „преди“ трябва да указва панел +@msg action is required action е задължителен +@msg anchor must name a registered panel anchor трябва да указва регистриран панел +@msg before must name a panel before трябва да указва панел @msg docking action contains an unknown panel or an excessive split path действието по закачане съдържа непознат панел или прекомерен път на разделяне @msg group must be true or false group трябва да е true или false -@msg panel is not in a dock group панелът не е в група на дока +@msg panel is not in a dock group panel не е в група на дока @msg only a dock group above or below others collapses, not one beside another свива се само група на дока над или под други, не и такава до друга @msg icon must be true or false icon трябва да е true или false @msg icon only docks a panel in the icon column, with no anchor icon закачва панел само в колоната с икони, без anchor @msg column docks a set as a dock column, with no anchor or icon column закачва набор като колона на дока, без anchor или icon @msg move requires a destination panel преместването изисква целеви панел @msg move requires an existing destination panel преместването изисква съществуващ целеви панел -@msg onto must name a floating panel параметърът „върху“ трябва да указва плаващ панел +@msg onto must name a floating panel onto трябва да указва плаващ панел @msg panel group is empty групата от панели е празна -@msg panel must name a registered panel панелът трябва да указва регистриран панел +@msg panel must name a registered panel panel трябва да указва регистриран панел @msg saved panel layout is invalid; reset the workspace first запазеното оформление на панелите е невалидно; нулирайте работното пространство първо -@msg zone must be a string зоната трябва да е низ -@msg zone must be tab, center, left, right, top or bottom зоната трябва да бъде tab, center, left, right, top или bottom +@msg zone must be a string zone трябва да е низ +@msg zone must be tab, center, left, right, top or bottom zone трябва да бъде tab, center, left, right, top или bottom @msg {key} must be a finite coordinate {key} трябва да е крайна координата @msg x and y must be provided together x и y трябва да бъдат подадени заедно @msg invalid restored panel group невалидна възстановена група от панели @@ -5366,7 +5366,7 @@ theme Appearance Външен вид Screen Mode Controls Контроли за режим на екран Drawer menu Меню на чекмеджето Toolbar: Show Controls Лента с инструменти: Показване на контроли -@msg control must be fillStroke, coloring, drawMode or screenMode контролът трябва да бъде fillStroke, coloring, drawMode или screenMode +@msg control must be fillStroke, coloring, drawMode or screenMode control трябва да бъде fillStroke, coloring, drawMode или screenMode @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode неизвестен контрол `{name}`: fillStroke, coloring, drawMode или screenMode @msg give a fraction `t` from 0 to 1 въведете дробно число `t` от 0 до 1 @msg give the path `id` въведете `id` на пътя diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 39a73d96a..8f7649026 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4237,7 +4237,7 @@ imageTrace Outline Obris @plural {n} stroke|{n} strokes {n} potez|{n} poteza|{n} poteza @msg no library `{id}` nema biblioteke `{id}` @msg select some type first prvo odaberite tekst -@msg kerning must be a number, "auto", or "optical" Podrezivanje mora biti broj, „auto” ili „optical” +@msg kerning must be a number, "auto", or "optical" kerning mora biti broj, „auto” ili „optical” @msg the selection has no gradient fill odabir nema ispunu gradijentom @msg the selection has no gradient stroke odabir nema potez gradijentom @msg the selection has no pattern fill odabir nema ispunu uzorkom @@ -4448,7 +4448,7 @@ imageTrace Outline Obris @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art ili type @msg artboard {0} does not exist radna ploča {0} ne postoji @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard u ovoj skali crtež je {w} × {h} pt, više nego što platno može primiti: odaberite manju skalu ili ga uklopite u radnu ploču -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` osa mora biti "vertical", "horizontal" ili ugao u stepenima, a ne `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis mora biti "vertical", "horizontal" ili ugao u stepenima, a ne `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" pozadina {other}: transparent, white, black ili boja poput "#ff8800" @msg bad color {v} loša boja {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio mora biti broj od 0,1 do 10 @@ -4462,7 +4462,7 @@ imageTrace Outline Obris @msg bad shortcut loša prečica @msg bad stream length loša dužina toka @msg bad trailer loš trailer -@msg balance must be between 0 and 100 balans mora biti između 0 i 100 +@msg balance must be between 0 and 100 balance mora biti između 0 i 100 @msg batch step {_1} (`{id}`) failed: {e} korak paketa {_1} (`{id}`) nije uspio: {e} @msg brightness must be dark|mediumDark|mediumLight|light brightness mora biti dark|mediumDark|mediumLight|light @msg can't encode the document: {0} nije moguće kodirati dokument: {0} @@ -4687,7 +4687,7 @@ imageTrace Outline Obris @msg replace: the object to replace was changed or deleted replace: objekat za zamjenu je promijenjen ili izbrisan @msg pattern missing uzorak nedostaje @msg patterns nested too deeply are filled with mid-grey previše duboko ugniježđeni uzorci ispunjavaju se srednje sivom -@msg percent must be above 0 and at most 6400, not {p} postotak mora biti iznad 0 i najviše 6400, a ne {p} +@msg percent must be above 0 and at most 6400, not {p} percent mora biti iznad 0 i najviše 6400, a ne {p} @msg pick a colour, gradient or pattern to save as a swatch odaberite boju, gradijent ili uzorak za čuvanje kao uzorak boje @msg plane must be left, right or ground plane mora biti left, right ili ground @msg plug-in ABI: {0} ABI dodatka: {0} @@ -4708,8 +4708,8 @@ imageTrace Outline Obris @msg result would exceed the canvas (coordinates out of range) rezultat bi premašio platno (koordinate izvan raspona) @msg objects can't be nested more than 32 deep objekti ne mogu biti ugniježđeni dublje od 32 nivoa @msg the SVG's elements are nested more than {0} deep elementi SVG-a ugniježđeni su dublje od {0} nivoa -@msg scale must be a number from {_1} to {_2}, not {_3} skala mora biti broj od {_1} do {_2}, a ne {_3} -@msg scale must be a number from {_1} to {_2}, not {s} skala mora biti broj od {_1} do {_2}, a ne {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale mora biti broj od {_1} do {_2}, a ne {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale mora biti broj od {_1} do {_2}, a ne {s} @msg select (or target) objects whose appearance isn't basic odaberite (ili ciljajte) objekte čiji izgled nije osnovni @msg select a Live Paint group odaberite grupu živog bojenja @msg select a blend odaberite stapanje @@ -5040,7 +5040,7 @@ imageTrace Outline Obris @msg the PSD file's image data can't be read: {e} podaci slike PSD datoteke se ne mogu pročitati: {e} @msg Imported library {name} Uvezena biblioteka {name} @msg Invalid panel layout; restored the default dock Nevažeći raspored panela; vraćeno je zadano sidrište -@msg action is required akcija je obavezna +@msg action is required action je obavezan @msg anchor must name a registered panel anchor mora navesti registrovani panel @msg before must name a panel before mora navesti panel @msg docking action contains an unknown panel or an excessive split path akcija sidrenja sadrži nepoznat panel ili prekomjernu putanju podjele @@ -5840,7 +5840,7 @@ axis Y Axis Y osa @msg the document has no symbols to save dokument nema simbola za čuvanje @msg Trap: select two or more objects filled with solid colours Zamka: odaberite dva ili više objekata ispunjenih punim bojama @msg Trap: no edges of different colours meet among the selected objects Zamka: među odabranim objektima ne susreću se ivice različitih boja -@msg thickness must be from 0.01 to 5000 pt debljina mora biti od 0,01 do 5000 pt +@msg thickness must be from 0.01 to 5000 pt thickness mora biti od 0,01 do 5000 pt @msg heightWidth must be from 1 to 1000 % heightWidth mora biti od 1 do 1000 % @msg tintReduction must be from 0 to 100 % tintReduction mora biti od 0 do 100 % Rectangular Grid Tool Options Opcije alata pravougaona mreža @@ -5848,7 +5848,7 @@ axis Y Axis Y osa Fill Line Ispuni liniju Pathfinder Options Opcije Pathfindera Divide and Outline Will Remove Unpainted Artwork Podijeli i Obris uklanjaju neobojenu grafiku -@msg precision must be from 0.001 to 10 pt preciznost mora biti od 0,001 do 10 pt +@msg precision must be from 0.001 to 10 pt precision mora biti od 0,001 do 10 pt @msg `pathfinder` must be an object `pathfinder` mora biti objekat Add to Brushes Dodaj u četkice Brush Library Biblioteka četkica diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3f61c46c3..a89022bf9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5007,7 +5007,7 @@ digits Malayalam Malaiàlam @msg a value has the wrong type for the operator un valor té el tipus incorrecte per a l’operador @msg a value is outside the range the operator takes un valor està fora de l’interval que accepta l’operador @msg a {width}×{height} screenshot's byte count overflows el recompte de bytes d’una captura de pantalla de {width}×{height} es desborda -@msg action is required cal una acció +@msg action is required cal action @msg add a format to export afegiu un format per exportar @msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 escriu fitxers .ai heretats, no plantilles .ait @msg already editing a pattern ja s’està editant un motiu @@ -5026,9 +5026,9 @@ digits Malayalam Malaiàlam @msg an object can't be read no es pot llegir un objecte @msg an object in perspective would leave the edited grid un objecte en perspectiva sortiria de la quadrícula editada @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objecte admet un efecte 3D. Editeu les opcions de l’efecte existent o suprimiu-lo primer. -@msg anchor must name a registered panel l’àncora ha de nomenar un panell registrat +@msg anchor must name a registered panel anchor ha de nomenar un panell registrat @msg Angles require two straight segments Els angles requereixen dos segments rectes -@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: cap, art o text +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art o type @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise el text d’àrea en marcs massa baixos per a una línia del tipus de lletra que mostren ha rebut marcs més alts ({_1}): d’altra manera el seu text no es veuria @msg artboard {0} does not exist el tauler {0} no existeix @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard a aquesta escala el dibuix fa {w} × {h} pt, més del que admet el llenç: trieu una escala més petita o ajusteu-lo al tauler @@ -5043,7 +5043,7 @@ digits Malayalam Malaiàlam @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} camí d’automatització rebutjat: recorregut ascendent per sota d’una carpeta que no existeix: {path} @msg automation path rejected: path is empty camí d’automatització rebutjat: el camí és buit @msg automation path rejected: reserved device names are not allowed: {path} camí d’automatització rebutjat: no es permeten noms de dispositiu reservats: {path} -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` l’eix ha de ser "vertical", "horizontal" o un angle en graus, no `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis ha de ser "vertical", "horizontal" o un angle en graus, no `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" fons {other}: transparent, blanc, negre o un color com "#ff8800" @msg bad color {v} color incorrecte {v} @msg bad colour `{hex}` (#rrggbb) color incorrecte `{hex}` (#rrggbb) @@ -5051,7 +5051,7 @@ digits Malayalam Malaiàlam @msg bad shortcut drecera incorrecta @msg bad stream length longitud de flux incorrecta @msg bad trailer tràiler incorrecte -@msg balance must be between 0 and 100 l’equilibri ha d’estar entre 0 i 100 +@msg balance must be between 0 and 100 balance ha d’estar entre 0 i 100 @msg batch step {_1} (`{id}`) failed: {e} el pas de lot {_1} (`{id}`) ha fallat: {e} @msg Batch: {count} processed Lot: {count} processats @msg Batch: {count} processed, {failed} failed Lot: {count} processats, {failed} fallits @@ -5059,7 +5059,7 @@ digits Malayalam Malaiàlam @msg below and above must name a panel floating in another group below i above han de nomenar un panell flotant en un altre grup @msg Blend colors: select at least three objects with filled colours Fusiona colors: seleccioneu com a mínim tres objectes amb colors de farciment @msg BMP files are RGB or grayscale: colorModel rgb or gray Els fitxers BMP són RGB o escala de grisos: colorModel rgb o gray -@msg brightness must be dark|mediumDark|mediumLight|light la brillantor ha de ser dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness ha de ser dark|mediumDark|mediumLight|light @msg brushes move only within their type els pinzells només es mouen dins del seu tipus @msg can't encode the document: {0} no es pot codificar el document: {0} @msg can't parse shortcut `{c}` no es pot analitzar la drecera `{c}` @@ -5469,7 +5469,7 @@ digits Malayalam Malaiàlam @msg page size {w} × {h} mida de pàgina {w} × {h} @msg page {0} does not exist (the PDF has {1}) la pàgina {0} no existeix (el PDF en té {1}) @msg panel group is empty el grup de panells és buit -@msg panel is not in a dock group el panell no és en un grup d’acoblament +@msg panel is not in a dock group panel no és en un grup d’acoblament @msg panel must name a registered panel panel ha de nomenar un panell registrat @msg part of the program isn't valid PostScript text una part del programa no és text PostScript vàlid @msg paste unavailable enganxament no disponible @@ -5481,7 +5481,7 @@ digits Malayalam Malaiàlam @msg PDF security settings are not an object la configuració de seguretat del PDF no és un objecte @msg PDF settings are not an object la configuració del PDF no és un objecte @msg PDF writer error: {0} error de l’escriptor PDF: {0} -@msg percent must be above 0 and at most 6400, not {p} el percentatge ha de ser superior a 0 i com a màxim 6400, no {p} +@msg percent must be above 0 and at most 6400, not {p} percent ha de ser superior a 0 i com a màxim 6400, no {p} @msg Persistent libraries not found: {files} No s’han trobat biblioteques persistents: {files} @msg pick a colour, gradient or pattern to save as a swatch tria un color, un degradat o un motiu per desar-lo com a mostra @msg Placing {_1} Col·locant {_1} @@ -5494,7 +5494,7 @@ digits Malayalam Malaiàlam @msg plug-in output: {0} sortida del complement: {0} @msg plug-in parameters: {0} paràmetres del complement: {0} @msg plug-in trapped: {0} el complement ha quedat atrapat: {0} -@msg precision must be from 0.001 to 10 pt la precisió ha de ser d’entre 0,001 i 10 pt +@msg precision must be from 0.001 to 10 pt precision ha de ser d’entre 0,001 i 10 pt @msg Preview in Browser needs the desktop app (the web downloads files instead) La previsualització al navegador necessita l’aplicació d’escriptori (a la web es baixen els fitxers) @msg print settings are not an object la configuració d’impressió no és un objecte @msg printing isn't available here la impressió no està disponible aquí @@ -5548,8 +5548,8 @@ digits Malayalam Malaiàlam @msg Saving S’està desant @msg Saving recovery data for {_1} S’estan desant les dades de recuperació de {_1} @msg Saving {_1} S’està desant {_1} -@msg scale must be a number from {_1} to {_2}, not {_3} l’escala ha de ser un número de {_1} a {_2}, no {_3} -@msg scale must be a number from {_1} to {_2}, not {s} l’escala ha de ser un número de {_1} a {_2}, no {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale ha de ser un número de {_1} a {_2}, no {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale ha de ser un número de {_1} a {_2}, no {s} @msg Scale must be drawing:real, for example 1:10 L’escala ha de ser drawing:real, per exemple 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 La relació d’escala ha d’estar entre 1:1000000 i 1000000:1 @msg Scale Text Attributes: selected type has no scale to bake Escala els atributs de text: el text seleccionat no té cap escala per aplicar @@ -5736,7 +5736,7 @@ digits Malayalam Malaiàlam @msg the {tool} tool has no options l’eina {tool} no té opcions @msg there are no objects to release no hi ha cap objecte per alliberar @msg there are no printer settings to open here aquí no hi ha cap configuració d’impressora per obrir -@msg thickness must be from 0.01 to 5000 pt el gruix ha de ser d’entre 0,01 i 5000 pt +@msg thickness must be from 0.01 to 5000 pt thickness ha de ser d’entre 0,01 i 5000 pt @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) aquest fitxer s’ha desat sense compatibilitat amb PDF i les seves dades d’edició no es poden llegir ({why}) @msg the PDF part couldn't be read ({_1}): the file opened from its editing data no s’ha pogut llegir la part PDF ({_1}): el fitxer s’ha obert a partir de les seves dades d’edició @msg this file's PostScript couldn't be read ({why}): {what} no s’ha pogut llegir el PostScript d’aquest fitxer ({why}): {what} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9291741e3..1fa584fd0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4287,7 +4287,7 @@ imageTrace Outline Obrys # Status and error messages @msg no library `{id}` knihovna `{id}` neexistuje @msg select some type first nejprve vyberte text -@msg kerning must be a number, "auto", or "optical" Vyrovnání párů musí být číslo, „auto“ nebo „optical“ +@msg kerning must be a number, "auto", or "optical" kerning musí být číslo, „auto“ nebo „optical“ @msg the selection has no gradient fill výběr nemá přechodovou výplň @msg the selection has no gradient stroke výběr nemá přechodový tah @msg the selection has no pattern fill výběr nemá vzorkovou výplň @@ -4499,7 +4499,7 @@ imageTrace Outline Obrys @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art nebo type @msg artboard {0} does not exist pracovní plocha {0} neexistuje @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard v tomto měřítku má kresba {w} × {h} pt, víc než pojme plátno: zvolte menší měřítko nebo ji přizpůsobte pracovní ploše -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` osa musí být "vertical", "horizontal" nebo úhel ve stupních, ne `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis musí být "vertical", "horizontal" nebo úhel ve stupních, ne `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" pozadí {other}: transparent, white, black nebo barva jako "#ff8800" @msg bad color {v} chybná barva {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio musí být číslo od 0,1 do 10 @@ -4513,9 +4513,9 @@ imageTrace Outline Obrys @msg bad shortcut chybná klávesová zkratka @msg bad stream length chybná délka datového proudu @msg bad trailer chybný trailer -@msg balance must be between 0 and 100 vyvážení musí být mezi 0 a 100 +@msg balance must be between 0 and 100 balance musí být mezi 0 a 100 @msg batch step {_1} (`{id}`) failed: {e} krok dávky {_1} (`{id}`) selhal: {e} -@msg brightness must be dark|mediumDark|mediumLight|light jas musí být dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness musí být dark|mediumDark|mediumLight|light @msg can't encode the document: {0} dokument nelze zakódovat: {0} @msg can't parse shortcut `{c}` klávesovou zkratku `{c}` nelze analyzovat @msg can't parse shortcut `{s}` for `{k}` klávesovou zkratku `{s}` pro `{k}` nelze analyzovat @@ -4540,7 +4540,7 @@ imageTrace Outline Obrys @msg compound paths can only contain paths složené cesty mohou obsahovat jen cesty @msg compressed (RLE) bitmaps can't be read yet komprimované bitmapy (RLE) zatím nelze načíst @msg damaged poškozeno -@msg decimals must be {_1}–{_2} (got {_3}) počet desetinných míst musí být {_1}–{_2} (zadáno {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals musí být {_1}–{_2} (zadáno {_3}) @msg divide into 1 to {MAX_PIECES} slices rozdělit lze na 1 až {MAX_PIECES} řezů @msg effect {k} is `{_1}`, not `{id}` efekt {k} je `{_1}`, ne `{id}` @msg empty brush stroke prázdný tah štětcem @@ -4738,9 +4738,9 @@ imageTrace Outline Obrys @msg replace: the object to replace was changed or deleted replace: objekt k nahrazení byl změněn nebo odstraněn @msg pattern missing chybí vzorek @msg patterns nested too deeply are filled with mid-grey příliš hluboko vnořené vzorky se vyplní středně šedou -@msg percent must be above 0 and at most 6400, not {p} procento musí být větší než 0 a nejvýše 6400, ne {p} +@msg percent must be above 0 and at most 6400, not {p} percent musí být větší než 0 a nejvýše 6400, ne {p} @msg pick a colour, gradient or pattern to save as a swatch vyberte barvu, přechod nebo vzorek, který se má uložit do vzorníku -@msg plane must be left, right or ground rovina musí být left, right nebo ground +@msg plane must be left, right or ground plane musí být left, right nebo ground @msg plug-in ABI: {0} ABI zásuvného modulu: {0} @msg plug-in exceeded its {0} zásuvný modul překročil limit: {0} @msg plug-in failed: {0} zásuvný modul selhal: {0} @@ -4759,8 +4759,8 @@ imageTrace Outline Obrys @msg result would exceed the canvas (coordinates out of range) výsledek by přesáhl plátno (souřadnice mimo rozsah) @msg objects can't be nested more than 32 deep objekty nelze vnořit hlouběji než do 32 úrovní @msg the SVG's elements are nested more than {0} deep prvky SVG jsou vnořeny hlouběji než do {0} úrovní -@msg scale must be a number from {_1} to {_2}, not {_3} měřítko musí být číslo od {_1} do {_2}, ne {_3} -@msg scale must be a number from {_1} to {_2}, not {s} měřítko musí být číslo od {_1} do {_2}, ne {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale musí být číslo od {_1} do {_2}, ne {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale musí být číslo od {_1} do {_2}, ne {s} @msg select (or target) objects whose appearance isn't basic vyberte (nebo zacilte) objekty, jejichž vzhled není základní @msg select a Live Paint group vyberte skupinu živé malby @msg select a blend vyberte prolnutí diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 5f5d188ed..3c68b09fe 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4002,7 +4002,7 @@ imageTrace Outline Kontur @plural {n} stroke|{n} strokes {n} kontur|{n} konturer @msg no library `{id}` intet bibliotek `{id}` @msg select some type first markér først noget tekst -@msg kerning must be a number, "auto", or "optical" Kerning skal være et tal, „auto“ eller „optical“ +@msg kerning must be a number, "auto", or "optical" kerning skal være et tal, „auto“ eller „optical“ @msg {_1} has no {_2} style {_1} har ingen {_2}-stil @msg a layer couldn't be read et lag kunne ikke læses @msg it has more than one artboard den har mere end én tegneflade @@ -4177,7 +4177,7 @@ imageTrace Outline Kontur @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art eller type @msg artboard {0} does not exist tegneflade {0} findes ikke @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard ved denne skala er tegningen {w} × {h} pt, mere end lærredet rummer: vælg en mindre skala, eller tilpas den til tegnefladen -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` aksen skal være "vertical", "horizontal" eller en vinkel i grader, ikke `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis skal være "vertical", "horizontal" eller en vinkel i grader, ikke `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" baggrund {other}: gennemsigtig, hvid, sort eller en farve som "#ff8800" @msg bad color {v} ugyldig farve {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio skal være et tal fra 0,1 til 10 @@ -4188,7 +4188,7 @@ imageTrace Outline Kontur @msg bad trailer ugyldig trailer @msg balance must be between 0 and 100 balance skal være mellem 0 og 100 @msg batch step {_1} (`{id}`) failed: {e} batchtrin {_1} (`{id}`) mislykkedes: {e} -@msg brightness must be dark|mediumDark|mediumLight|light lysstyrken skal være dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness skal være dark|mediumDark|mediumLight|light @msg can't encode the document: {0} kan ikke kode dokumentet: {0} @msg can't parse shortcut `{c}` kan ikke parse genvejen `{c}` @msg can't parse shortcut `{s}` for `{k}` kan ikke parse genvejen `{s}` for `{k}` @@ -4211,7 +4211,7 @@ imageTrace Outline Kontur @msg compound paths can only contain paths sammensatte kurver kan kun indeholde kurver @msg compressed (RLE) bitmaps can't be read yet komprimerede (RLE) bitmaps kan endnu ikke læses @msg damaged beskadiget -@msg decimals must be {_1}–{_2} (got {_3}) decimaler skal være {_1}–{_2} (fik {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals skal være {_1}–{_2} (fik {_3}) @msg divide into 1 to {MAX_PIECES} slices opdel i 1 til {MAX_PIECES} udsnit @msg effect {k} is `{_1}`, not `{id}` effekten {k} er `{_1}`, ikke `{id}` @msg empty brush stroke tomt penselstrøg @@ -4228,8 +4228,8 @@ imageTrace Outline Kontur @msg an image has no pixels and was left out et billede uden pixels blev udeladt @msg image data `{_1}` is missing billeddata `{_1}` mangler @msg image would be too large; lower the resolution billedet ville blive for stort; sænk opløsningen -@msg index must be a number or first, previous, next or last indeks skal være et tal eller first, previous, next eller last -@msg index must be a number or first, previous, next or last, not `{other}` indeks skal være et tal eller first, previous, next eller last, ikke `{other}` +@msg index must be a number or first, previous, next or last index skal være et tal eller first, previous, next eller last +@msg index must be a number or first, previous, next or last, not `{other}` index skal være et tal eller first, previous, next eller last, ikke `{other}` @msg installing from a path is not available on the web; pass the module as `dataBase64` installation fra en sti er ikke tilgængelig på webbet; overfør modulet som `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) intern fejl i `{cmd}`: {msg} (dokumentet blev efterladt, som det var tidligere; meld venligst denne fejl) @msg internal error: {msg} (please report this bug) intern fejl: {msg} (meld venligst denne fejl) @@ -4392,9 +4392,9 @@ imageTrace Outline Kontur @msg replace: the object to replace was changed or deleted erstat: objektet, der skulle erstattes, er ændret eller slettet @msg pattern missing mønster mangler @msg patterns nested too deeply are filled with mid-grey mønstre, der er indlejret for dybt, udfyldes med mellemgrå -@msg percent must be above 0 and at most 6400, not {p} procent skal være over 0 og højst 6400, ikke {p} +@msg percent must be above 0 and at most 6400, not {p} percent skal være over 0 og højst 6400, ikke {p} @msg pick a colour, gradient or pattern to save as a swatch vælg en farve, en gradient eller et mønster, der skal gemmes som en farveprøve -@msg plane must be left, right or ground planet skal være venstre, højre eller grund +@msg plane must be left, right or ground plane skal være left, right eller ground @msg plug-in ABI: {0} plug-in-ABI: {0} @msg plug-in exceeded its {0} plug-in'et overskred sin {0} @msg plug-in failed: {0} plug-in fejlede: {0} @@ -4413,8 +4413,8 @@ imageTrace Outline Kontur @msg result would exceed the canvas (coordinates out of range) resultatet ville gå ud over lærredet (koordinater uden for intervallet) @msg objects can't be nested more than 32 deep objekter kan ikke indlejres mere end 32 niveauer dybt @msg the SVG's elements are nested more than {0} deep SVG'ens elementer er indlejret mere end {0} niveauer dybt -@msg scale must be a number from {_1} to {_2}, not {_3} skalaen skal være et tal fra {_1} til {_2}, ikke {_3} -@msg scale must be a number from {_1} to {_2}, not {s} skalaen skal være et tal fra {_1} til {_2}, ikke {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale skal være et tal fra {_1} til {_2}, ikke {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale skal være et tal fra {_1} til {_2}, ikke {s} @msg select (or target) objects whose appearance isn't basic markér (eller målret) objekter, hvis udseende ikke er grundlæggende @msg select a Live Paint group markér en Live Paint-gruppe @msg select a blend markér en blanding @@ -4722,7 +4722,7 @@ imageTrace Outline Kontur @msg before must name a panel before skal angive et panel @msg docking action contains an unknown panel or an excessive split path dokningshandlingen indeholder et ukendt panel eller en overdreven opdelingssti @msg group must be true or false group skal være true eller false -@msg panel is not in a dock group panelet er ikke i en dockgruppe +@msg panel is not in a dock group panel er ikke i en dockgruppe @msg only a dock group above or below others collapses, not one beside another kun en dockgruppe over eller under andre kan skjules, ikke en ved siden af en anden @msg icon must be true or false icon skal være true eller false @msg icon only docks a panel in the icon column, with no anchor icon docker kun et panel i ikonkolonnen, uden anchor diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e42790f99..5061ce431 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3894,7 +3894,7 @@ theme Appearance Erscheinungsbild # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` keine Bibliothek `{id}` @msg select some type first wählen Sie zuerst Text aus -@msg kerning must be a number, "auto", or "optical" Kerning muss eine Zahl, „auto“ oder „optical“ sein +@msg kerning must be a number, "auto", or "optical" kerning muss eine Zahl, „auto“ oder „optical“ sein @msg the selection has no gradient fill die Auswahl hat keinen Flächenverlauf @msg the selection has no gradient stroke die Auswahl hat keinen Konturverlauf @msg the selection has no pattern fill die Auswahl hat kein Flächenmuster @@ -4915,12 +4915,12 @@ theme Appearance Erscheinungsbild Resize panel window Bedienfeldfenster skalieren Resize panels Bedienfelder skalieren @msg Invalid panel layout; restored the default dock Ungültige Bedienfeldanordnung; Standarddock wiederhergestellt -@msg action is required Eine Aktion ist erforderlich -@msg anchor must name a registered panel Der Anker muss ein registriertes Bedienfeld benennen +@msg action is required action ist erforderlich +@msg anchor must name a registered panel anchor muss ein registriertes Bedienfeld benennen @msg before must name a panel before muss ein Bedienfeld benennen @msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad @msg group must be true or false group muss true oder false sein -@msg panel is not in a dock group Bedienfeld ist in keiner Dock-Gruppe +@msg panel is not in a dock group panel ist in keiner Dock-Gruppe @msg only a dock group above or below others collapses, not one beside another nur eine Dock-Gruppe über oder unter anderen lässt sich minimieren, nicht eine daneben @msg icon must be true or false icon muss true oder false sein @msg icon only docks a panel in the icon column, with no anchor icon dockt ein Bedienfeld nur in der Symbolspalte an, ohne anchor diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ddc22b961..25dac9e83 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2161,7 +2161,7 @@ axis Both Και τα δύο Dock Control Bar Αγκύρωση γραμμής ελέγχου @msg no library `{id}` Δεν υπάρχει βιβλιοθήκη με αναγνωριστικό `{id}` @msg select some type first Επιλέξτε πρώτα κάποιο κείμενο -@msg kerning must be a number, "auto", or "optical" Η διαγραμμάτωση πρέπει να είναι αριθμός, «auto» ή «optical» +@msg kerning must be a number, "auto", or "optical" το kerning πρέπει να είναι αριθμός, «auto» ή «optical» @msg the selection has no gradient fill η επιλογή δεν έχει ντεγκραντέ γεμίσματος @msg the selection has no gradient stroke η επιλογή δεν έχει ντεγκραντέ περιγράμματος @msg the selection has no pattern fill η επιλογή δεν έχει μοτίβο γεμίσματος @@ -4152,7 +4152,7 @@ digits Malayalam Μαλαγιάλαμ @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art ή type @msg artboard {0} does not exist ο πίνακας σχεδίασης {0} δεν υπάρχει @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard σε αυτήν την κλίμακα το σχέδιο είναι {w} × {h} pt και υπερβαίνει τον καμβά: επιλέξτε μικρότερη κλίμακα ή προσαρμόστε το στον πίνακα σχεδίασης -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` ο άξονας πρέπει να είναι "vertical", "horizontal" ή γωνία σε μοίρες, όχι `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` το axis πρέπει να είναι "vertical", "horizontal" ή γωνία σε μοίρες, όχι `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" φόντο {other}: transparent, white, black ή χρώμα όπως "#ff8800" @msg bad color {v} μη έγκυρο χρώμα {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 Το videoRulerPixelAspectRatio πρέπει να είναι αριθμός από 0,1 έως 10 @@ -4161,9 +4161,9 @@ digits Malayalam Μαλαγιάλαμ @msg bad shortcut μη έγκυρη συντόμευση @msg bad stream length μη έγκυρο μήκος ροής @msg bad trailer μη έγκυρο τελικό τμήμα -@msg balance must be between 0 and 100 η ισορροπία πρέπει να είναι μεταξύ 0 και 100 +@msg balance must be between 0 and 100 το balance πρέπει να είναι μεταξύ 0 και 100 @msg batch step {_1} (`{id}`) failed: {e} απέτυχε το βήμα {_1} της μαζικής επεξεργασίας (`{id}`): {e} -@msg brightness must be dark|mediumDark|mediumLight|light η φωτεινότητα πρέπει να είναι dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light το brightness πρέπει να είναι dark|mediumDark|mediumLight|light @msg can't encode the document: {0} δεν είναι δυνατή η κωδικοποίηση εγγράφου: {0} @msg can't parse shortcut `{c}` δεν είναι δυνατή η ανάλυση της συντόμευσης `{c}` @msg can't parse shortcut `{s}` for `{k}` δεν είναι δυνατή η ανάλυση της συντόμευσης `{s}` για `{k}` @@ -4188,7 +4188,7 @@ digits Malayalam Μαλαγιάλαμ @msg compound paths can only contain paths οι σύνθετες διαδρομές μπορούν να περιέχουν μόνο διαδρομές @msg compressed (RLE) bitmaps can't be read yet τα συμπιεσμένα (RLE) bitmap δεν μπορούν ακόμη να διαβαστούν @msg damaged κατεστραμμένο -@msg decimals must be {_1}–{_2} (got {_3}) τα δεκαδικά πρέπει να είναι {_1}–{_2} (δόθηκε {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) το decimals πρέπει να είναι {_1}–{_2} (δόθηκε {_3}) @msg divide into 1 to {MAX_PIECES} slices διαιρέστε σε 1 έως {MAX_PIECES} τομές @msg effect {k} is `{_1}`, not `{id}` το εφέ {k} είναι `{_1}`, όχι `{id}` @msg empty brush stroke κενό περίγραμμα πινέλου @@ -4206,9 +4206,9 @@ digits Malayalam Μαλαγιάλαμ @msg an image has no pixels and was left out παραλείφθηκε εικόνα χωρίς εικονοστοιχεία @msg image data `{_1}` is missing λείπουν τα δεδομένα εικόνας `{_1}` @msg image would be too large; lower the resolution η εικόνα θα ήταν υπερβολικά μεγάλη· μειώστε την ανάλυση -@msg index must be a number or first, previous, next or last ο δείκτης πρέπει να είναι αριθμός ή first, previous, next ή last -@msg index must be a number or first, previous, next or last, not `{other}` ο δείκτης πρέπει να είναι αριθμός ή first, previous, next ή last, όχι `{other}` -@msg index must be ≥ 0 ο δείκτης δεν μπορεί να είναι αρνητικός +@msg index must be a number or first, previous, next or last το index πρέπει να είναι αριθμός ή first, previous, next ή last +@msg index must be a number or first, previous, next or last, not `{other}` το index πρέπει να είναι αριθμός ή first, previous, next ή last, όχι `{other}` +@msg index must be ≥ 0 το index πρέπει να είναι ≥ 0 @msg installing from a path is not available on the web; pass the module as `dataBase64` η εγκατάσταση από διαδρομή δεν υποστηρίζεται στο web· περάστε το πρόσθετο ως `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) εσωτερικό σφάλμα στην εντολή `{cmd}`: {msg} (το έγγραφο έμεινε αμετάβλητο· αναφέρετε το σφάλμα) @msg internal error: {msg} (please report this bug) εσωτερικό σφάλμα: {msg} (αναφέρετε το σφάλμα) @@ -4221,7 +4221,7 @@ digits Malayalam Μαλαγιάλαμ @msg its Windows metafile preview was opened instead, which may simplify the art αντί γι' αυτό ανοίχτηκε η προεπισκόπηση μετααρχείου Windows, η οποία μπορεί να απλοποιεί το σχέδιο @msg its preview was placed instead, at screen resolution τοποθετήθηκε η προεπισκόπησή του σε ανάλυση οθόνης @msg join needs open paths η ένωση απαιτεί ανοιχτές διαδρομές -@msg lang must be one of {_1} η γλώσσα πρέπει να είναι μία από τις {_1} +@msg lang must be one of {_1} το lang πρέπει να είναι ένα από τα {_1} @msg layers can't be wrap objects τα επίπεδα δεν μπορούν να είναι αντικείμενα αναδίπλωσης @msg line {line}: value for `{var}` must be true or false γραμμή {line}: η τιμή για το `{var}` πρέπει να είναι true ή false @msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes γραμμή {line}: η τιμή για το `{var}` είναι μεγαλύτερη από {MAX_GRAPH_CSV} byte @@ -4387,9 +4387,9 @@ digits Malayalam Μαλαγιάλαμ @msg replace: the object to replace was changed or deleted αντικατάσταση: το αντικείμενο προς αντικατάσταση άλλαξε ή διαγράφηκε @msg pattern missing λείπει το μοτίβο @msg patterns nested too deeply are filled with mid-grey τα πολύ βαθιά ένθετα μοτίβα γεμίζονται με μέτριο γκρι -@msg percent must be above 0 and at most 6400, not {p} το ποσοστό πρέπει να είναι μεγαλύτερο από 0 και έως 6400, όχι {p} +@msg percent must be above 0 and at most 6400, not {p} το percent πρέπει να είναι μεγαλύτερο από 0 και έως 6400, όχι {p} @msg pick a colour, gradient or pattern to save as a swatch επιλέξτε χρώμα, ντεγκραντέ ή μοτίβο για αποθήκευση ως δείγμα -@msg plane must be left, right or ground το επίπεδο πρέπει να είναι left, right ή ground +@msg plane must be left, right or ground το plane πρέπει να είναι left, right ή ground @msg plug-in ABI: {0} ABI προσθέτου: {0} @msg plug-in exceeded its {0} το πρόσθετο υπερέβη το όριο {0} @msg plug-in failed: {0} το πρόσθετο απέτυχε: {0} @@ -4408,8 +4408,8 @@ digits Malayalam Μαλαγιάλαμ @msg result would exceed the canvas (coordinates out of range) το αποτέλεσμα θα υπερέβαινε τον καμβά (συντεταγμένες εκτός εύρους) @msg objects can't be nested more than 32 deep τα αντικείμενα δεν μπορούν να έχουν ένθεση μεγαλύτερη των 32 επιπέδων @msg the SVG's elements are nested more than {0} deep τα στοιχεία SVG έχουν ένθεση μεγαλύτερη των {0} επιπέδων -@msg scale must be a number from {_1} to {_2}, not {_3} η κλίμακα πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {_3} -@msg scale must be a number from {_1} to {_2}, not {s} η κλίμακα πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {s} +@msg scale must be a number from {_1} to {_2}, not {_3} το scale πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {_3} +@msg scale must be a number from {_1} to {_2}, not {s} το scale πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {s} @msg select (or target) objects whose appearance isn't basic επιλέξτε (ή στοχεύστε) αντικείμενα των οποίων η εμφάνιση δεν είναι βασική @msg select a Live Paint group επιλέξτε ομάδα δυναμικής βαφής @msg select a blend επιλέξτε ανάμειξη @@ -4798,12 +4798,12 @@ digits Malayalam Μαλαγιάλαμ Edit Data Set Επεξεργασία συνόλου δεδομένων Define a variable first Ορίστε πρώτα μία μεταβλητή @msg Invalid panel layout; restored the default dock Μη έγκυρη διάταξη πινάκων· επανήλθε η προεπιλεγμένη περιοχή σύνδεσης -@msg action is required απαιτείται ενέργεια +@msg action is required απαιτείται το action @msg anchor must name a registered panel το anchor πρέπει να ορίζει καταχωρισμένο πίνακα @msg before must name a panel το before πρέπει να ορίζει πίνακα @msg docking action contains an unknown panel or an excessive split path η ενέργεια σύνδεσης περιέχει άγνωστο πίνακα ή υπερβολικά βαθιά διαδρομή διαίρεσης @msg group must be true or false το group πρέπει να είναι true ή false -@msg panel is not in a dock group Το πάνελ δεν βρίσκεται σε ομάδα αγκύρωσης +@msg panel is not in a dock group το panel δεν βρίσκεται σε ομάδα αγκύρωσης @msg only a dock group above or below others collapses, not one beside another συμπτύσσεται μόνο ομάδα αγκύρωσης πάνω ή κάτω από άλλες, όχι δίπλα σε άλλη @msg icon must be true or false το icon πρέπει να είναι true ή false @msg icon only docks a panel in the icon column, with no anchor το icon αγκυρώνει πάνελ μόνο στη στήλη εικονιδίων, χωρίς anchor @@ -5260,7 +5260,7 @@ effect Vertical Κατακόρυφο effect Points Σημεία Single Corner Radius Ακτίνα μίας γωνίας CMYK CMYK -@msg control must be fillStroke, coloring, drawMode or screenMode Το στοιχείο ελέγχου πρέπει να είναι fillStroke, coloring, drawMode ή screenMode +@msg control must be fillStroke, coloring, drawMode or screenMode το control πρέπει να είναι fillStroke, coloring, drawMode ή screenMode Draw Mode Controls Στοιχεία ελέγχου λειτουργίας σχεδίασης Hold Κρατήστε πατημένο Latin Λατινικά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 531aaf18e..60897a54d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3979,7 +3979,7 @@ axis Both Ambos # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` no hay ninguna biblioteca `{id}` @msg select some type first selecciona primero algún texto -@msg kerning must be a number, "auto", or "optical" El interletraje debe ser un número, «auto» u «optical» +@msg kerning must be a number, "auto", or "optical" kerning debe ser un número, «auto» u «optical» @msg the selection has no gradient fill la selección no tiene un degradado de relleno @msg the selection has no gradient stroke la selección no tiene un degradado de trazo @msg the selection has no pattern fill la selección no tiene un motivo de relleno @@ -4206,9 +4206,9 @@ axis Both Ambos @msg bad shortcut atajo no válido @msg bad stream length longitud de flujo no válida @msg bad trailer trailer no válido -@msg balance must be between 0 and 100 el equilibrio debe estar entre 0 y 100 +@msg balance must be between 0 and 100 balance debe estar entre 0 y 100 @msg batch step {_1} (`{id}`) failed: {e} falló el paso {_1} del lote (`{id}`): {e} -@msg brightness must be dark|mediumDark|mediumLight|light el brillo debe ser dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness debe ser dark|mediumDark|mediumLight|light @msg can't encode the document: {0} no se puede codificar el documento: {0} @msg can't parse shortcut `{c}` no se puede interpretar el atajo `{c}` @msg can't parse shortcut `{s}` for `{k}` no se puede interpretar el atajo `{s}` de `{k}` @@ -4233,7 +4233,7 @@ axis Both Ambos @msg compound paths can only contain paths los trazados compuestos solo pueden contener trazados @msg compressed (RLE) bitmaps can't be read yet todavía no se pueden leer mapas de bits comprimidos (RLE) @msg damaged dañado -@msg decimals must be {_1}–{_2} (got {_3}) los decimales deben ser {_1}–{_2} (se recibió {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals debe ser {_1}–{_2} (se recibió {_3}) @msg divide into 1 to {MAX_PIECES} slices divide en 1 a {MAX_PIECES} sectores @msg effect {k} is `{_1}`, not `{id}` el efecto {k} es `{_1}`, no `{id}` @msg empty brush stroke trazo de pincel vacío @@ -4432,7 +4432,7 @@ axis Both Ambos @msg replace: the object to replace was changed or deleted replace: se modificó o eliminó el objeto que se iba a reemplazar @msg pattern missing falta el motivo @msg patterns nested too deeply are filled with mid-grey los motivos anidados a demasiada profundidad se rellenan de gris medio -@msg percent must be above 0 and at most 6400, not {p} el porcentaje debe ser mayor que 0 y como máximo 6400, no {p} +@msg percent must be above 0 and at most 6400, not {p} percent debe ser mayor que 0 y como máximo 6400, no {p} @msg pick a colour, gradient or pattern to save as a swatch elige un color, degradado o motivo para guardarlo como muestra @msg plane must be left, right or ground plane debe ser left, right o ground @msg plug-in ABI: {0} ABI del plugin: {0} @@ -4453,8 +4453,8 @@ axis Both Ambos @msg result would exceed the canvas (coordinates out of range) el resultado excedería el lienzo (coordenadas fuera de rango) @msg objects can't be nested more than 32 deep los objetos no pueden anidarse a más de 32 niveles @msg the SVG's elements are nested more than {0} deep los elementos del SVG están anidados a más de {0} niveles -@msg scale must be a number from {_1} to {_2}, not {_3} la escala debe ser un número de {_1} a {_2}, no {_3} -@msg scale must be a number from {_1} to {_2}, not {s} la escala debe ser un número de {_1} a {_2}, no {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale debe ser un número de {_1} a {_2}, no {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale debe ser un número de {_1} a {_2}, no {s} @msg select (or target) objects whose appearance isn't basic selecciona (o apunta a) objetos cuya apariencia no sea básica @msg select a Live Paint group selecciona un grupo de pintura interactiva @msg select a blend selecciona una fusión diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 6ae295376..8bb2da5c7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4202,7 +4202,7 @@ imageTrace Outline Ääriviiva @plural {n} stroke|{n} strokes {n} viiva|{n} viivaa @msg no library `{id}` ei kirjastoa `{id}` @msg select some type first valitse ensin tekstiä -@msg kerning must be a number, "auto", or "optical" Parivälistyksen on oltava luku, ”auto” tai ”optical” +@msg kerning must be a number, "auto", or "optical" kerning-arvon on oltava luku, ”auto” tai ”optical” @msg the selection has no gradient fill valinnalla ei ole liukuväritäyttöä @msg the selection has no gradient stroke valinnalla ei ole liukuväriviivaa @msg the selection has no pattern fill valinnalla ei ole kuviotäyttöä @@ -4412,7 +4412,7 @@ imageTrace Outline Ääriviiva @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art tai type @msg artboard {0} does not exist piirtoalustaa {0} ei ole olemassa @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard tässä mittakaavassa piirustus on {w} × {h} pt, enemmän kuin kankaalle mahtuu: valitse pienempi mittakaava tai sovita se piirtoalustaan -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` akselin on oltava "vertical", "horizontal" tai kulma asteina, ei `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis-arvon on oltava "vertical", "horizontal" tai kulma asteina, ei `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" tausta {other}: transparent, white, black tai väri, kuten "#ff8800" @msg bad color {v} virheellinen väri {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio on oltava luku väliltä 0,1–10 @@ -4421,9 +4421,9 @@ imageTrace Outline Ääriviiva @msg bad shortcut virheellinen pikanäppäin @msg bad stream length virheellinen virran pituus @msg bad trailer virheellinen trailer -@msg balance must be between 0 and 100 tasapainon on oltava väliltä 0–100 +@msg balance must be between 0 and 100 balance-arvon on oltava väliltä 0–100 @msg batch step {_1} (`{id}`) failed: {e} erävaihe {_1} (`{id}`) epäonnistui: {e} -@msg brightness must be dark|mediumDark|mediumLight|light kirkkauden on oltava dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness-arvon on oltava dark|mediumDark|mediumLight|light @msg can't encode the document: {0} asiakirjaa ei voi koodata: {0} @msg can't parse shortcut `{c}` pikanäppäintä `{c}` ei voi jäsentää @msg can't parse shortcut `{s}` for `{k}` pikanäppäintä `{s}` kohteelle `{k}` ei voi jäsentää @@ -4448,7 +4448,7 @@ imageTrace Outline Ääriviiva @msg compound paths can only contain paths yhdistelmäpolut voivat sisältää vain polkuja @msg compressed (RLE) bitmaps can't be read yet pakattuja (RLE) bittikarttoja ei voi vielä lukea @msg damaged vaurioitunut -@msg decimals must be {_1}–{_2} (got {_3}) desimaalien on oltava {_1}–{_2} (saatiin {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals-arvon on oltava {_1}–{_2} (saatiin {_3}) @msg divide into 1 to {MAX_PIECES} slices jaa 1–{MAX_PIECES} viipaleeseen @msg effect {k} is `{_1}`, not `{id}` tehoste {k} on `{_1}`, ei `{id}` @msg empty brush stroke tyhjä sivellinviiva @@ -4466,9 +4466,9 @@ imageTrace Outline Ääriviiva @msg an image has no pixels and was left out kuva, jossa ei ole pikseleitä, jätettiin pois @msg image data `{_1}` is missing kuvan data `{_1}` puuttuu @msg image would be too large; lower the resolution kuvasta tulisi liian suuri; pienennä tarkkuutta -@msg index must be a number or first, previous, next or last indeksin on oltava luku tai first, previous, next tai last -@msg index must be a number or first, previous, next or last, not `{other}` indeksin on oltava luku tai first, previous, next tai last, ei `{other}` -@msg index must be ≥ 0 indeksi ei voi olla negatiivinen +@msg index must be a number or first, previous, next or last index-arvon on oltava luku tai first, previous, next tai last +@msg index must be a number or first, previous, next or last, not `{other}` index-arvon on oltava luku tai first, previous, next tai last, ei `{other}` +@msg index must be ≥ 0 index-arvon on oltava ≥ 0 @msg installing from a path is not available on the web; pass the module as `dataBase64` polusta asentaminen ei ole käytettävissä verkossa; välitä moduuli muodossa `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) sisäinen virhe kohteessa `{cmd}`: {msg} (asiakirja jätettiin ennalleen; ilmoita tästä virheestä) @msg internal error: {msg} (please report this bug) sisäinen virhe: {msg} (ilmoita tästä virheestä) @@ -4646,9 +4646,9 @@ imageTrace Outline Ääriviiva @msg replace: the object to replace was changed or deleted replace: korvattavaa objektia on muutettu tai se on poistettu @msg pattern missing kuvio puuttuu @msg patterns nested too deeply are filled with mid-grey liian syvälle sisäkkäiset kuviot täytetään keskiharmaalla -@msg percent must be above 0 and at most 6400, not {p} prosenttiarvon on oltava yli 0 ja enintään 6400, ei {p} +@msg percent must be above 0 and at most 6400, not {p} percent-arvon on oltava yli 0 ja enintään 6400, ei {p} @msg pick a colour, gradient or pattern to save as a swatch valitse väri, liukuväri tai kuvio tallennettavaksi värimalliksi -@msg plane must be left, right or ground tason on oltava left, right tai ground +@msg plane must be left, right or ground plane-arvon on oltava left, right tai ground @msg plug-in ABI: {0} laajennuksen ABI: {0} @msg plug-in exceeded its {0} laajennus ylitti rajan: {0} @msg plug-in failed: {0} laajennus epäonnistui: {0} @@ -4667,8 +4667,8 @@ imageTrace Outline Ääriviiva @msg result would exceed the canvas (coordinates out of range) tulos ylittäisi kankaan (koordinaatit alueen ulkopuolella) @msg objects can't be nested more than 32 deep objekteja ei voi sisäkkäistää yli 32 tason syvyyteen @msg the SVG's elements are nested more than {0} deep elementit ovat SVG-tiedostossa sisäkkäin yli {0} tason syvyydessä -@msg scale must be a number from {_1} to {_2}, not {_3} mittakaavan on oltava luku väliltä {_1}–{_2}, ei {_3} -@msg scale must be a number from {_1} to {_2}, not {s} mittakaavan on oltava luku väliltä {_1}–{_2}, ei {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale-arvon on oltava luku väliltä {_1}–{_2}, ei {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale-arvon on oltava luku väliltä {_1}–{_2}, ei {s} @msg select (or target) objects whose appearance isn't basic valitse (tai kohdista) objektit, joiden ulkoasu ei ole perusulkoasu @msg select a Live Paint group valitse elävän maalauksen ryhmä @msg select a blend valitse sulautus @@ -5009,7 +5009,7 @@ imageTrace Outline Ääriviiva @msg before must name a panel before-arvon on nimettävä paneeli @msg docking action contains an unknown panel or an excessive split path telakointitoiminto sisältää tuntemattoman paneelin tai liian pitkän jakopolun @msg group must be true or false group-arvon on oltava true tai false -@msg panel is not in a dock group paneeli ei ole telakkaryhmässä +@msg panel is not in a dock group panel ei ole telakkaryhmässä @msg only a dock group above or below others collapses, not one beside another vain muiden ylä- tai alapuolella oleva telakkaryhmä kutistuu, ei toisen vieressä oleva @msg icon must be true or false icon-arvon on oltava true tai false @msg icon only docks a panel in the icon column, with no anchor icon telakoi paneelin vain kuvakesarakkeeseen, ilman anchor-arvoa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fdad1035b..824d6b2af 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3969,7 +3969,7 @@ axis Both Les deux # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` aucune bibliothèque `{id}` @msg select some type first sélectionnez d’abord du texte -@msg kerning must be a number, "auto", or "optical" Le crénage doit être un nombre, « auto » ou « optical » +@msg kerning must be a number, "auto", or "optical" kerning doit être un nombre, « auto » ou « optical » @msg the selection has no gradient fill la sélection n’a pas de dégradé de fond @msg the selection has no gradient stroke la sélection n’a pas de dégradé de contour @msg the selection has no pattern fill la sélection n’a pas de motif de fond @@ -4196,9 +4196,9 @@ axis Both Les deux @msg bad shortcut raccourci non valide @msg bad stream length longueur de flux non valide @msg bad trailer trailer non valide -@msg balance must be between 0 and 100 l’équilibre doit être compris entre 0 et 100 +@msg balance must be between 0 and 100 balance doit être compris entre 0 et 100 @msg batch step {_1} (`{id}`) failed: {e} l’étape {_1} du lot (`{id}`) a échoué : {e} -@msg brightness must be dark|mediumDark|mediumLight|light la luminosité doit valoir dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness doit valoir dark|mediumDark|mediumLight|light @msg can't encode the document: {0} impossible d’encoder le document : {0} @msg can't parse shortcut `{c}` impossible d’interpréter le raccourci `{c}` @msg can't parse shortcut `{s}` for `{k}` impossible d’interpréter le raccourci `{s}` pour `{k}` @@ -4223,7 +4223,7 @@ axis Both Les deux @msg compound paths can only contain paths les tracés transparents ne peuvent contenir que des tracés @msg compressed (RLE) bitmaps can't be read yet les images bitmap compressées (RLE) ne peuvent pas encore être lues @msg damaged endommagé -@msg decimals must be {_1}–{_2} (got {_3}) le nombre de décimales doit être compris entre {_1} et {_2} (reçu : {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals doit être compris entre {_1} et {_2} (reçu : {_3}) @msg divide into 1 to {MAX_PIECES} slices divisez en 1 à {MAX_PIECES} tranches @msg effect {k} is `{_1}`, not `{id}` l’effet {k} est `{_1}`, pas `{id}` @msg empty brush stroke tracé de forme vide @@ -4422,7 +4422,7 @@ axis Both Les deux @msg replace: the object to replace was changed or deleted replace : l’objet à remplacer a été modifié ou supprimé @msg pattern missing motif manquant @msg patterns nested too deeply are filled with mid-grey les motifs imbriqués trop profondément sont remplis de gris moyen -@msg percent must be above 0 and at most 6400, not {p} le pourcentage doit être supérieur à 0 et au plus égal à 6400, pas {p} +@msg percent must be above 0 and at most 6400, not {p} percent doit être supérieur à 0 et au plus égal à 6400, pas {p} @msg pick a colour, gradient or pattern to save as a swatch choisissez une couleur, un dégradé ou un motif à enregistrer comme nuance @msg plane must be left, right or ground plane doit valoir left, right ou ground @msg plug-in ABI: {0} ABI du plug-in : {0} @@ -4443,8 +4443,8 @@ axis Both Les deux @msg result would exceed the canvas (coordinates out of range) le résultat dépasserait la zone de travail (coordonnées hors limites) @msg objects can't be nested more than 32 deep les objets ne peuvent pas être imbriqués sur plus de 32 niveaux @msg the SVG's elements are nested more than {0} deep les éléments du SVG sont imbriqués sur plus de {0} niveaux -@msg scale must be a number from {_1} to {_2}, not {_3} l’échelle doit être un nombre compris entre {_1} et {_2}, pas {_3} -@msg scale must be a number from {_1} to {_2}, not {s} l’échelle doit être un nombre compris entre {_1} et {_2}, pas {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale doit être un nombre compris entre {_1} et {_2}, pas {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale doit être un nombre compris entre {_1} et {_2}, pas {s} @msg select (or target) objects whose appearance isn't basic sélectionnez (ou ciblez) des objets dont l’aspect n’est pas basique @msg select a Live Paint group sélectionnez un groupe de peinture dynamique @msg select a blend sélectionnez un dégradé de formes @@ -4930,7 +4930,7 @@ effect Soft Doux @msg before must name a panel before doit désigner un panneau @msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long @msg group must be true or false group doit être true ou false -@msg panel is not in a dock group le panneau n’est dans aucun groupe du dock +@msg panel is not in a dock group panel n’est dans aucun groupe du dock @msg only a dock group above or below others collapses, not one beside another seul un groupe du dock situé au-dessus ou au-dessous d’autres se réduit, pas un groupe à côté @msg icon must be true or false icon doit être true ou false @msg icon only docks a panel in the icon column, with no anchor icon ne fait qu’ancrer un panneau dans la colonne d’icônes, sans anchor diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index efff1b054..9416bfd5b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5311,7 +5311,7 @@ @msg {k} must be a number or "auto" {k} एक नंबर या "auto" होना चाहिए। @msg {k} must be a string {k} एक स्ट्रिंग होना चाहिए @msg {k} must be text {k} को टेक्स्ट होना चाहिए -@msg {k} must be true or false {k} को सच या झूठा होना चाहिए +@msg {k} must be true or false {k} को true या false होना चाहिए @msg {k}: can't read `{s}` as a length {k}: `{s}` को लंबाई के रूप में नहीं पढ़ा जा सकता @msg {key} must be [x, y] with finite coordinates {key} को [x, y] होना चाहिए, जिसमें परिमित निर्देशांक होंगे। @msg {key} must be [x,y] {key} होना चाहिए [x,y]] @@ -5398,7 +5398,7 @@ @msg `{id}` isn't a loaded library `{id}` एक लोड लाइब्रेरी नहीं है @msg `{k}.family` is required `{k}.family` की आवश्यकता है @msg `{k}` is a number 0..1 `{k}` एक नंबर 0..1 है। -@msg `{k}` is true or false `{k}` सही या झूठ है +@msg `{k}` is true or false `{k}` true या false है @msg `{k}` isn't an export setting: {_1} `{k}` एक निर्यात सेटिंग नहीं है: {_1} @msg `{k}` must be [x, y] `{k}` होना चाहिए [x, y] @msg `{k}` must be {what} `{k}` होना चाहिए {what} @@ -5442,7 +5442,7 @@ @msg `{key}` must be from {lo} to {hi} `{key}` को {lo} से {hi} होना चाहिए। @msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` 0 से 180 डिग्री तक होना चाहिए, नहीं {a} @msg `{key}` must be one of {_1} `{key}` को {_1} में से एक होना चाहिए -@msg `{key}` must be true or false `{key}` को सच या झूठ होना चाहिए +@msg `{key}` must be true or false `{key}` को true या false होना चाहिए @msg `{key}`: not a colour: {v} `{key}`: कोई रंग नहीं: {v} @msg `{l}` isn't a global or spot colour `{l}` एक वैश्विक या स्पॉट रंग नहीं है @msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` एक बिटमैप नहीं है: clipboard.importSvg, importPdf या importText का उपयोग करें @@ -5550,18 +5550,18 @@ @msg `diameter` must be from 0.1 to 2500 pt `diameter` को 0.1 से 2500 pt होना चाहिए @msg `diameter` must be from 0.25 to 200 pt `diameter` को 0.25 से 200 pt होना चाहिए @msg `digits` must be one of {_1} `digits` को {_1} में से एक होना चाहिए -@msg `dimImages` is a percentage or false `dimImages` एक प्रतिशत या झूठी है +@msg `dimImages` is a percentage or false `dimImages` एक प्रतिशत या false है @msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` को "auto", "leftToRight" या "rightToLeft" होना चाहिए @msg `dither` applies to gradient fills (paint.setFill) `dither` gradient fills (paint.setFill) पर लागू होता है -@msg `dither` applies to gradient fills (stroke: false) `dither` gradient fills (स्ट्रोक: झूठी) पर लागू होता है -@msg `dither` must be true or false `dither` को सच या झूठा होना चाहिए +@msg `dither` applies to gradient fills (stroke: false) `dither` ग्रेडिएंट फ़िल (stroke: false) पर लागू होता है +@msg `dither` must be true or false `dither` को true या false होना चाहिए @msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} bytes (limit {MAX_DOCUMENT_DATA}) है। @msg `each` is the value one design stands for, more than 0 `each` एक डिज़ाइन का मान है, जो 0 से अधिक होना चाहिए @msg `end` must be "start" or "end" `end` को "start" या "end" होना चाहिए। @msg `ends` applies to joining exactly two paths `ends` वास्तव में दो पाथ में शामिल होने के लिए लागू होता है @msg `event` must be one of {_1} `event` is one of the {_1} @msg `every` must be "all", "odd" or "even", not {v} `every` होना चाहिए "all", "odd" या "even", नहीं {v} -@msg `expand` must be true or false `expand` को सच या झूठ होना चाहिए +@msg `expand` must be true or false `expand` को true या false होना चाहिए @msg `exportPreset` is the name of a preset of document.exportPresets.list `exportPreset`, document.exportPresets.list के किसी प्रीसेट का नाम है @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` में पिछली text.findFontFiles सर्च में मिली फ़ॉन्ट फ़ाइलों के 1 से 1000 पाथ होने चाहिए @msg `folder` must be an absolute folder path `folder` एक पूर्ण फ़ोल्डर पाथ होना चाहिए @@ -5619,11 +5619,11 @@ @msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` "none", "hard" या "soft" होना चाहिए। @msg `label` must be a non-empty name of at most 200 characters `label` को 200 वर्णों में से एक गैर-खाली नाम होना चाहिए @msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` एक भाषा का नाम या टैग होना चाहिए, जैसे "Turkish" या "tr" -@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` को 0.1 से 5000 तक होना चाहिए, या ऑटो के लिए शून्य होना चाहिए +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` को 0.1 से 5000 तक होना चाहिए, या ऑटो के लिए null होना चाहिए @msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` को "romanBaseline" या "emBoxTop" होना चाहिए @msg `length` must be a number of at most 1000000 pt either way `length` को 1000000 pt में से एक होना चाहिए। @msg `library` is a library id or name (see {list}) `library` एक लाइब्रेरी आईडी या नाम है ( {list} देखें) -@msg `line` must be true or false `line` को सही या झूठा होना चाहिए +@msg `line` must be true or false `line` को true या false होना चाहिए @msg `method` must be classic or perceptual `method` क्लासिक या अवधारणात्मक होना चाहिए @msg `min` is greater than `max` `min` अधिकतम `max` से अधिक है @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` और `maxAnchors` पूरे नंबर होना चाहिए @@ -5661,7 +5661,7 @@ @msg `ratio` must be from 0.1 to 0.5 `ratio` को 0.1 से 0.5 तक होना चाहिए @msg `rect` must be [x, y, width, height] with a positive size `rect` होना चाहिए [x, y, चौड़ाई, ऊंचाई] एक सकारात्मक आकार के साथ @msg `relation` must be overlap, inside or outside `relation` को ओवरलैप, अंदर या बाहर होना चाहिए -@msg `reversed` must be true or false `reversed` को सच या झूठ होना चाहिए +@msg `reversed` must be true or false `reversed` को true या false होना चाहिए @msg `rotate` must be a finite number `rotate` एक परिमित संख्या होना चाहिए @msg `rule` must be nonZero or evenOdd `rule` होना चाहिए nonZero or evenOdd होना चाहिए @msg `sampleSize` must be 1, 3 or 5 `sampleSize` होना चाहिए 1, 3 या 5 @@ -5673,7 +5673,7 @@ @msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` श्रृंखला सूचकांकों की एक गैर-खाली सूची है @msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` को `type` या `valueAxis` की आवश्यकता होती है @msg `shift` must be a number of points `shift` को कई अंक होना चाहिए -@msg `showCenter` must be true or false `showCenter` को सच या झूठ होना चाहिए +@msg `showCenter` must be true or false `showCenter` को true या false होना चाहिए @msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` को "fullJustify", "alignLeft", "alignCenter" या "alignRight" होना चाहिए। @msg `size` is a percentage of the base text's size, 1 to 100 `size` आधार टेक्स्ट के आकार का एक प्रतिशत है, 1 से 100 @msg `source` and `target` are the same object `source` और `target` एक ही ऑब्जेक्ट है @@ -5687,7 +5687,7 @@ @msg `stops` must be an array `stops` एक सारणी होना चाहिए @msg `story` styles the whole story: give no `start` or `end` `story` पूरी स्टोरी को स्टाइल करता है: `start` या `end` न दें @msg `story` takes the whole story: give no `start` or `end` `story` पूरी स्टोरी लेता है: `start` या `end` न दें -@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` स्ट्रोक पर लागू होता है (स्ट्रोक: सच) +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` स्ट्रोक पर लागू होता है (stroke: true) @msg `style` must be roman or japanese `style` को रोमन या जापानी होना चाहिए @msg `subpaths` must be two integer indices `subpaths` होना चाहिए दो पूर्णांक सूचकांक @msg `svg` must be an object of SVG options (see document.formats) `svg` को SVG ऑप्शन्स का ऑब्जेक्ट होना चाहिए (document.formats देखें) @@ -5708,8 +5708,8 @@ @msg `to` takes the rows of one object's stack, not aligned rows `to` एक ऑब्जेक्ट के स्टैक की पंक्तियों को लेता है, पंक्तियां संरेखित नहीं है @msg `tolerance` must be from 0 to 3 pt `tolerance` को 0 से 3 pt के बीच होना चाहिए @msg `tolerance` must be from 1 to 100 `tolerance` को 1 से 100 के बीच होना चाहिए -@msg `top` and `below` can't both be true `top` और `below` दोनों सच नहीं हो सकते -@msg `useLab` must be true or false `useLab` को सही या झूठ होना चाहिए +@msg `top` and `below` can't both be true `top` और `below` दोनों true नहीं हो सकते +@msg `useLab` must be true or false `useLab` को true या false होना चाहिए @msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` डिफ़ॉल्ट, शीर्ष, मध्य, बेसलाइन या नीचे होना चाहिए @msg `values` must be a list `values` एक सूची होना चाहिए @msg `values` must be an object `values` एक ऑब्जेक्ट होना चाहिए @@ -5744,7 +5744,7 @@ @msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files अधिकांश {MAX_SVG_SCRIPTS} जावास्क्रिप्ट फ़ाइलों पर एक डॉक्यूमेंट लिंक @msg a document needs at least one artboard कम से कम एक आर्टबोर्ड की आवश्यकता होती है @msg a document needs at least one layer डॉक्यूमेंट की कम से कम एक लेयर की आवश्यकता होती है -@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard एक DXF ड्राइंग एक मूल है: पास useArtboards: कलाबोर्ड प्रति एक ड्राइंग के लिए सच +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard एक DXF ड्राइंग का एक मूल होता है: हर आर्टबोर्ड के लिए एक ड्राइंग के लिए useArtboards: true दें @msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes एक feConvolveMatrix फिल्टर का आदेश {x} ×{y} है, जो कर्नेल-आकार की बहुप्लाई usvg को ओवरफ्लो करता है। @msg a freeform gradient keeps at least one point एक freeform ग्रेडिएंट कम से कम एक बिंदु रखता है @msg a gradient needs at least two stops कम से कम दो स्टॉप की आवश्यकता होती है @@ -5767,7 +5767,7 @@ @msg a mask that covers several layers comes in as a clipping group in each layer it covers एक मास्क जो कई लेयर्स को कवर करता है, प्रत्येक लेयर में एक क्लिपिंग समूह के रूप में आता है, यह कवर करता है @msg a multi-layer mask needs one clipping path बहुलेयर मास्क को एक क्लिपिंग पाथ की आवश्यकता होती है @msg a multi-layer mask's operators are out of order बहुलेयर मास्क ऑपरेटर क्रम से बाहर हैं -@msg a name names one asset: give multiple: false, or one object नाम एक परिसंपत्ति: एकाधिक देना: झूठा, या एक ऑब्जेक्ट +@msg a name names one asset: give multiple: false, or one object एक नाम एक ही एसेट को दर्शाता है: multiple: false दें, या एक ऑब्जेक्ट @msg a new document refuses this file because of these variables: {names} एक नया डॉक्यूमेंट इन चरों के कारण इस फ़ाइल को मना कर देता है: {names} @msg a new document won't read these variables back with their kind: {names} नया डॉक्यूमेंट इन वेरिएबल्स का प्रकार वापस नहीं पढ़ पाएगा: {names} @msg a page can't be read एक पेज पढ़ा नहीं जा सकता @@ -5816,7 +5816,7 @@ @msg a symbol can't hold an instance of itself एक सिंबल खुद का एक उदाहरण नहीं पकड़ सकता है @msg a symbol named `{n}` already exists पहले से ही `{n}` नाम का सिंबल मौजूद है @msg a theme has 1 to {THEME_SIZE} colours थीम में 1 से {THEME_SIZE} रंग होते हैं -@msg a theme is already called {name} (replace: "{name}" overwrites it) एक विषय को पहले से ही {name} कहा जाता है (प्रतिस्थापन: "{name}" इसे ओवरराइट करता है) +@msg a theme is already called {name} (replace: "{name}" overwrites it) {name} नाम की थीम पहले से है (replace: "{name}" उसे ओवरराइट करता है) @msg a toolbar name has at most {MAX_NAME} characters टूलबार नाम में सबसे {MAX_NAME} वर्ण हैं @msg a value has the wrong type for the operator एक मूल्य ऑपरेटर के लिए गलत प्रकार है @msg a value is outside the range the operator takes एक मूल्य सीमा के बाहर है जो ऑपरेटर लेता है @@ -5826,7 +5826,7 @@ @msg a view named `{name}` exists `{name}` नामक दृश्य मौजूद है @msg a workspace keeps at most {MAX} toolbars एक वर्कस्पेस सबसे {MAX} टूलबार में रहता है @msg a wraparound group is inside a compound path एक रैपराउंड समूह एक मिश्रित पाथ के अंदर है -@msg action is required कार्रवाई की आवश्यकता है +@msg action is required action आवश्यक है @msg add a format to export निर्यात करने के लिए एक फ़ॉर्मैट जोड़ें @msg Add Points at Crossings: a path went missing क्रॉसिंग्स में अंक जोड़ें: एक पाथ गायब हो गया @msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF-संगत, डिफ़ॉल्ट) या "7" (पुरानी .ai, संस्करण 7) @@ -6019,12 +6019,12 @@ @msg Choose two non-parallel straight segments दो गैर-parallel सीधे खंड चुनें @msg Choose two nonzero angle arms दो nonzero कोण हथियार चुनें @msg Click in type with a Type tool to split it इसे विभाजित करने के लिए टाइप टूल के साथ टाइप करें -@msg clip must be true or false clip: क्लिप को सच या झूठा होना चाहिए +@msg clip must be true or false clip को true या false होना चाहिए @msg clipboard is empty क्लिपबोर्ड खाली है @msg clippingMask must be true or false clippingMask को true या false होना चाहिए @msg closed must be a boolean closed: बंद होना चाहिए एक boolean @msg closing needs three anchors closing: बंद करने की जरूरत है तीन एंकर -@msg collapsed must be true or false collapsed: पतन सच होना चाहिए या गलत होना चाहिए +@msg collapsed must be true or false collapsed को true या false होना चाहिए @msg color.profile: no RGB or CMYK profile is called “{profile}” Color.profile: कोई RGB या CMYK प्रोफ़ाइल को "{profile}" कहा जाता है। @msg colorMode must be rgb or cmyk, not `{m}` colorMode को rgb या cmyk होना चाहिए, `{m}` नहीं @msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel को {own} (डॉक्यूमेंट का मोड), grayscale या bitmap होना चाहिए @@ -6037,8 +6037,8 @@ @msg column {column}: `{cell}` names no variable कॉलम {column}: `{cell}` नाम कोई चर नहीं @msg column {column}: the variable name is longer than {MAX_NAME} characters कॉलम {column}: चर नाम {MAX_NAME} अक्षरों से लंबा है @msg column docks a set as a dock column, with no anchor or icon कॉलम एक डॉक कॉलम के रूप में सेट करता है, जिसमें कोई एंकर या आइकन नहीं होता है -@msg column must be true or a number from 1 column: कॉलम सही होना चाहिए या 1 से एक नंबर होना चाहिए -@msg column: true floats the whole column and takes no onto, above or below स्तंभ: सच पूरे स्तंभ को तैरता है और ऊपर या नीचे नहीं लेता है +@msg column must be true or a number from 1 column को true या 1 या उससे बड़ी संख्या होना चाहिए +@msg column: true floats the whole column and takes no onto, above or below column: true पूरे कॉलम को फ़्लोट करता है और onto, above या below नहीं लेता @msg columns {first} and {column} both name the variable `{name}` कॉलम {first} और {column} दोनों नाम चर `{name}` @msg Combine Slices: the slices have no bounds स्लाइस को मिलाएं: स्लाइस में कोई सीमा नहीं है @msg command `{0}` is not available right now: {1} आदेश `{0}` अभी उपलब्ध नहीं है: {1} @@ -6114,7 +6114,7 @@ @msg decimals must be {_1}–{_2} (got {_3}) decimals: दशमलव होना चाहिए {_1}-{_2} ({_3}) @msg decoded data is too large डीकोडेड डेटा बहुत बड़ा है @msg define a variable first पहले एक चर को परिभाषित करना -@msg detect must be true or false detect: पता लगाना सही या गलत होना चाहिए +@msg detect must be true or false detect को true या false होना चाहिए @msg Dimension is locked or hidden आयाम बंद या छिपा हुआ है @msg Dimension mode does not match its source आयाम मोड अपने स्रोत से मेल नहीं खाता है @msg Dimension point is outside the canvas आयाम बिंदु कैनवास के बाहर है @@ -6275,7 +6275,7 @@ @msg give one of assets, layers, selection or fullDocument assets, layers, selection या fullDocument में से एक दें @msg give only one of onto, above, below, icon and a column place केवल ऊपर, नीचे, आइकन और एक स्तंभ स्थान देने के लिए @msg give only one of onto, below and above नीचे और ऊपर केवल एक देना -@msg give origin: [x, y], or reset: true मूल देना: [x, y], या रीसेट करें: सच +@msg give origin: [x, y], or reset: true origin: [x, y] दें, या reset: true @msg give overprintFill, overprintStroke, showCenter, imageMap, url or note overprintFill, overprintStroke, showCenter, imageMap, url या नोट @msg give path (a file) or folder पाथ (एक फ़ाइल) या फ़ोल्डर देना @msg give path, or name and dataBase64 path या name और dataBase64 दें @@ -6296,7 +6296,7 @@ @msg give x, y, or a handle's length or angle x, y, या एक हैंडल की लंबाई या कोण देना @msg Global Edit is off ग्लोबल एडिट बंद है @msg Global Edit works on paths, compound paths, symbol instances and groups of them ग्लोबल एडिट पाथ, मिश्रित पाथ, सिंबल उदाहरणों और उनके समूहों पर काम करता है -@msg global must be true or false global: वैश्विक होना चाहिए या गलत होना चाहिए +@msg global must be true or false global को true या false होना चाहिए @msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) Glyph {_1} चरित्र की स्टाइल (जैसे सभी कैप्स, छोटे कैप्स या एक संख्यात्मक प्रणाली) के साथ {ch} के लिए तैयार नहीं है। @msg glyph {g} is not an alternate of {ch} in {_1} {_2} Glyph {g} {_1} {_2} में {ch} का एक वैकल्पिक नहीं है। @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it ग्रेडिएंट "{name}" को फ़ाइल में परिभाषित नहीं किया गया है: इसके लिए एक सादे रंग खड़ा है @@ -6306,7 +6306,7 @@ @msg gridColors must be a preset name or two colours gridColors को प्रीसेट नाम या दो रंग होना चाहिए @msg gridSize must be small, medium or large gridSize को small, medium या large होना चाहिए @msg group must be the id of a Shaper Group group: समूह को एक शेपर ग्रुप का आईडी होना चाहिए -@msg group must be true or false group: समूह को सच या झूठा होना चाहिए +@msg group must be true or false group को true या false होना चाहिए @msg guide {g} is not selected गाइड {g} नहीं चुना गया है @msg guides are locked गाइड लॉक कर रहे हैं @msg handle must be 0..3 handle को 0..3 होना चाहिए @@ -6318,7 +6318,7 @@ @msg highlight two or more rows to merge दो या अधिक पंक्तियों को मर्ज करने के लिए हाइलाइट करें @msg HTML Text applies to object slices of type only एचटीएमएल टेक्स्ट केवल टाइप के ऑब्जेक्ट स्लाइस पर लागू होता है @msg icon docks a panel in the icon column: use window.panel.dock आइकन कॉलम में एक पैनल डॉक करता है: विंडो का उपयोग करें.पैनल.dock -@msg icon must be true or false icon: आइकन सही या झूठी होना चाहिए +@msg icon must be true or false icon को true या false होना चाहिए @msg icon only docks a panel in the icon column, with no anchor आइकन केवल आइकन कॉलम में एक पैनल को डॉक करता है, जिसमें कोई एंकर नहीं होता है @msg id {value} is a layer, not an object Id {value} एक लेयर है, कोई ऑब्जेक्ट नहीं है @msg id {value} is a layer, not an object; move layers with layer.move Id {value} एक लेयर है, कोई ऑब्जेक्ट नहीं है; लेयर के साथ लेयर्स को स्थानांतरित करें। @@ -6339,10 +6339,10 @@ @msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client या server @msg Imported {what}: {_1} आयात {what}: {_1} @msg Imported library {name} आयातित लाइब्रेरी {name} -@msg included must be true or false included: इसमें सही या गलत होना चाहिए +@msg included must be true or false included को true या false होना चाहिए @msg index must be ≥ 0 index: सूचकांक होना चाहिए ≥ 0 -@msg index must be a number or first, previous, next or last index: सूचकांक एक नंबर या पहले होना चाहिए, पिछले, अगले या आखिरी -@msg index must be a number or first, previous, next or last, not `{other}` index: सूचकांक एक नंबर या पहले होना चाहिए, पिछले, अगले या आखिरी, नहीं `{other}` +@msg index must be a number or first, previous, next or last index को एक नंबर या first, previous, next या last होना चाहिए +@msg index must be a number or first, previous, next or last, not `{other}` index को एक नंबर या first, previous, next या last होना चाहिए, `{other}` नहीं @msg index out of range रेंज से बाहर सूचकांक @msg Installed plug-in {_1} स्थापित प्लग-इन {_1} @msg installing from a path is not available on the web; pass the module as `dataBase64` एक पाथ से स्थापित वेब पर उपलब्ध नहीं है; मॉड्यूल को `dataBase64`` के रूप में पास करें @@ -6434,7 +6434,7 @@ @msg layers can't be intertwined: select the objects in them लेयर्स में हस्तक्षेप नहीं किया जा सकता है: उनमें ऑब्जेक्ट्स सिलेक्ट करें @msg layers can't be wrap objects लेयर्स को लपेटा नहीं जा सकता @msg layers go only at the top level or in layers, objects in layers and groups लेयरें केवल शीर्ष स्तर पर या लेयर्स में, लेयर्स और समूहों में ऑब्जेक्टएं -@msg layers must be true or false layers: लेयरें सत्य या झूठी होनी चाहिए +@msg layers must be true or false layers को true या false होना चाहिए @msg layout must be gridByRow, gridByColumn, row or column layout को gridByRow, gridByColumn, row या column होना चाहिए @msg leading must be a number or "auto" leading: अग्रणी एक नंबर या "auto" होना चाहिए @msg left/right must be non-negative widths बाएं / दाएं गैर-नकारात्मक चौड़ाई होना चाहिए @@ -6444,7 +6444,7 @@ @msg line {line}: more than {MAX_CSV_COLS} columns लाइन {line}: {MAX_CSV_COLS} कॉलम से अधिक @msg line {line}: unclosed quote लाइन {line}: unclosed उद्धरण @msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes लाइन {line}: `{var}` के लिए मूल्य {MAX_GRAPH_CSV} बाइट्स से अधिक है -@msg line {line}: value for `{var}` must be true or false लाइन {line}: `{var}` के लिए मान सही या झूठ होना चाहिए +@msg line {line}: value for `{var}` must be true or false लाइन {line}: `{var}` के लिए मान true या false होना चाहिए @msg line {line}: value for `{var}` must match its variable kind लाइन {line}: `{var}` के लिए मान इसके परिवर्तनीय प्रकार से मेल खाता होना चाहिए @msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf या crlf @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel लिंक किए गए चित्रों से पता चलता है कि फ़ाइल में सेव किए जाने वाले निम्न-रिज़ॉल्यूशन प्रीव्यू को उनकी फ़ाइलों को मिलने तक: लिंक पैनल में उन्हें फिर से लिंक करें @@ -6468,7 +6468,7 @@ @msg matte: {e} मैट: {e} @msg Measured anchor no longer exists मापने वाला एंकर अब मौजूद नहीं है @msg Measured segment no longer exists मापदंड अब मौजूद नहीं है -@msg member is for order front, forward, backward or back सदस्य ऑर्डर फ्रंट, फॉरवर्ड, बैकवर्ड या बैक के लिए है +@msg member is for order front, forward, backward or back member केवल order front, forward, backward या back के लिए है @msg memory budget (the selection is too large to hand over) स्मृति बजट (चुनाव बहुत बड़ा है) @msg missing `{k}` लापता `{k}` @msg missing `{key}` लापता `{key}` @@ -6553,7 +6553,7 @@ @msg missing target लापता लक्ष्य @msg missing to याद करना @msg missing x, y लापता xy -@msg mode is set, add or toggle, not `{other}` मोड सेट, ऐड या टॉगल है, नहीं `{other}` +@msg mode is set, add or toggle, not `{other}` mode set, add या toggle है, `{other}` नहीं @msg mode must be "cmyk" or "rgb" mode: मोड "cmyk" या "rgb" होना चाहिए @msg mode must be area|onPath, got {o} mode: मोड होना चाहिए क्षेत्रफल (onPath), {o} मिला @msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स (सीम {_2}) है। @@ -6577,7 +6577,7 @@ @msg name is empty नाम खाली है @msg name must be a toolbar's name name: नाम एक टूलबार का नाम होना चाहिए @msg name the file फ़ाइल नाम -@msg names is empty नाम खाली है +@msg names is empty names खाली है @msg names must be an array of symbol names names: नाम सिंबल नामों की एक सरणी होना चाहिए @msg need `anchor` or `segment` + `t` की जरूरत `anchor` या `segment` + `t` @msg need at least one anchor कम से कम एक एंकर की जरूरत है @@ -6925,7 +6925,7 @@ @msg Offset Path: select paths ऑफसेट पाथ: सिलेक्शन पाथ @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) ऑफसेट पाथ: ऑफ़सेट सिलेक्टेड पाथ की कुछ भी नहीं छोड़ता है (आकार की तुलना में एक निर्धारित बड़ा) @msg Offset Path: the path is too complex to offset ऑफसेट पाथ: पाथ ऑफसेट करने के लिए बहुत जटिल है -@msg on must be true or false on: पर सच होना चाहिए या गलत होना चाहिए +@msg on must be true or false on को true या false होना चाहिए @msg only a dock group above or below others collapses, not one beside another केवल दूसरों के ऊपर या नीचे एक डॉक समूह गिर जाता है, एक दूसरे के बगल में नहीं @msg only an exporter declares `extensions` केवल निर्यातक `extensions` घोषित करता है @msg only an object of the Global Edit other than the one it started from can be left out केवल ग्लोबल एडिट का एक ऑब्जेक्ट, इसके अलावा इसे शुरू किया जा सकता है, छोड़ दिया जा सकता है @@ -6947,7 +6947,7 @@ @msg options must be an object (see file.formatOptions) options: ऑप्शन्स एक ऑब्जेक्ट होना चाहिए (file.formatOptions देखें) @msg order must be leftToRight or rightToLeft order को leftToRight या rightToLeft होना चाहिए @msg orientation must be portrait or landscape, not `{o}` orientation: अभिविन्यास चित्र या परिदृश्य होना चाहिए, नहीं `{o}` -@msg others must be true or false others: दूसरों को सच होना चाहिए या गलत होना चाहिए +@msg others must be true or false others को true या false होना चाहिए @msg Outline Stroke: no stroked paths selected आउटलाइन स्ट्रोक: सिलेक्टेड कोई स्ट्रोक पाथ नहीं @msg outlineText {v}: true or false outlineText {v}: true या false @msg output intent profile: {e} उत्पादन आशय प्रोफ़ाइल: {e} @@ -7018,7 +7018,7 @@ @msg pins must be finite pins: पिन परिमित होना चाहिए @msg place is above, below or inside, not `{other}` place: स्थान ऊपर, नीचे या अंदर है, नहीं `{other}` @msg Placing {_1} पतंग {_1} -@msg plane must be left, right or ground plane: समतल बाएं, दाएं या जमीन होना चाहिए +@msg plane must be left, right or ground plane को left, right या ground होना चाहिए @msg plane must be left|right|ground plane को left|right|ground होना चाहिए @msg plane must be left|right|ground|none plane को left|right|ground|none होना चाहिए @msg plug-in ABI: {0} प्लग-इन ABI: {0} @@ -7069,7 +7069,7 @@ @msg recovery copy `{file}`: {e} वसूली प्रतिलिपि `{file}`: {e} @msg rect and {k} contradict each other: give the area or the artboards सही और {k} एक दूसरे के विपरीत: क्षेत्र या आर्टबोर्ड देना @msg rect applies to raster formats, not {_1} सही रेस्टर फ़ॉर्मैट्स पर लागू होता है, न कि {_1} -@msg rect is [x, y, width, height] in points सही [x, y, चौड़ाई, ऊंचाई] अंक में है +@msg rect is [x, y, width, height] in points rect पॉइंट में [x, y, width, height] है @msg rect must be [x, y, width, height] rect: [x, y, चौड़ाई, ऊंचाई] होना चाहिए @msg rect must be [x, y, width, height] with a positive width and height rect: सही होना चाहिए [x, y, चौड़ाई, ऊंचाई] सकारात्मक चौड़ाई और ऊंचाई के साथ @msg rect needs finite numbers and a positive width and height rect: सही की जरूरत परिमित संख्या और सकारात्मक चौड़ाई और ऊंचाई @@ -7237,7 +7237,7 @@ @msg shortcut for `{k}` must be a string `{k}` के लिए शॉर्टकट एक स्ट्रिंग होना चाहिए @msg show `{show}`: all, missing, modified or embedded `{show}``: सभी, लापता, संशोधित या एम्बेडेड @msg Show All Hidden Attributes: nothing is hidden Show all Hidden Attributes: कुछ भी छिपा नहीं है -@msg show must be true or false show: प्रदर्शन को सच या झूठा होना चाहिए +@msg show must be true or false show को true या false होना चाहिए @msg Simplify Anchors: the paths have too many anchors to simplify at once एंकर को सरलीकृत करें: पाथ में एक बार में सरल बनाने के लिए कई एंकर हैं @msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: आकार "small", "medium", "large" या 0 से अधिक अंक {_1} तक होना चाहिए @msg size must be positive size: आकार सकारात्मक होना चाहिए @@ -7252,7 +7252,7 @@ @msg span must be "object" or "selection", got {v} span: स्पैन को "object" या "selection" होना चाहिए, {v} मिला @msg span: "selection" places a `gradient`, not a swatch span: "selection" एक `gradient` रखता है, स्वॉच नहीं @msg span: "selection" places a gradient: give `gradient` span: "selection" ग्रेडिएंट रखता है: `gradient` दें -@msg span: "selection" places a linear or radial gradient, not a freeform one स्पैन: "selection" एक रैखिक या रेडियल ग्रेडिएंट रखता है, एक फ्रीफॉर्म नहीं एक +@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" एक रैखिक या रेडियल ग्रेडिएंट रखता है, फ्रीफॉर्म ग्रेडिएंट नहीं @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" खुद ग्रेडिएंट रखता है: `start` और `end` न दें (वे उसे डॉक्यूमेंट निर्देशांकों पर रख देते हैं) @msg speed must be accelerated, step or pause speed: गति को तेज, कदम या रोकें होना चाहिए @msg Split Into Grid: select paths large enough for the grid स्प्लिट इनो ग्रिड: ग्रिड के लिए काफी बड़े पाथ सिलेक्ट करें @@ -7593,7 +7593,7 @@ @msg tileRange of {what}: {e} {what} का tileRange: {e} @msg time budget समय बजट @msg tintReduction must be from 0 to 100 % tintReduction 0 से 100 % तक होना चाहिए -@msg title can't be empty शीर्षक खाली नहीं हो सकता है +@msg title can't be empty title खाली नहीं हो सकता @msg to (the new name) is required (नया नाम) के लिए आवश्यक है @msg to must be fill or stroke to: होना चाहिए भरने या स्ट्रोक होना चाहिए @msg to must be smooth or corner to: होना चाहिए चिकनी या कोने होना @@ -7758,7 +7758,7 @@ @msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it VectorCraft के फ़ॉन्ट फ़ोल्डर यहां सेट नहीं है: डेस्कटॉप ऐप और VectorCraft-क्लाई ने इसे सेट किया @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files संस्करण {0} की फ़ाइलें संपीड़ित नहीं हो सकतीं: केवल संस्करण {COMPRESSED_SINCE} और बाद के संस्करण संपीड़ित फ़ाइलें खोलते हैं @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio को 0.1 से 10 के बीच संख्या होना चाहिए -@msg view is required देखने की आवश्यकता है +@msg view is required view आवश्यक है @msg view must be one of {_1} view: राय होना चाहिए {_1} @msg warpStyle can only edit an applied Warp effect warpStyle केवल लागू वार्प इफ़ेक्ट एडिट कर सकता है @msg width and height are at least 1 pixel चौड़ाई और ऊंचाई कम से कम 1 पिक्सेल हैं @@ -7777,7 +7777,7 @@ @msg Your unsaved changes are kept in this browser: reload the page to get them back. इस ब्राउज़र में आपके बिना सेव किए गए बदलाव को रखा गया है: उन्हें वापस पाने के लिए पेज को फिर से लोड करें। @msg zipped 32-bit PSD image data with prediction can't be read भविष्यवाणी के साथ 32-bit PSD इमेज डेटा zipped नहीं किया जा सकता @msg zone must be a string zone: क्षेत्र एक स्ट्रिंग होना चाहिए -@msg zone must be tab, center, left, right, top or bottom zone: क्षेत्र टैब होना चाहिए, केंद्र, बाएं, दाएं, ऊपर या नीचे +@msg zone must be tab, center, left, right, top or bottom zone को tab, center, left, right, top या bottom होना चाहिए @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone: जोन को एक एंकर की आवश्यकता होती है: एंकर के साथ एक पैनल के बगल में, या साइड (स्टार्ट या एंड) के साथ एक नए डॉक कॉलम में @msg zoom must be positive zoom: ज़ूम सकारात्मक होना चाहिए @msgplural ({n} linked file not found)|({n} linked files not found) ({n} लिंक्ड फ़ाइल नहीं मिली)|({n} लिंक्ड फ़ाइलें नहीं मिलीं) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3153fae2a..db219a38b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4827,7 +4827,7 @@ imageTrace Outline Körvonal @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Az adat-helyreállításnak nincs hova mentenie a másolatokat: állítsd be a Beállítások → Fájlkezelés → Adat-helyreállítás → Mappa értéket (recoveryFolder) @msg Data Recovery can't keep copies in {path}: {e} Az adat-helyreállítás nem tud másolatokat menteni ide: {path}: {e} @msg Plug-ins can't be loaded from {path}: {e} Innen nem tölthetők be bővítmények: {path}: {e} -@msg decimals must be {_1}–{_2} (got {_3}) a tizedesjegyek száma {_1}–{_2} lehet (kapott érték: {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) a decimals értéke {_1}–{_2} lehet (kapott érték: {_3}) @msg direct-select anchor points to cut at (not the end points of open paths) jelöld ki közvetlen kijelöléssel a vágási horgonypontokat (nyitott útvonal végpontja nem lehet) @msgplural Discarded {n} recovered document|Discarded {n} recovered documents Elvetett helyreállított dokumentumok: {n} @msg divide into 1 to {MAX_PIECES} slices a felosztás 1 és {MAX_PIECES} közötti szeletre lehetséges @@ -5198,8 +5198,8 @@ imageTrace Outline Körvonal @msg Saving Mentés @msg Saving recovery data for {_1} Helyreállítási adatok mentése: {_1} @msg Saving {_1} Mentés: {_1} -@msg scale must be a number from {_1} to {_2}, not {_3} a méretarány {_1} és {_2} közötti szám lehet, ez nem az: {_3} -@msg scale must be a number from {_1} to {_2}, not {s} a méretarány {_1} és {_2} közötti szám lehet, ez nem az: {s} +@msg scale must be a number from {_1} to {_2}, not {_3} a scale {_1} és {_2} közötti szám lehet, ez nem az: {_3} +@msg scale must be a number from {_1} to {_2}, not {s} a scale {_1} és {_2} közötti szám lehet, ez nem az: {s} @msg searching a folder isn't available on the web a webes változatban nem lehet mappában keresni @msg select (or target) objects whose appearance isn't basic jelölj ki (vagy célozz meg) olyan objektumokat, amelyeknek nem alapmegjelenésük van @msg select a blend jelölj ki egy összemosást @@ -5231,7 +5231,7 @@ imageTrace Outline Körvonal @msg select paths or anchor points jelölj ki útvonalakat vagy horgonypontokat @msg select paths to join jelöld ki az összekapcsolandó útvonalakat @msg select some type first előbb jelölj ki szöveget -@msg kerning must be a number, "auto", or "optical" Az alávágás szám, „auto” vagy „optical” lehet +@msg kerning must be a number, "auto", or "optical" a kerning szám, „auto” vagy „optical” lehet @msg the selection has no gradient fill a kijelölésnek nincs színátmenetes kitöltése @msg the selection has no gradient stroke a kijelölésnek nincs színátmenetes körvonala @msg the selection has no pattern fill a kijelölésnek nincs mintázatos kitöltése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9ba667353..3ac90fabf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4002,7 +4002,7 @@ imageTrace Outline Kontur @plural {n} stroke|{n} strokes {n} goresan @msg no library `{id}` tidak ada pustaka `{id}` @msg select some type first pilih beberapa teks terlebih dahulu -@msg kerning must be a number, "auto", or "optical" Kerning harus berupa angka, “auto”, atau “optical” +@msg kerning must be a number, "auto", or "optical" kerning harus berupa angka, “auto”, atau “optical” @msg {_1} has no {_2} style {_1} tidak memiliki gaya {_2} @msg a layer couldn't be read satu lapisan tidak dapat dibaca @msg it has more than one artboard memiliki lebih dari satu artboard @@ -4177,7 +4177,7 @@ imageTrace Outline Kontur @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art, atau type @msg artboard {0} does not exist artboard {0} tidak ada @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard pada skala ini gambar berukuran {w} × {h} pt, melebihi kapasitas kanvas: pilih skala yang lebih kecil atau paskan ke artboard -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` sumbu harus "vertical", "horizontal", atau sudut dalam derajat, bukan `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis harus "vertical", "horizontal", atau sudut dalam derajat, bukan `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparan, putih, hitam, atau warna seperti "#ff8800" @msg bad color {v} warna tidak valid {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio harus berupa angka dari 0,1 sampai 10 @@ -4392,9 +4392,9 @@ imageTrace Outline Kontur @msg replace: the object to replace was changed or deleted ganti: objek yang akan diganti telah diubah atau dihapus @msg pattern missing pola hilang @msg patterns nested too deeply are filled with mid-grey pola yang bersarang terlalu dalam diisi dengan abu-abu tengah -@msg percent must be above 0 and at most 6400, not {p} persen harus di atas 0 dan paling banyak 6400, bukan {p} +@msg percent must be above 0 and at most 6400, not {p} percent harus di atas 0 dan paling banyak 6400, bukan {p} @msg pick a colour, gradient or pattern to save as a swatch pilih warna, gradien, atau pola untuk disimpan sebagai swatch -@msg plane must be left, right or ground bidang harus kiri, kanan, atau tanah +@msg plane must be left, right or ground plane harus left, right, atau ground @msg plug-in ABI: {0} ABI plug-in: {0} @msg plug-in exceeded its {0} plug-in melampaui {0}-nya @msg plug-in failed: {0} plug-in gagal: {0} @@ -4413,8 +4413,8 @@ imageTrace Outline Kontur @msg result would exceed the canvas (coordinates out of range) hasil akan melampaui kanvas (koordinat di luar rentang) @msg objects can't be nested more than 32 deep objek tidak dapat bersarang lebih dari 32 tingkat @msg the SVG's elements are nested more than {0} deep elemen SVG bersarang lebih dari {0} tingkat -@msg scale must be a number from {_1} to {_2}, not {_3} skala harus berupa angka dari {_1} sampai {_2}, bukan {_3} -@msg scale must be a number from {_1} to {_2}, not {s} skala harus berupa angka dari {_1} sampai {_2}, bukan {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale harus berupa angka dari {_1} sampai {_2}, bukan {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale harus berupa angka dari {_1} sampai {_2}, bukan {s} @msg select (or target) objects whose appearance isn't basic pilih (atau jadikan target) objek yang penampilannya bukan dasar @msg select a Live Paint group pilih grup Live Paint @msg select a blend pilih blend diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bfef006c9..0d54ffe59 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4021,7 +4021,7 @@ axis Both Entrambi # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` nessuna libreria `{id}` @msg select some type first seleziona prima del testo -@msg kerning must be a number, "auto", or "optical" La crenatura deve essere un numero, «auto» o «optical» +@msg kerning must be a number, "auto", or "optical" kerning deve essere un numero, «auto» o «optical» @msg the selection has no gradient fill la selezione non ha una sfumatura di riempimento @msg the selection has no gradient stroke la selezione non ha una sfumatura di traccia @msg the selection has no pattern fill la selezione non ha un motivo di riempimento @@ -4248,9 +4248,9 @@ axis Both Entrambi @msg bad shortcut scelta rapida non valida @msg bad stream length lunghezza dello stream non valida @msg bad trailer trailer non valido -@msg balance must be between 0 and 100 il bilanciamento deve essere compreso tra 0 e 100 +@msg balance must be between 0 and 100 balance deve essere compreso tra 0 e 100 @msg batch step {_1} (`{id}`) failed: {e} il passaggio {_1} del batch (`{id}`) non è riuscito: {e} -@msg brightness must be dark|mediumDark|mediumLight|light la luminosità deve essere dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness deve essere dark|mediumDark|mediumLight|light @msg can't encode the document: {0} impossibile codificare il documento: {0} @msg can't parse shortcut `{c}` impossibile interpretare la scelta rapida `{c}` @msg can't parse shortcut `{s}` for `{k}` impossibile interpretare la scelta rapida `{s}` per `{k}` @@ -4275,7 +4275,7 @@ axis Both Entrambi @msg compound paths can only contain paths i tracciati composti possono contenere solo tracciati @msg compressed (RLE) bitmaps can't be read yet le bitmap compresse (RLE) non possono ancora essere lette @msg damaged danneggiato -@msg decimals must be {_1}–{_2} (got {_3}) i decimali devono essere {_1}–{_2} (ricevuto {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals deve essere {_1}–{_2} (ricevuto {_3}) @msg divide into 1 to {MAX_PIECES} slices dividi in un numero di sezioni da 1 a {MAX_PIECES} @msg effect {k} is `{_1}`, not `{id}` l'effetto {k} è `{_1}`, non `{id}` @msg empty brush stroke traccia di pennello vuota @@ -4474,7 +4474,7 @@ axis Both Entrambi @msg replace: the object to replace was changed or deleted replace: l’oggetto da sostituire è stato modificato o eliminato @msg pattern missing motivo mancante @msg patterns nested too deeply are filled with mid-grey i motivi annidati troppo in profondità vengono riempiti di grigio medio -@msg percent must be above 0 and at most 6400, not {p} la percentuale deve essere maggiore di 0 e al massimo 6400, non {p} +@msg percent must be above 0 and at most 6400, not {p} percent deve essere maggiore di 0 e al massimo 6400, non {p} @msg pick a colour, gradient or pattern to save as a swatch scegli un colore, una sfumatura o un motivo da salvare come campione @msg plane must be left, right or ground plane deve essere left, right o ground @msg plug-in ABI: {0} ABI del plug-in: {0} @@ -4495,8 +4495,8 @@ axis Both Entrambi @msg result would exceed the canvas (coordinates out of range) il risultato supererebbe l'area di disegno (coordinate fuori intervallo) @msg objects can't be nested more than 32 deep gli oggetti non possono essere nidificati oltre 32 livelli @msg the SVG's elements are nested more than {0} deep gli elementi dell'SVG sono nidificati oltre {0} livelli -@msg scale must be a number from {_1} to {_2}, not {_3} la scala deve essere un numero da {_1} a {_2}, non {_3} -@msg scale must be a number from {_1} to {_2}, not {s} la scala deve essere un numero da {_1} a {_2}, non {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale deve essere un numero da {_1} a {_2}, non {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale deve essere un numero da {_1} a {_2}, non {s} @msg select (or target) objects whose appearance isn't basic seleziona (o imposta come destinazione) oggetti con un aspetto non di base @msg select a Live Paint group seleziona un gruppo di pittura dinamica @msg select a blend seleziona una fusione @@ -4965,7 +4965,7 @@ effect Regular Normale @msg before must name a panel before deve indicare un pannello @msg docking action contains an unknown panel or an excessive split path L’azione di ancoraggio contiene un pannello sconosciuto o un percorso di divisioni troppo lungo @msg group must be true or false group deve essere true o false -@msg panel is not in a dock group il pannello non è in un gruppo del dock +@msg panel is not in a dock group panel non è in un gruppo del dock @msg only a dock group above or below others collapses, not one beside another si comprime solo un gruppo del dock sopra o sotto altri, non uno accanto @msg icon must be true or false icon deve essere true o false @msg icon only docks a panel in the icon column, with no anchor icon ancora un pannello solo nella colonna delle icone, senza anchor diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 07ce883e6..284f8cffb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4115,14 +4115,14 @@ swatch Bright Violet ビビッドバイオレット @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 前回の VectorCraft の起動はウィンドウが表示される前に {name} で終了したため、{summary} で起動しました。このグラフィックプロセッサーをもう一度試すには、「環境設定 › パフォーマンス › グラフィックプロセッサー」を変更するか、WGPU_BACKEND を設定して起動してください @msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set 前回の VectorCraft の起動はグラフィックプロセッサーの一覧を取得している間に終了したため、OpenGL のみを使って {summary} で起動しました。ほかのグラフィックバックエンドをもう一度試すには、「環境設定 › パフォーマンス › グラフィックプロセッサー」を変更するか、WGPU_BACKEND を設定して起動してください @msg not a DXF drawing DXF 図面ではありません -@msg scale must be a number from {_1} to {_2}, not {s} 縮尺は {_1} から {_2} までの数値にしてください ({s} は指定できません) +@msg scale must be a number from {_1} to {_2}, not {s} scale は {_1} から {_2} までの数値にしてください ({s} は指定できません) @msg the artboard to fit must be a positive size up to {_1} pt 合わせる先のアートボードのサイズは 0 より大きく {_1} pt 以下にしてください @msg the drawing has no layout `{_1}` (it has {_2}) 図面にレイアウト `{_1}` はありません (含まれるレイアウト: {_2}) @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that DWG 図面は読み込めません: CAD アプリで ASCII DXF として保存し、そのファイルを開いてください @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard この縮尺では図面が {w} × {h} pt になり、カンバスに収まりません: 縮尺を小さくするか、アートボードに合わせてください @msg the scale is not a usable number 縮尺が有効な数値ではありません @msg the drawing is too large to open 図面が大きすぎて開けません -@msg scale must be a number from {_1} to {_2}, not {_3} 縮尺は {_1} から {_2} までの数値にしてください ({_3} は指定できません) +@msg scale must be a number from {_1} to {_2}, not {_3} scale は {_1} から {_2} までの数値にしてください ({_3} は指定できません) @msg an image has no pixels and was left out ピクセルがない画像を省略しました @msg object {0} cannot have children オブジェクト {0} には子オブジェクトを含められません @msg range `{s}` is empty 範囲 `{s}` が空です @@ -4240,7 +4240,7 @@ swatch Bright Violet ビビッドバイオレット @msg {name}: no local header {name}: ローカルヘッダーがありません @msg Flatten Transparency: select objects to flatten 透明部分を分割・統合: 分割・統合するオブジェクトを選択してください @msg Flatten Transparency: {e} 透明部分を分割・統合: {e} -@msg balance must be between 0 and 100 バランスは 0 から 100 までの値にしてください +@msg balance must be between 0 and 100 balance は 0 から 100 までの値にしてください @msg resolutions must be between 1 and 2400 ppi 解像度は 1 から 2400 ppi までの値にしてください @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) `{name}` は不明なプリセットです (high、medium、low、または保存したプリセット: flattener.presets.list を参照してください) @msg `at` must be [x, y] `at` は [x, y] の形式で指定してください @@ -4410,7 +4410,7 @@ swatch Bright Violet ビビッドバイオレット @msg no artboard {i} (0-based; the document has {_1}) アートボード {i} はありません (番号は 0 始まり、このドキュメントには {_1} 個あります) @msg no selected slice in the image: select slices, or save all of them 画像内に選択されたスライスがありません: スライスを選択するか、すべてのスライスを保存してください @msg nothing to save: the document has no visible art 保存するものがありません: ドキュメントに表示されているアートがありません -@msg percent must be above 0 and at most 6400, not {p} パーセントは 0 より大きく 6400 以下にしてください ({p} は指定できません) +@msg percent must be above 0 and at most 6400, not {p} percent は 0 より大きく 6400 以下にしてください ({p} は指定できません) @msg unknown preset `{name}` (see webExport.presets.list) `{name}` は不明なプリセットです (webExport.presets.list を参照してください) @msg width and height are at least 1 pixel 幅と高さは 1 ピクセル以上にしてください @msg `sampleSize` must be 1, 3 or 5 `sampleSize` は 1、3、5 のいずれかにしてください @@ -4573,7 +4573,7 @@ swatch Bright Violet ビビッドバイオレット @msg is not an image VectorCraft reads VectorCraft で読み込める画像ではありません @msg not found 見つかりません @msg SVG parse error: {0} SVG を解析できませんでした: {0} -@msg decimals must be {_1}–{_2} (got {_3}) 小数点以下の桁数は {_1} から {_2} までにしてください (指定値: {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals は {_1} から {_2} までにしてください (指定値: {_3}) @msg Exporting {_1} {_1} を書き出し中 @msg Packaging {_1} {_1} をパッケージ中 @msg Placing {_1} {_1} を配置中 @@ -4743,7 +4743,7 @@ swatch Bright Violet ビビッドバイオレット @msg choose a folder to export to 書き出し先のフォルダーを選択してください @msg the selection has no URL (or they differ) 選択したオブジェクトに URL がありません (または URL がそれぞれ異なります) @msg select some type first 先にテキストを選択してください -@msg kerning must be a number, "auto", or "optical" カーニングは数値、「auto」または「optical」にしてください +@msg kerning must be a number, "auto", or "optical" kerning は数値、「auto」または「optical」にしてください @msg the selection has no gradient fill 選択したオブジェクトに塗りのグラデーションがありません @msg the selection has no gradient stroke 選択したオブジェクトに線のグラデーションがありません @msg the selection has no pattern fill 選択したオブジェクトに塗りのパターンがありません @@ -5008,7 +5008,7 @@ effect Vertical 垂直 @msg before must name a panel before にパネルを指定してください @msg docking action contains an unknown panel or an excessive split path ドッキング操作に不明なパネルまたは長すぎる分割パスが含まれています @msg group must be true or false group は true または false にしてください -@msg panel is not in a dock group パネルはドックのグループにありません +@msg panel is not in a dock group panel はドックのグループにありません @msg only a dock group above or below others collapses, not one beside another 折りたためるのは他のグループの上下にあるドックのグループだけで、横に並んだグループは折りたためません @msg icon must be true or false icon は true または false である必要があります @msg icon only docks a panel in the icon column, with no anchor icon はパネルをアイコン列にのみドッキングします(anchor なし) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5d049fb9e..cf9abe5bb 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3773,7 +3773,7 @@ font-technology Variable ცვლადი @plural {n} stroke|{n} strokes კონტური: {n} @msg no library `{id}` ბიბლიოთეკა `{id}` არ არსებობს @msg select some type first ჯერ მონიშნეთ ტექსტი -@msg kerning must be a number, "auto", or "optical" კერნინგი უნდა იყოს რიცხვი, „auto“ ან „optical“ +@msg kerning must be a number, "auto", or "optical" kerning უნდა იყოს რიცხვი, „auto“ ან „optical“ @msg the selection has no gradient fill მონიშნულს შევსების გრადიენტი არ აქვს @msg the selection has no gradient stroke მონიშნულს კონტურის გრადიენტი არ აქვს @msg the selection has no pattern fill მონიშნულს შევსების პატერნი არ აქვს @@ -4004,7 +4004,7 @@ font-technology Variable ცვლადი @msg compound paths can only contain paths შედგენილი ტრაექტორია მხოლოდ ტრაექტორიებს შეიძლება შეიცავდეს @msg compressed (RLE) bitmaps can't be read yet შეკუმშული (RLE) ბიტმაპების წაკითხვა ჯერ არ ხერხდება @msg damaged დაზიანებულია -@msg decimals must be {_1}–{_2} (got {_3}) ციფრები მძიმის შემდეგ უნდა იყოს {_1}–{_2} (მიღებულია {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals უნდა იყოს {_1}–{_2} (მიღებულია {_3}) @msg divide into 1 to {MAX_PIECES} slices დაყავით 1-დან {MAX_PIECES}-მდე ნაჭრად @msg effect {k} is `{_1}`, not `{id}` ეფექტი {k} არის `{_1}` და არა `{id}` @msg empty brush stroke ცარიელი ფუნჯის შტრიხი @@ -4189,9 +4189,9 @@ font-technology Variable ცვლადი @msg replace: the object to replace was changed or deleted replace: ჩასანაცვლებელი ობიექტი შეიცვალა ან წაიშალა @msg pattern missing პატერნი აკლია @msg patterns nested too deeply are filled with mid-grey ზედმეტად ღრმად ჩადგმული პატერნები საშუალო ნაცრისფრით ივსება -@msg percent must be above 0 and at most 6400, not {p} პროცენტი უნდა იყოს 0-ზე მეტი და არაუმეტეს 6400-ისა და არა {p} +@msg percent must be above 0 and at most 6400, not {p} percent უნდა იყოს 0-ზე მეტი და არაუმეტეს 6400-ისა და არა {p} @msg pick a colour, gradient or pattern to save as a swatch ფერის ნიმუშად შესანახად აირჩიეთ ფერი, გრადიენტი ან პატერნი -@msg plane must be left, right or ground სიბრტყე უნდა იყოს left, right ან ground +@msg plane must be left, right or ground plane უნდა იყოს left, right ან ground @msg plug-in ABI: {0} პლაგინის ABI: {0} @msg plug-in exceeded its {0} პლაგინმა გადააჭარბა ლიმიტს: {0} @msg plug-in failed: {0} პლაგინის შეცდომა: {0} @@ -4208,8 +4208,8 @@ font-technology Variable ცვლადი @msg recovery copy `{file}`: {e} აღდგენის ასლი `{file}`: {e} @msg resolutions must be between 1 and 2400 ppi გარჩევადობა უნდა იყოს 1-დან 2400 ppi-მდე @msg result would exceed the canvas (coordinates out of range) შედეგი ტილოს გასცდება (კოორდინატები დიაპაზონს გარეთაა) -@msg scale must be a number from {_1} to {_2}, not {_3} მასშტაბი უნდა იყოს რიცხვი {_1}-დან {_2}-მდე და არა {_3} -@msg scale must be a number from {_1} to {_2}, not {s} მასშტაბი უნდა იყოს რიცხვი {_1}-დან {_2}-მდე და არა {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale უნდა იყოს რიცხვი {_1}-დან {_2}-მდე და არა {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale უნდა იყოს რიცხვი {_1}-დან {_2}-მდე და არა {s} @msg select (or target) objects whose appearance isn't basic მონიშნეთ (ან მიზნად აირჩიეთ) ობიექტები, რომელთა იერსახე საბაზისო არ არის @msg select a Live Paint group მონიშნეთ ცოცხალი შეღებვის ჯგუფი @msg select a blend მონიშნეთ გადასვლა @@ -4526,7 +4526,7 @@ font-technology Variable ცვლადი @msg before must name a panel before უნდა ასახელებდეს პანელს @msg docking action contains an unknown panel or an excessive split path მიმაგრების მოქმედება შეიცავს უცნობ პანელს ან ზედმეტად გრძელ გაყოფის გზას @msg group must be true or false group უნდა იყოს true ან false -@msg panel is not in a dock group პანელი არ არის დოკის ჯგუფში +@msg panel is not in a dock group panel არ არის დოკის ჯგუფში @msg only a dock group above or below others collapses, not one beside another იკეცება მხოლოდ სხვების ზემოთ ან ქვემოთ მდებარე დოკის ჯგუფი, არა სხვის გვერდით მდებარე @msg icon must be true or false icon უნდა იყოს true ან false @msg icon only docks a panel in the icon column, with no anchor icon პანელს მხოლოდ ხატულების სვეტში ამაგრებს, anchor-ის გარეშე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5cf97c257..f341e1776 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4042,14 +4042,14 @@ theme Appearance 외관 @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 지난번 VectorCraft 시작이 창이 나타나기 전에 {name}에서 끝나서 {summary}에서 시작했습니다. 이 그래픽 프로세서를 다시 시도하려면 환경 설정 › 성능 › 그래픽 프로세서를 변경하거나 WGPU_BACKEND를 설정하고 시작하세요 @msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set 지난번 VectorCraft 시작이 그래픽 프로세서 목록을 가져오는 중에 끝나서 OpenGL만 사용해 {summary}에서 시작했습니다. 다른 그래픽 백엔드를 다시 시도하려면 환경 설정 › 성능 › 그래픽 프로세서를 변경하거나 WGPU_BACKEND를 설정하고 시작하세요 @msg not a DXF drawing DXF 도면이 아닙니다 -@msg scale must be a number from {_1} to {_2}, not {s} 배율은 {_1}에서 {_2} 사이의 숫자여야 합니다: {s} +@msg scale must be a number from {_1} to {_2}, not {s} scale은 {_1}에서 {_2} 사이의 숫자여야 합니다: {s} @msg the artboard to fit must be a positive size up to {_1} pt 맞출 아트보드는 최대 {_1} pt까지의 양수 크기여야 합니다 @msg the drawing has no layout `{_1}` (it has {_2}) 도면에 레이아웃 `{_1}`이(가) 없습니다(있는 레이아웃: {_2}) @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that DWG 도면은 읽을 수 없습니다. CAD 앱에서 ASCII DXF로 저장한 후 여세요 @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard 이 배율에서는 도면이 {w} × {h} pt로 캔버스 크기를 넘습니다. 더 작은 배율을 고르거나 아트보드에 맞추세요 @msg the scale is not a usable number 배율이 사용할 수 없는 숫자입니다 @msg the drawing is too large to open 도면이 너무 커서 열 수 없습니다 -@msg scale must be a number from {_1} to {_2}, not {_3} 배율은 {_1}에서 {_2} 사이의 숫자여야 합니다: {_3} +@msg scale must be a number from {_1} to {_2}, not {_3} scale은 {_1}에서 {_2} 사이의 숫자여야 합니다: {_3} @msg an image has no pixels and was left out 픽셀이 없는 이미지를 제외했습니다 @msg object {0} cannot have children 개체 {0}에는 하위 항목을 둘 수 없습니다 @msg range `{s}` is empty 범위 `{s}`이(가) 비어 있습니다 @@ -4167,7 +4167,7 @@ theme Appearance 외관 @msg {name}: no local header {name}: 로컬 헤더가 없습니다 @msg Flatten Transparency: select objects to flatten 투명도 병합: 병합할 개체를 선택하세요 @msg Flatten Transparency: {e} 투명도 병합: {e} -@msg balance must be between 0 and 100 균형은 0에서 100 사이여야 합니다 +@msg balance must be between 0 and 100 balance는 0에서 100 사이여야 합니다 @msg resolutions must be between 1 and 2400 ppi 해상도는 1에서 2400 ppi 사이여야 합니다 @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) 알 수 없는 프리셋 `{name}`(high, medium, low 또는 저장된 프리셋: flattener.presets.list 참조) @msg `at` must be [x, y] `at`은 [x, y]여야 합니다 @@ -4242,7 +4242,7 @@ theme Appearance 외관 @msg nothing to align 정렬할 항목이 없습니다 @msg select a clip group 클립 그룹을 선택하세요 @msg select two or more objects to distribute 분포할 개체를 두 개 이상 선택하세요 -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` 축은 "vertical", "horizontal" 또는 도 단위 각도여야 합니다: `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis는 "vertical", "horizontal" 또는 도 단위 각도여야 합니다: `{v}` @msg give horizontal or vertical horizontal 또는 vertical을 지정하세요 @msg selection has no bounds 선택 영역에 경계가 없습니다 @msg the top object must be a path, compound path or text object to use as a clipping mask 클리핑 마스크로 쓰려면 맨 위 개체가 패스, 컴파운드 패스 또는 텍스트 개체여야 합니다 @@ -4337,7 +4337,7 @@ theme Appearance 외관 @msg no artboard {i} (0-based; the document has {_1}) 아트보드 {i}이(가) 없습니다(0부터 시작, 문서의 아트보드 수: {_1}) @msg no selected slice in the image: select slices, or save all of them 이미지에 선택한 슬라이스가 없습니다. 슬라이스를 선택하거나 모두 저장하세요 @msg nothing to save: the document has no visible art 저장할 항목이 없습니다. 문서에 보이는 아트웍이 없습니다 -@msg percent must be above 0 and at most 6400, not {p} 퍼센트는 0 초과 6400 이하여야 합니다: {p} +@msg percent must be above 0 and at most 6400, not {p} percent는 0 초과 6400 이하여야 합니다: {p} @msg unknown preset `{name}` (see webExport.presets.list) 알 수 없는 프리셋 `{name}`(webExport.presets.list 참조) @msg width and height are at least 1 pixel 너비와 높이는 1픽셀 이상이어야 합니다 @msg `sampleSize` must be 1, 3 or 5 `sampleSize`는 1, 3 또는 5여야 합니다 @@ -4500,7 +4500,7 @@ theme Appearance 외관 @msg is not an image VectorCraft reads VectorCraft가 읽는 이미지가 아닙니다 @msg not found 찾을 수 없습니다 @msg SVG parse error: {0} SVG 구문 분석 오류: {0} -@msg decimals must be {_1}–{_2} (got {_3}) 소수 자릿수는 {_1}~{_2}이어야 합니다({_3} 입력됨) +@msg decimals must be {_1}–{_2} (got {_3}) decimals는 {_1}~{_2}이어야 합니다({_3} 입력됨) @msg Exporting {_1} {_1} 내보내는 중 @msg Packaging {_1} {_1} 패키지 만드는 중 @msg Placing {_1} {_1} 가져오는 중 @@ -4558,7 +4558,7 @@ theme Appearance 외관 @msg no preset editor 프리셋 편집기가 없습니다 @msg no perspective grid preset named `{name}` `{name}`이라는 원근 격자 프리셋이 없습니다 @msg no active perspective plane 활성 원근 평면이 없습니다 -@msg plane must be left, right or ground 평면은 왼쪽, 오른쪽 또는 바닥이어야 합니다 +@msg plane must be left, right or ground plane은 left, right 또는 ground여야 합니다 @msg missing `id` (see plugin.list) `id`가 없습니다(plugin.list 참조) @msg no plug-in {id} is installed 플러그인 {id}이(가) 설치되어 있지 않습니다 @msg {_1} is a live effect (Effect › Plug-ins) {_1}은(는) 라이브 효과입니다(효과 › 플러그인) @@ -4670,7 +4670,7 @@ theme Appearance 외관 @msg choose a folder to export to 내보낼 폴더를 선택하세요 @msg the selection has no URL (or they differ) 선택 항목에 URL이 없거나 서로 다릅니다 @msg select some type first 먼저 문자를 선택하세요 -@msg kerning must be a number, "auto", or "optical" 커닝은 숫자, “auto” 또는 “optical”이어야 합니다 +@msg kerning must be a number, "auto", or "optical" kerning은 숫자, “auto” 또는 “optical”이어야 합니다 @msg the selection has no gradient fill 선택 항목에 칠 그레이디언트가 없습니다 @msg the selection has no gradient stroke 선택 항목에 선 그레이디언트가 없습니다 @msg the selection has no pattern fill 선택 항목에 칠 패턴이 없습니다 @@ -4933,7 +4933,7 @@ effect Vertical 세로 @msg before must name a panel before는 패널을 지정해야 합니다 @msg docking action contains an unknown panel or an excessive split path 도킹 동작에 알 수 없는 패널이나 지나치게 많은 분할 경로가 있습니다 @msg group must be true or false group은 true 또는 false여야 합니다 -@msg panel is not in a dock group 패널이 도크 그룹에 없습니다 +@msg panel is not in a dock group panel이 도크 그룹에 없습니다 @msg only a dock group above or below others collapses, not one beside another 다른 그룹의 위나 아래에 있는 도크 그룹만 접을 수 있으며, 옆에 있는 그룹은 접을 수 없습니다 @msg icon must be true or false icon은 true 또는 false여야 합니다 @msg icon only docks a panel in the icon column, with no anchor icon은 anchor 없이 패널을 아이콘 열에만 도킹합니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 5a16c754c..3f0c7365b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4016,7 +4016,7 @@ imageTrace Outline Contour @plural {n} stroke|{n} strokes {n} lijn|{n} lijnen @msg no library `{id}` geen bibliotheek `{id}` @msg select some type first selecteer eerst wat tekst -@msg kerning must be a number, "auto", or "optical" Kerning moet een getal, ‘auto’ of ‘optical’ zijn +@msg kerning must be a number, "auto", or "optical" kerning moet een getal, ‘auto’ of ‘optical’ zijn @msg {_1} has no {_2} style {_1} heeft geen {_2}stijl @msg a layer couldn't be read een laag kon niet worden gelezen @msg it has more than one artboard het heeft meer dan één tekengebied @@ -4191,7 +4191,7 @@ imageTrace Outline Contour @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art of type @msg artboard {0} does not exist tekengebied {0} bestaat niet @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard bij deze schaal is de tekening {w} × {h} pt, meer dan het canvas kan bevatten: kies een kleinere schaal of pas de tekening aan op het tekengebied -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` as moet "vertical", "horizontal" of een hoek in graden zijn, niet `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis moet "vertical", "horizontal" of een hoek in graden zijn, niet `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" achtergrond {other}: transparant, wit, zwart of een kleur zoals "#ff8800" @msg bad color {v} onjuiste kleur {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio moet een getal van 0,1 tot 10 zijn @@ -4200,9 +4200,9 @@ imageTrace Outline Contour @msg bad shortcut onjuiste sneltoets @msg bad stream length onjuiste streamlengte @msg bad trailer onjuiste trailer -@msg balance must be between 0 and 100 balans moet tussen 0 en 100 liggen +@msg balance must be between 0 and 100 balance moet tussen 0 en 100 liggen @msg batch step {_1} (`{id}`) failed: {e} batchstap {_1} (`{id}`) is mislukt: {e} -@msg brightness must be dark|mediumDark|mediumLight|light helderheid moet dark|mediumDark|mediumLight|light zijn +@msg brightness must be dark|mediumDark|mediumLight|light brightness moet dark|mediumDark|mediumLight|light zijn @msg can't encode the document: {0} kan het document niet coderen: {0} @msg can't parse shortcut `{c}` kan sneltoets `{c}` niet parseren @msg can't parse shortcut `{s}` for `{k}` kan sneltoets `{s}` voor `{k}` niet parseren @@ -4225,7 +4225,7 @@ imageTrace Outline Contour @msg compound paths can only contain paths samengestelde paden kunnen alleen paden bevatten @msg compressed (RLE) bitmaps can't be read yet gecomprimeerde (RLE) bitmaps kunnen nog niet gelezen worden @msg damaged beschadigd -@msg decimals must be {_1}–{_2} (got {_3}) decimalen moeten {_1}–{_2} zijn (kreeg {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals moet {_1}–{_2} zijn (kreeg {_3}) @msg divide into 1 to {MAX_PIECES} slices deel op in 1 tot {MAX_PIECES} slices @msg effect {k} is `{_1}`, not `{id}` effect {k} is `{_1}`, niet `{id}` @msg empty brush stroke lege penseelstreek @@ -4404,9 +4404,9 @@ imageTrace Outline Contour @msg replace: the object to replace was changed or deleted vervangen: het te vervangen object is gewijzigd of verwijderd @msg pattern missing patroon ontbreekt @msg patterns nested too deeply are filled with mid-grey patronen die te diep genest zijn, worden gevuld met middelgrijs -@msg percent must be above 0 and at most 6400, not {p} procent moet boven 0 en maximaal 6400 zijn, niet {p} +@msg percent must be above 0 and at most 6400, not {p} percent moet boven 0 en maximaal 6400 zijn, niet {p} @msg pick a colour, gradient or pattern to save as a swatch kies een kleur, verloop of patroon om als staal op te slaan -@msg plane must be left, right or ground vlak moet links, rechts of grond zijn +@msg plane must be left, right or ground plane moet left, right of ground zijn @msg plug-in ABI: {0} plug-in-ABI: {0} @msg plug-in exceeded its {0} plug-in overschreed zijn {0} @msg plug-in failed: {0} plug-in mislukt: {0} @@ -4425,8 +4425,8 @@ imageTrace Outline Contour @msg result would exceed the canvas (coordinates out of range) het resultaat zou het canvas overschrijden (coördinaten buiten bereik) @msg objects can't be nested more than 32 deep objecten kunnen niet dieper dan 32 niveaus worden genest @msg the SVG's elements are nested more than {0} deep de elementen van de SVG zijn dieper dan {0} niveaus genest -@msg scale must be a number from {_1} to {_2}, not {_3} schaal moet een getal van {_1} tot {_2} zijn, niet {_3} -@msg scale must be a number from {_1} to {_2}, not {s} schaal moet een getal van {_1} tot {_2} zijn, niet {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale moet een getal van {_1} tot {_2} zijn, niet {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale moet een getal van {_1} tot {_2} zijn, niet {s} @msg select (or target) objects whose appearance isn't basic selecteer (of richt je op) objecten waarvan de vormgeving niet basis is @msg select a Live Paint group selecteer een Live Paint-groep @msg select a blend selecteer een vermenging @@ -4729,12 +4729,12 @@ imageTrace Outline Contour @msg the PSD file's image data can't be read: {e} de afbeeldingsgegevens van het PSD-bestand kunnen niet worden gelezen: {e} @msg Imported library {name} Bibliotheek {name} geïmporteerd @msg Invalid panel layout; restored the default dock Ongeldige paneelindeling; het standaarddok is hersteld -@msg action is required actie is vereist +@msg action is required action is vereist @msg anchor must name a registered panel anchor moet een geregistreerd paneel benoemen @msg before must name a panel before moet een paneel benoemen @msg docking action contains an unknown panel or an excessive split path dokactie bevat een onbekend paneel of een overmatig splitspad @msg group must be true or false group moet true of false zijn -@msg panel is not in a dock group paneel staat niet in een dockgroep +@msg panel is not in a dock group panel staat niet in een dockgroep @msg only a dock group above or below others collapses, not one beside another alleen een dockgroep boven of onder andere vouwt samen, niet een ernaast @msg icon must be true or false icon moet true of false zijn @msg icon only docks a panel in the icon column, with no anchor icon dockt een paneel alleen in de pictogramkolom, zonder anchor diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e218ac53c..a1b2bb462 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4110,7 +4110,7 @@ imageTrace Outline Kontur @plural {n} stroke|{n} strokes {n} obrys|{n} obrysy|{n} obrysów @msg no library `{id}` brak biblioteki `{id}` @msg select some type first najpierw zaznacz tekst -@msg kerning must be a number, "auto", or "optical" Kerning musi być liczbą, „auto” lub „optical” +@msg kerning must be a number, "auto", or "optical" kerning musi być liczbą, „auto” lub „optical” @msg the selection has no gradient fill zaznaczenie nie ma gradientu wypełnienia @msg the selection has no gradient stroke zaznaczenie nie ma gradientu obrysu @msg the selection has no pattern fill zaznaczenie nie ma wzorka wypełnienia @@ -4322,7 +4322,7 @@ imageTrace Outline Kontur @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art lub type @msg artboard {0} does not exist obszar kompozycji {0} nie istnieje @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard w tej skali rysunek ma {w} × {h} pt, więcej niż mieści płótno: wybierz mniejszą skalę lub dopasuj go do obszaru kompozycji -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` oś musi mieć wartość "vertical", "horizontal" lub kąt w stopniach, nie `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis musi mieć wartość "vertical", "horizontal" lub kąt w stopniach, nie `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" tło {other}: transparent, white, black lub kolor, np. "#ff8800" @msg bad color {v} nieprawidłowy kolor {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio musi być liczbą od 0,1 do 10 @@ -4336,9 +4336,9 @@ imageTrace Outline Kontur @msg bad shortcut nieprawidłowy skrót @msg bad stream length nieprawidłowa długość strumienia @msg bad trailer nieprawidłowa sekcja trailer -@msg balance must be between 0 and 100 równowaga musi mieścić się w zakresie od 0 do 100 +@msg balance must be between 0 and 100 balance musi mieścić się w zakresie od 0 do 100 @msg batch step {_1} (`{id}`) failed: {e} krok wsadowy {_1} (`{id}`) nie powiódł się: {e} -@msg brightness must be dark|mediumDark|mediumLight|light jasność musi mieć wartość dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness musi mieć wartość dark|mediumDark|mediumLight|light @msg can't encode the document: {0} nie można zakodować dokumentu: {0} @msg can't parse shortcut `{c}` nie można przeanalizować skrótu `{c}` @msg can't parse shortcut `{s}` for `{k}` nie można przeanalizować skrótu `{s}` dla `{k}` @@ -4363,7 +4363,7 @@ imageTrace Outline Kontur @msg compound paths can only contain paths ścieżki złożone mogą zawierać tylko ścieżki @msg compressed (RLE) bitmaps can't be read yet skompresowanych bitmap (RLE) nie można jeszcze odczytać @msg damaged uszkodzony -@msg decimals must be {_1}–{_2} (got {_3}) liczba miejsc dziesiętnych musi wynosić {_1}–{_2} (podano {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals musi wynosić {_1}–{_2} (podano {_3}) @msg divide into 1 to {MAX_PIECES} slices podziel na od 1 do {MAX_PIECES} plasterków @msg effect {k} is `{_1}`, not `{id}` efekt {k} to `{_1}`, nie `{id}` @msg empty brush stroke puste pociągnięcie pędzla @@ -4381,9 +4381,9 @@ imageTrace Outline Kontur @msg an image has no pixels and was left out pominięto obraz bez pikseli @msg image data `{_1}` is missing brak danych obrazu `{_1}` @msg image would be too large; lower the resolution obraz byłby zbyt duży; zmniejsz rozdzielczość -@msg index must be a number or first, previous, next or last indeks musi być liczbą lub first, previous, next albo last -@msg index must be a number or first, previous, next or last, not `{other}` indeks musi być liczbą lub first, previous, next albo last, nie `{other}` -@msg index must be ≥ 0 indeks nie może być ujemny +@msg index must be a number or first, previous, next or last index musi być liczbą lub first, previous, next albo last +@msg index must be a number or first, previous, next or last, not `{other}` index musi być liczbą lub first, previous, next albo last, nie `{other}` +@msg index must be ≥ 0 index musi być ≥ 0 @msg installing from a path is not available on the web; pass the module as `dataBase64` instalacja ze ścieżki nie jest dostępna w wersji webowej; przekaż moduł jako `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) błąd wewnętrzny w `{cmd}`: {msg} (dokument pozostał bez zmian; zgłoś ten błąd) @msg internal error: {msg} (please report this bug) błąd wewnętrzny: {msg} (zgłoś ten błąd) @@ -4563,9 +4563,9 @@ imageTrace Outline Kontur @msg replace: the object to replace was changed or deleted replace: obiekt do zastąpienia został zmieniony lub usunięty @msg pattern missing brak wzorka @msg patterns nested too deeply are filled with mid-grey zbyt głęboko zagnieżdżone wzorki są wypełniane średnią szarością -@msg percent must be above 0 and at most 6400, not {p} procent musi być większy od 0 i nie większy niż 6400, nie {p} +@msg percent must be above 0 and at most 6400, not {p} percent musi być większy od 0 i nie większy niż 6400, nie {p} @msg pick a colour, gradient or pattern to save as a swatch wybierz kolor, gradient lub wzorek do zapisania jako próbka -@msg plane must be left, right or ground płaszczyzna musi mieć wartość left, right lub ground +@msg plane must be left, right or ground plane musi mieć wartość left, right lub ground @msg plug-in ABI: {0} ABI wtyczki: {0} @msg plug-in exceeded its {0} wtyczka przekroczyła swój limit: {0} @msg plug-in failed: {0} wtyczka zawiodła: {0} @@ -4584,8 +4584,8 @@ imageTrace Outline Kontur @msg result would exceed the canvas (coordinates out of range) wynik wykroczyłby poza płótno (współrzędne poza zakresem) @msg objects can't be nested more than 32 deep obiektów nie można zagnieżdżać głębiej niż na 32 poziomy @msg the SVG's elements are nested more than {0} deep elementy SVG są zagnieżdżone głębiej niż na {0} poziomów -@msg scale must be a number from {_1} to {_2}, not {_3} skala musi być liczbą od {_1} do {_2}, nie {_3} -@msg scale must be a number from {_1} to {_2}, not {s} skala musi być liczbą od {_1} do {_2}, nie {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale musi być liczbą od {_1} do {_2}, nie {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale musi być liczbą od {_1} do {_2}, nie {s} @msg select (or target) objects whose appearance isn't basic zaznacz (lub wskaż) obiekty, których wygląd nie jest podstawowy @msg select a Live Paint group zaznacz grupę aktywnego malowania @msg select a blend zaznacz przejście diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index af16149fe..efd6d8047 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3787,7 +3787,7 @@ axis Both Ambos @msg bad shortcut atalho inválido @msg bad stream length comprimento de fluxo inválido @msg bad trailer trailer inválido -@msg balance must be between 0 and 100 o equilíbrio deve estar entre 0 e 100 +@msg balance must be between 0 and 100 balance deve estar entre 0 e 100 @msg batch step {_1} (`{id}`) failed: {e} a etapa {_1} do lote (`{id}`) falhou: {e} @msg brightness must be dark|mediumDark|mediumLight|light brightness deve ser dark|mediumDark|mediumLight|light @msg can't encode the document: {0} não foi possível codificar o documento: {0} @@ -3814,7 +3814,7 @@ axis Both Ambos @msg compound paths can only contain paths traçados compostos só podem conter traçados @msg compressed (RLE) bitmaps can't be read yet bitmaps compactados (RLE) ainda não podem ser lidos @msg damaged danificado -@msg decimals must be {_1}–{_2} (got {_3}) as casas decimais devem estar entre {_1} e {_2} (recebido {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals deve estar entre {_1} e {_2} (recebido {_3}) @msg divide into 1 to {MAX_PIECES} slices divida em 1 a {MAX_PIECES} fatias @msg effect {k} is `{_1}`, not `{id}` o efeito {k} é `{_1}`, não `{id}` @msg empty brush stroke traço de pincel vazio @@ -4010,7 +4010,7 @@ axis Both Ambos @msg replace: the object to replace was changed or deleted replace: o objeto a ser substituído foi alterado ou excluído @msg pattern missing padrão ausente @msg patterns nested too deeply are filled with mid-grey padrões aninhados com profundidade excessiva são preenchidos com cinza médio -@msg percent must be above 0 and at most 6400, not {p} a porcentagem deve ser maior que 0 e no máximo 6400, não {p} +@msg percent must be above 0 and at most 6400, not {p} percent deve ser maior que 0 e no máximo 6400, não {p} @msg pick a colour, gradient or pattern to save as a swatch escolha uma cor, gradiente ou padrão para salvar como amostra @msg plane must be left, right or ground plane deve ser left, right ou ground @msg plug-in ABI: {0} ABI do plug-in: {0} @@ -4031,8 +4031,8 @@ axis Both Ambos @msg result would exceed the canvas (coordinates out of range) o resultado excederia a tela (coordenadas fora do intervalo) @msg objects can't be nested more than 32 deep os objetos não podem ser aninhados em mais de 32 níveis @msg the SVG's elements are nested more than {0} deep os elementos do SVG estão aninhados em mais de {0} níveis -@msg scale must be a number from {_1} to {_2}, not {_3} a escala deve ser um número de {_1} a {_2}, não {_3} -@msg scale must be a number from {_1} to {_2}, not {s} a escala deve ser um número de {_1} a {_2}, não {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale deve ser um número de {_1} a {_2}, não {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale deve ser um número de {_1} a {_2}, não {s} @msg select (or target) objects whose appearance isn't basic selecione (ou direcione) objetos cuja aparência não seja básica @msg select a Live Paint group selecione um grupo de Pintura dinâmica @msg select a blend selecione uma mesclagem @@ -4374,7 +4374,7 @@ axis Both Ambos @msg before must name a panel before deve nomear um painel @msg docking action contains an unknown panel or an excessive split path a ação de encaixe contém um painel desconhecido ou um caminho de divisão excessivo @msg group must be true or false group deve ser true ou false -@msg panel is not in a dock group o painel não está em um grupo do encaixe +@msg panel is not in a dock group panel não está em um grupo do encaixe @msg only a dock group above or below others collapses, not one beside another só um grupo do encaixe acima ou abaixo de outros é recolhido, não um ao lado @msg icon must be true or false icon deve ser true ou false @msg icon only docks a panel in the icon column, with no anchor icon apenas encaixa um painel na coluna de ícones, sem anchor diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2ff6c5873..18931dce6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4016,7 +4016,7 @@ axis Both Оба # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` нет библиотеки `{id}` @msg select some type first сначала выделите текст -@msg kerning must be a number, "auto", or "optical" Кернинг должен быть числом, «auto» или «optical» +@msg kerning must be a number, "auto", or "optical" kerning должен быть числом, «auto» или «optical» @msg the selection has no gradient fill у выделения нет градиентной заливки @msg the selection has no gradient stroke у выделения нет градиентной обводки @msg the selection has no pattern fill у выделения нет узорной заливки @@ -4243,9 +4243,9 @@ axis Both Оба @msg bad shortcut недопустимое сочетание клавиш @msg bad stream length недопустимая длина потока @msg bad trailer недопустимый трейлер -@msg balance must be between 0 and 100 баланс должен быть от 0 до 100 +@msg balance must be between 0 and 100 balance должен быть от 0 до 100 @msg batch step {_1} (`{id}`) failed: {e} шаг пакета {_1} (`{id}`) не выполнен: {e} -@msg brightness must be dark|mediumDark|mediumLight|light яркость должна быть dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness должен быть dark|mediumDark|mediumLight|light @msg can't encode the document: {0} не удалось закодировать документ: {0} @msg can't parse shortcut `{c}` не удалось разобрать сочетание `{c}` @msg can't parse shortcut `{s}` for `{k}` не удалось разобрать сочетание `{s}` для `{k}` @@ -4270,7 +4270,7 @@ axis Both Оба @msg compound paths can only contain paths составные контуры могут содержать только контуры @msg compressed (RLE) bitmaps can't be read yet сжатые (RLE) растровые изображения пока не читаются @msg damaged повреждено -@msg decimals must be {_1}–{_2} (got {_3}) число десятичных знаков должно быть {_1}–{_2} (получено {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals должен быть {_1}–{_2} (получено {_3}) @msg divide into 1 to {MAX_PIECES} slices разделите на 1–{MAX_PIECES} фрагментов @msg effect {k} is `{_1}`, not `{id}` эффект {k} — это `{_1}`, а не `{id}` @msg empty brush stroke пустой мазок кисти @@ -4469,9 +4469,9 @@ axis Both Оба @msg replace: the object to replace was changed or deleted replace: заменяемый объект был изменён или удалён @msg pattern missing узор отсутствует @msg patterns nested too deeply are filled with mid-grey слишком глубоко вложенные узоры заполняются серым -@msg percent must be above 0 and at most 6400, not {p} процент должен быть больше 0 и не более 6400, а не {p} +@msg percent must be above 0 and at most 6400, not {p} percent должен быть больше 0 и не более 6400, а не {p} @msg pick a colour, gradient or pattern to save as a swatch выберите цвет, градиент или узор для сохранения в качестве образца -@msg plane must be left, right or ground плоскость должна быть left, right или ground +@msg plane must be left, right or ground plane должен быть left, right или ground @msg plug-in ABI: {0} ABI модуля: {0} @msg plug-in exceeded its {0} модуль превысил свой {0} @msg plug-in failed: {0} модуль завершился с ошибкой: {0} @@ -4490,8 +4490,8 @@ axis Both Оба @msg result would exceed the canvas (coordinates out of range) результат превысил бы холст (координаты вне диапазона) @msg objects can't be nested more than 32 deep объекты нельзя вкладывать глубже 32 уровней @msg the SVG's elements are nested more than {0} deep элементы SVG вложены глубже {0} уровней -@msg scale must be a number from {_1} to {_2}, not {_3} масштаб должен быть числом от {_1} до {_2}, а не {_3} -@msg scale must be a number from {_1} to {_2}, not {s} масштаб должен быть числом от {_1} до {_2}, а не {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale должен быть числом от {_1} до {_2}, а не {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale должен быть числом от {_1} до {_2}, а не {s} @msg select (or target) objects whose appearance isn't basic выделите (или укажите как объект) объекты, оформление которых не является основным @msg select a Live Paint group выделите группу быстрой заливки @msg select a blend выделите переход @@ -4960,7 +4960,7 @@ axis Both Оба @msg before must name a panel before должен указывать панель @msg docking action contains an unknown panel or an excessive split path Действие прикрепления содержит неизвестную панель или слишком длинный путь разделений @msg group must be true or false group должен иметь значение true или false -@msg panel is not in a dock group панель не находится в группе дока +@msg panel is not in a dock group panel не находится в группе дока @msg only a dock group above or below others collapses, not one beside another свернуть можно только группу дока над или под другими, а не рядом с ними @msg icon must be true or false icon должен быть true или false @msg icon only docks a panel in the icon column, with no anchor icon только закрепляет панель в столбце значков, без anchor diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ea0550be3..19a6f11c3 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3891,7 +3891,7 @@ axis Both Båda Float Tool Group Gör verktygsgruppen flytande @msg no library `{id}` inget bibliotek `{id}` @msg select some type first markera någon text först -@msg kerning must be a number, "auto", or "optical" Kerning måste vara ett tal, ”auto” eller ”optical” +@msg kerning must be a number, "auto", or "optical" kerning måste vara ett tal, ”auto” eller ”optical” @msg the selection has no gradient fill markeringen har ingen fyllnadsgradient @msg the selection has no gradient stroke markeringen har ingen konturgradient @msg the selection has no pattern fill markeringen har inget fyllnadsmönster @@ -4111,7 +4111,7 @@ axis Both Båda @msg bad shortcut ogiltigt kortkommando @msg bad stream length ogiltig strömlängd @msg bad trailer ogiltig trailer -@msg balance must be between 0 and 100 balansen måste vara mellan 0 och 100 +@msg balance must be between 0 and 100 balance måste vara mellan 0 och 100 @msg batch step {_1} (`{id}`) failed: {e} batchsteg {_1} (`{id}`) misslyckades: {e} @msg brightness must be dark|mediumDark|mediumLight|light brightness måste vara dark|mediumDark|mediumLight|light @msg can't encode the document: {0} kan inte koda dokumentet: {0} @@ -4138,7 +4138,7 @@ axis Both Båda @msg compound paths can only contain paths sammansatta banor kan bara innehålla banor @msg compressed (RLE) bitmaps can't be read yet komprimerade (RLE) bitmappar kan inte läsas än @msg damaged skadad -@msg decimals must be {_1}–{_2} (got {_3}) decimaler måste vara {_1}–{_2} (fick {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals måste vara {_1}–{_2} (fick {_3}) @msg divide into 1 to {MAX_PIECES} slices dela i 1 till {MAX_PIECES} skivor @msg effect {k} is `{_1}`, not `{id}` effekt {k} är `{_1}`, inte `{id}` @msg empty brush stroke tomt penseldrag @@ -4337,7 +4337,7 @@ axis Both Båda @msg replace: the object to replace was changed or deleted replace: objektet som skulle ersättas har ändrats eller tagits bort @msg pattern missing mönster saknas @msg patterns nested too deeply are filled with mid-grey mönster som är kapslade för djupt fylls med mellangrått -@msg percent must be above 0 and at most 6400, not {p} procent måste vara över 0 och högst 6400, inte {p} +@msg percent must be above 0 and at most 6400, not {p} percent måste vara över 0 och högst 6400, inte {p} @msg pick a colour, gradient or pattern to save as a swatch välj en färg, gradient eller ett mönster att spara som färgruta @msg plane must be left, right or ground plane måste vara left, right eller ground @msg plug-in ABI: {0} pluginets ABI: {0} @@ -4358,8 +4358,8 @@ axis Both Båda @msg result would exceed the canvas (coordinates out of range) resultatet skulle hamna utanför arbetsytan (koordinater utanför intervallet) @msg objects can't be nested more than 32 deep objekt kan inte kapslas djupare än 32 nivåer @msg the SVG's elements are nested more than {0} deep SVG-filens element är nästlade mer än {0} nivåer djupt -@msg scale must be a number from {_1} to {_2}, not {_3} skala måste vara ett tal från {_1} till {_2}, inte {_3} -@msg scale must be a number from {_1} to {_2}, not {s} skala måste vara ett tal från {_1} till {_2}, inte {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale måste vara ett tal från {_1} till {_2}, inte {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale måste vara ett tal från {_1} till {_2}, inte {s} @msg select (or target) objects whose appearance isn't basic markera (eller ange som mål) objekt vars utseende inte är grundläggande @msg select a Live Paint group markera en Live Paint-grupp @msg select a blend markera en blandning @@ -4838,7 +4838,7 @@ effect Soft Mjuk @msg before must name a panel before måste ange en panel @msg docking action contains an unknown panel or an excessive split path dockningsåtgärden innehåller en okänd panel eller en alltför djup delningssökväg @msg group must be true or false group måste vara true eller false -@msg panel is not in a dock group panelen ligger inte i en dockningsgrupp +@msg panel is not in a dock group panel ligger inte i en dockningsgrupp @msg only a dock group above or below others collapses, not one beside another bara en dockningsgrupp ovanför eller under andra komprimeras, inte en bredvid en annan @msg icon must be true or false icon måste vara true eller false @msg icon only docks a panel in the icon column, with no anchor icon dockar bara en panel i ikonkolumnen, utan anchor @@ -7309,7 +7309,7 @@ digits Hindi Hindi @msg margin: a {m} pt margin leaves no imageable area on the paper margin: en marginal på {m} pt lämnar ingen tryckbar yta på papperet @msg matrix must be finite and invertible, depth finite matrix måste vara ändlig och inverterbar, depth ändligt @msg matte: {e} bakgrundsfärg: {e} -@msg member is for order front, forward, backward or back member gäller ordningen front, forward, backward eller back +@msg member is for order front, forward, backward or back member gäller order front, forward, backward eller back @msg memory budget (the selection is too large to hand over) minnesbudget (markeringen är för stor för att lämnas över) @msg missing `{k}` `{k}` saknas @msg missing `{key}` `{key}` saknas @@ -7578,7 +7578,7 @@ digits Hindi Hindi @msg page art is missing image data for {key} sidans bildmaterial saknar bilddata för {key} @msg page art is missing pattern {name} sidans bildmaterial saknar mönstret {name} @msg page art is missing swatch {name} sidans bildmaterial saknar färgrutan {name} -@msg page must be a whole number from 1 sidan måste vara ett heltal från och med 1 +@msg page must be a whole number from 1 page måste vara ett heltal från och med 1 @msg page thumbnail: {e} sidminiatyr: {e} @msg pages must be a range such as "2-3" or a page number, not {v} pages måste vara ett intervall som "2-3" eller ett sidnummer, inte {v} @msg pages: {e} sidor: {e} @@ -7588,7 +7588,7 @@ digits Hindi Hindi @msg parameters must be an object parametrarna måste vara ett objekt @msg passwords can have up to {MAX_PASSWORD} bytes lösenord får vara högst {MAX_PASSWORD} byte @msg path has no open end banan har ingen öppen ände -@msg paths must be strings sökvägar måste vara strängar +@msg paths must be strings paths måste vara strängar @msg PDF content is incomplete or exceeds the reader's limits; no document was imported PDF-innehållet är ofullständigt eller överskrider läsarens gränser; inget dokument importerades @msg PDF flattener: {e} PDF-plattning: {e} @msg PDF options: {e} PDF-alternativ: {e} @@ -7877,7 +7877,7 @@ digits Hindi Hindi @msg thumbnail: no page {v} (the {what} has {count}) miniatyr: ingen sida {v} ({what} har {count}) @msg tileRange of {what}: {e} tileRange för {what}: {e} @msg time budget tidsbudget -@msg title can't be empty titeln får inte vara tom +@msg title can't be empty title får inte vara tom @msg to (the new name) is required to (det nya namnet) krävs @msg to must be fill or stroke to måste vara fill eller stroke @msg to must be smooth or corner to måste vara smooth eller corner @@ -7983,7 +7983,7 @@ digits Hindi Hindi @msg view must be one of {_1} view måste vara något av {_1} @msg warpStyle can only edit an applied Warp effect warpStyle kan bara redigera en tillämpad Förvräng-effekt @msg width factors must be finite breddfaktorerna måste vara ändliga -@msg width must be positive bredden måste vara positiv +@msg width must be positive width måste vara positiv @msg width out of range bredden ligger utanför tillåtet omfång @msg width/height must be > 0 bredd/höjd måste vara > 0 @msg width/height must be positive and within the canvas bredd/höjd måste vara positiva och inom arbetsytan diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 1ccbe1484..ee18b78a2 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2206,6 +2206,151 @@ fn translated_messages_keep_the_double_quoted_values_of_their_english() { assert!(missing.is_empty(), "{} rows drop a double-quoted value:\n{}", missing.len(), missing.join("\n")); } +/// Whether `text` holds `word` with no ASCII letter, digit or underscore right before or after it, +/// so `true` counts in `true이거나` but not in `untrue`. +fn holds_ascii_word(text: &str, word: &str) -> bool { + let joins = |c: char| c.is_ascii_alphanumeric() || c == '_'; + text.match_indices(word).any(|(at, _)| { + let before = text[..at].chars().next_back().is_none_or(|c| !joins(c)); + let after = text[at + word.len()..].chars().next().is_none_or(|c| !joins(c)); + before && after + }) +} + +/// The literal values `true`, `false` and `null` that `s` names outside its backticked and +/// double-quoted spans. +fn bare_literals(s: &str) -> Vec<&'static str> { + let mut outside = String::new(); + let mut quoted = false; + for part in s.split('`').step_by(2) { + for (i, piece) in part.split('"').enumerate() { + if i > 0 { + quoted = !quoted; + } + if !quoted { + outside.push_str(piece); + outside.push(' '); + } + } + } + ["true", "false", "null"].into_iter().filter(|lit| holds_ascii_word(&outside, lit)).collect() +} + +#[test] +fn bare_literals_finds_true_false_and_null_outside_backticks_and_quotes() { + assert_eq!(bare_literals("clip must be true or false"), ["true", "false"]); + assert_eq!(bare_literals("`leading` must be from 0.1 to 5000, or null for Auto"), ["null"]); + assert_eq!(bare_literals(r#"`true` is "false", not untrue"#), Vec::<&str>::new()); + assert!(holds_ascii_word("true이거나", "true")); + assert!(!holds_ascii_word("isTrue or falsey", "false")); +} + +/// A translated status or error message keeps each `true`, `false` and `null` its English text +/// names without backticks, spelled as the English spells it, since that is the value a command +/// reads. +#[test] +fn translated_messages_keep_the_bare_literals_of_their_english() { + let mut missing = Vec::new(); + for l in &LANGUAGES { + let (entries, _) = parse_entries(l.source); + for (ctx, src, tr) in entries.iter().filter(|(ctx, _, _)| ctx == "@msg" || ctx == "@msgplural") { + let plural = ctx == "@msgplural"; + let english = if plural { src.rsplit('|').next().unwrap_or(src) } else { src.as_str() }; + let forms: Vec<&str> = if plural { tr.split('|').collect() } else { vec![tr.as_str()] }; + for form in forms { + for lit in bare_literals(english) { + if !holds_ascii_word(form, lit) { + missing.push(format!("{}: {lit} in {src:?} -> {form:?}", l.code)); + } + } + } + } + } + assert!(missing.is_empty(), "{} rows drop a bare true, false or null:\n{}", missing.len(), missing.join("\n")); +} + +/// Messages that name parameter keys and values without backticks, each with the names a +/// translation keeps as written in the English. +const NAMED_KEYS: &[(&str, &[&str])] = &[ + ("a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard", &["useArtboards"]), + ("a name names one asset: give multiple: false, or one object", &["multiple"]), + ("a theme is already called {name} (replace: \"{name}\" overwrites it)", &["replace"]), + ("action is required", &["action"]), + ("anchor must name a registered panel", &["anchor"]), + ("antiAlias `{id}`: none, art or type", &["antiAlias", "none", "art", "type"]), + ("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{v}`", &["axis"]), + ("balance must be between 0 and 100", &["balance"]), + ("before must name a panel", &["before"]), + ("brightness must be dark|mediumDark|mediumLight|light", &["brightness", "dark", "mediumDark", "mediumLight", "light"]), + ("clip must be true or false", &["clip"]), + ("collapsed must be true or false", &["collapsed"]), + ("column must be true or a number from 1", &["column"]), + ("column: true floats the whole column and takes no onto, above or below", &["column", "onto", "above", "below"]), + ("control must be fillStroke, coloring, drawMode or screenMode", &["control", "fillStroke", "coloring", "drawMode", "screenMode"]), + ("decimals must be {_1}–{_2} (got {_3})", &["decimals"]), + ("detect must be true or false", &["detect"]), + ("give origin: [x, y], or reset: true", &["origin", "reset"]), + ("global must be true or false", &["global"]), + ("group must be true or false", &["group"]), + ("icon must be true or false", &["icon"]), + ("included must be true or false", &["included"]), + ("index must be a number or first, previous, next or last", &["index", "first", "previous", "next", "last"]), + ("index must be a number or first, previous, next or last, not `{other}`", &["index", "first", "previous", "next", "last"]), + ("index must be ≥ 0", &["index"]), + ("kerning must be a number, \"auto\", or \"optical\"", &["kerning"]), + ("lang must be one of {_1}", &["lang"]), + ("layers must be true or false", &["layers"]), + ("member is for order front, forward, backward or back", &["member", "order", "front", "forward", "backward", "back"]), + ("mode is set, add or toggle, not `{other}`", &["mode", "set", "add", "toggle"]), + ("names is empty", &["names"]), + ("on must be true or false", &["on"]), + ("onto must name a floating panel", &["onto"]), + ("others must be true or false", &["others"]), + ("page must be a whole number from 1", &["page"]), + ("panel is not in a dock group", &["panel"]), + ("panel must name a registered panel", &["panel"]), + ("paths must be strings", &["paths"]), + ("percent must be above 0 and at most 6400, not {p}", &["percent"]), + ("plane must be left, right or ground", &["plane", "left", "right", "ground"]), + ("precision must be from 0.001 to 10 pt", &["precision"]), + ("rect is [x, y, width, height] in points", &["rect"]), + ("scale must be a number from {_1} to {_2}, not {_3}", &["scale"]), + ("scale must be a number from {_1} to {_2}, not {s}", &["scale"]), + ("show must be true or false", &["show"]), + ("span: \"selection\" places a linear or radial gradient, not a freeform one", &["span"]), + ("thickness must be from 0.01 to 5000 pt", &["thickness"]), + ("title can't be empty", &["title"]), + ("view is required", &["view"]), + ("width must be positive", &["width"]), + ("zone must be a string", &["zone"]), + ("zone must be tab, center, left, right, top or bottom", &["zone", "tab", "center", "left", "right", "top", "bottom"]), + ("`dither` applies to gradient fills (stroke: false)", &["stroke"]), + ("`strokeMode` applies to strokes (stroke: true)", &["stroke"]), +]; + +/// Every catalog's translation of a [`NAMED_KEYS`] message keeps each name the table lists, as +/// a whole word, and every message in the table is still in a catalog. +#[test] +fn translated_messages_keep_the_keys_and_values_their_english_names() { + let mut missing = Vec::new(); + let mut seen = vec![false; NAMED_KEYS.len()]; + for l in &LANGUAGES { + let (entries, _) = parse_entries(l.source); + for (_, src, tr) in entries.iter().filter(|(ctx, _, _)| ctx == "@msg") { + let Some(at) = NAMED_KEYS.iter().position(|(english, _)| english == src) else { continue }; + seen[at] = true; + for name in NAMED_KEYS[at].1 { + if !holds_ascii_word(tr, name) { + missing.push(format!("{}: {name} in {src:?} -> {tr:?}", l.code)); + } + } + } + } + let stale: Vec<&str> = NAMED_KEYS.iter().zip(&seen).filter(|(_, seen)| !**seen).map(|((english, _), _)| *english).collect(); + assert!(stale.is_empty(), "no catalog translates {stale:?}"); + assert!(missing.is_empty(), "{} rows drop a key or value their English names:\n{}", missing.len(), missing.join("\n")); +} + /// Languages whose catalogs translate status and error messages; [`untranslated_report`] lists /// the messages each one lacks. const COMPLETE_MESSAGES: &[&str] = &[ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cf5eea934..02587229e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3956,7 +3956,7 @@ axis Both Her İkisi @msg already editing a pattern zaten bir desen düzenliyor @msg an object can't be read bir nesne okunamıyor @msg an object in perspective would leave the edited grid perspektifteki bir nesne düzenlenen ızgaradan ayrılır -@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: yok, resim veya tür +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art veya type @msg artboard {0} does not exist {0} çalışma yüzeyi mevcut değil @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard bu ölçekte çizim {w} × {h} nk, kanvasın sığabileceğinden daha fazladır: daha küçük bir ölçek seçin veya çalışma yüzeyine sığdırın @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis "vertical", "horizontal" veya derece cinsinden bir açı olmalıdır, `{v}` değil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b8d3c44c0..e8e7bb937 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4005,7 +4005,7 @@ axis Both Обидві Float Tool Group Від’єднати групу інструментів @msg no library `{id}` немає бібліотеки `{id}` @msg select some type first спочатку виділіть текст -@msg kerning must be a number, "auto", or "optical" Кернінг має бути числом, «auto» або «optical» +@msg kerning must be a number, "auto", or "optical" kerning має бути числом, «auto» або «optical» @msg the selection has no gradient fill виділення не має градієнтної заливки @msg the selection has no gradient stroke виділення не має градієнтного обведення @msg the selection has no pattern fill виділення не має візерункової заливки @@ -4232,9 +4232,9 @@ axis Both Обидві @msg bad shortcut неприпустиме поєднання клавіш @msg bad stream length неприпустима довжина потоку @msg bad trailer неприпустимий трейлер -@msg balance must be between 0 and 100 баланс має бути від 0 до 100 +@msg balance must be between 0 and 100 balance має бути від 0 до 100 @msg batch step {_1} (`{id}`) failed: {e} крок пакету {_1} (`{id}`) не виконано: {e} -@msg brightness must be dark|mediumDark|mediumLight|light яскравість має бути dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness має бути dark|mediumDark|mediumLight|light @msg can't encode the document: {0} не вдалося закодувати документ: {0} @msg can't parse shortcut `{c}` не вдалося розібрати поєднання `{c}` @msg can't parse shortcut `{s}` for `{k}` не вдалося розібрати поєднання `{s}` для `{k}` @@ -4259,7 +4259,7 @@ axis Both Обидві @msg compound paths can only contain paths складені контури можуть містити лише контури @msg compressed (RLE) bitmaps can't be read yet стислі (RLE) растрові зображення поки не читаються @msg damaged пошкоджено -@msg decimals must be {_1}–{_2} (got {_3}) число десяткових знаків має бути {_1}–{_2} (отримано {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals має бути {_1}–{_2} (отримано {_3}) @msg divide into 1 to {MAX_PIECES} slices розділіть на 1–{MAX_PIECES} фрагментів @msg effect {k} is `{_1}`, not `{id}` ефект {k} - це `{_1}`, а не `{id}` @msg empty brush stroke порожній мазок пензля @@ -4458,9 +4458,9 @@ axis Both Обидві @msg replace: the object to replace was changed or deleted replace: об’єкт для заміни було змінено або видалено @msg pattern missing візерунок відсутній @msg patterns nested too deeply are filled with mid-grey надто глибоко вкладені візерунки заповнюються сірим -@msg percent must be above 0 and at most 6400, not {p} відсоток має бути більше 0 і не більше 6400, а не {p} +@msg percent must be above 0 and at most 6400, not {p} percent має бути більше 0 і не більше 6400, а не {p} @msg pick a colour, gradient or pattern to save as a swatch виберіть колір, градієнт або візерунок, щоб зберегти як зразок -@msg plane must be left, right or ground площина повинна бути left, right або ground +@msg plane must be left, right or ground plane має бути left, right або ground @msg plug-in ABI: {0} ABI модуля: {0} @msg plug-in exceeded its {0} модуль перевищив свій {0} @msg plug-in failed: {0} модуль завершився помилково: {0} @@ -4479,8 +4479,8 @@ axis Both Обидві @msg result would exceed the canvas (coordinates out of range) результат перевищив би полотно (координати поза діапазоном) @msg objects can't be nested more than 32 deep об’єкти не можна вкладати глибше ніж на 32 рівні @msg the SVG's elements are nested more than {0} deep елементи SVG вкладено глибше ніж на {0} рівнів -@msg scale must be a number from {_1} to {_2}, not {_3} масштаб має бути числом від {_1} до {_2}, а не {_3} -@msg scale must be a number from {_1} to {_2}, not {s} масштаб має бути числом від {_1} до {_2}, а не {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale має бути числом від {_1} до {_2}, а не {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale має бути числом від {_1} до {_2}, а не {s} @msg select (or target) objects whose appearance isn't basic виділіть (або вкажіть як об’єкт) об’єкти, оформлення яких не є основним @msg select a Live Paint group виділіть групу швидкої заливки @msg select a blend виділіть перехід @@ -4947,7 +4947,7 @@ axis Both Обидві @msg before must name a panel before має вказувати панель @msg docking action contains an unknown panel or an excessive split path Дія прикріплення містить невідому панель або надто довгий шлях поділів @msg group must be true or false group має мати значення true або false -@msg panel is not in a dock group панель не входить до групи дока +@msg panel is not in a dock group panel не входить до групи дока @msg only a dock group above or below others collapses, not one beside another згорнути можна лише групу дока над або під іншими, а не поруч з ними @msg icon must be true or false icon має бути true або false @msg icon only docks a panel in the icon column, with no anchor icon лише закріплює панель у стовпці значків, без anchor diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7a736a1b4..7bd7ad97f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4420,7 +4420,7 @@ theme Appearance Giao diện @msg bad shortcut phím tắt không hợp lệ @msg bad stream length độ dài luồng không hợp lệ @msg bad trailer trailer không hợp lệ -@msg balance must be between 0 and 100 cân bằng phải nằm trong khoảng 0 đến 100 +@msg balance must be between 0 and 100 balance phải nằm trong khoảng 0 đến 100 @msg batch step {_1} (`{id}`) failed: {e} bước hàng loạt {_1} (`{id}`) thất bại: {e} @msg before must name a panel before phải đặt tên bảng @msg below and above must name a panel floating in another group below và above phải đặt tên bảng đang thả nổi trong nhóm khác @@ -4475,7 +4475,7 @@ theme Appearance Giao diện @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Phục hồi dữ liệu không có nơi lưu bản sao: đặt Tùy chọn → Xử lý tệp → Phục hồi dữ liệu → Thư mục (recoveryFolder) @msg Data Recovery can't keep copies in {path}: {e} Phục hồi dữ liệu không thể lưu bản sao vào {path}: {e} @msg Plug-ins can't be loaded from {path}: {e} Không thể tải plug-in từ {path}: {e} -@msg decimals must be {_1}–{_2} (got {_3}) số thập phân phải từ {_1}–{_2} (nhận được {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals phải từ {_1}–{_2} (nhận được {_3}) @msg direct-select anchor points to cut at (not the end points of open paths) chọn trực tiếp các điểm neo để cắt (không phải điểm cuối của đường dẫn mở) @msgplural Discarded {n} recovered document|Discarded {n} recovered documents Đã loại bỏ {n} tài liệu đã phục hồi @msg divide into 1 to {MAX_PIECES} slices chia thành 1 đến {MAX_PIECES} lát cắt @@ -4515,7 +4515,7 @@ theme Appearance Giao diện @msg icon must be true or false icon phải là true hoặc false @msg icon only docks a panel in the icon column, with no anchor icon chỉ neo bảng vào cột biểu tượng, không có anchor @msg column docks a set as a dock column, with no anchor or icon column neo một bộ thành cột neo, không có anchor hay icon -@msg panel is not in a dock group bảng không thuộc nhóm nào của dock +@msg panel is not in a dock group panel không thuộc nhóm nào của dock @msg only a dock group above or below others collapses, not one beside another chỉ thu gọn được nhóm dock nằm trên hoặc dưới các nhóm khác, không phải nhóm nằm cạnh @msg guides are locked đường căn bị khóa @msg handle must be in or out handle phải là in hoặc out @@ -4526,9 +4526,9 @@ theme Appearance Giao diện @msg image would be too large; lower the resolution ảnh sẽ quá lớn; hãy giảm độ phân giải @msg Imported library {name} Đã nhập thư viện {name} @msg Imported {what}: {_1} Đã nhập {what}: {_1} -@msg index must be a number or first, previous, next or last chỉ mục phải là số hoặc first, previous, next hoặc last -@msg index must be a number or first, previous, next or last, not `{other}` chỉ mục phải là số hoặc first, previous, next hoặc last, không phải `{other}` -@msg index must be ≥ 0 chỉ mục không được âm +@msg index must be a number or first, previous, next or last index phải là số hoặc first, previous, next hoặc last +@msg index must be a number or first, previous, next or last, not `{other}` index phải là số hoặc first, previous, next hoặc last, không phải `{other}` +@msg index must be ≥ 0 index phải ≥ 0 @msg Installed plug-in {_1} Đã cài đặt plug-in {_1} @msg installing from a path is not available on the web; pass the module as `dataBase64` cài đặt từ đường dẫn không khả dụng trên web; hãy truyền mô-đun dưới dạng `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) lỗi nội bộ trong `{cmd}`: {msg} (tài liệu được giữ nguyên; vui lòng báo cáo lỗi này) @@ -4788,9 +4788,9 @@ theme Appearance Giao diện @msg PDF settings are not an object Cài đặt PDF không phải là đối tượng @msg PDF security settings are not an object Cài đặt bảo mật PDF không phải là đối tượng @msg PDF writer error: {0} Lỗi ghi PDF: {0} -@msg percent must be above 0 and at most 6400, not {p} phần trăm phải lớn hơn 0 và tối đa 6400, không phải {p} +@msg percent must be above 0 and at most 6400, not {p} percent phải lớn hơn 0 và tối đa 6400, không phải {p} @msg pick a colour, gradient or pattern to save as a swatch hãy chọn màu, chuyển sắc hoặc họa tiết để lưu làm mẫu màu -@msg plane must be left, right or ground mặt phẳng phải là left, right hoặc ground +@msg plane must be left, right or ground plane phải là left, right hoặc ground @msg plug-in ABI: {0} ABI plug-in: {0} @msg plug-in exceeded its {0} plug-in đã vượt quá {0} @msg plug-in failed: {0} plug-in thất bại: {0} @@ -4852,8 +4852,8 @@ theme Appearance Giao diện @msg Saving Đang lưu @msg Saving recovery data for {_1} Đang lưu dữ liệu phục hồi cho {_1} @msg Saving {_1} Đang lưu {_1} -@msg scale must be a number from {_1} to {_2}, not {_3} tỷ lệ phải là số từ {_1} đến {_2}, không phải {_3} -@msg scale must be a number from {_1} to {_2}, not {s} tỷ lệ phải là số từ {_1} đến {_2}, không phải {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale phải là số từ {_1} đến {_2}, không phải {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale phải là số từ {_1} đến {_2}, không phải {s} @msg searching a folder isn't available on the web tìm kiếm trong thư mục không khả dụng trên web @msg select (or target) objects whose appearance isn't basic chọn (hoặc đặt mục tiêu) đối tượng có diện mạo không cơ bản @msg select a blend hãy chọn một đối tượng hòa trộn @@ -4885,7 +4885,7 @@ theme Appearance Giao diện @msg select paths or anchor points chọn đường dẫn hoặc điểm neo @msg select paths to join hãy chọn đường dẫn để nối @msg select some type first hãy chọn chữ trước -@msg kerning must be a number, "auto", or "optical" Kerning phải là số, “auto” hoặc “optical” +@msg kerning must be a number, "auto", or "optical" kerning phải là số, “auto” hoặc “optical” @msg the selection has no gradient fill vùng chọn không có tô chuyển sắc @msg the selection has no gradient stroke vùng chọn không có nét chuyển sắc @msg the selection has no pattern fill vùng chọn không có tô họa tiết diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 347f1a47a..ed3d1da78 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3859,14 +3859,14 @@ imageTrace Outline 轮廓 @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 VectorCraft 上次启动时在其窗口出现之前于 {name} 上终止,因此本次在 {summary} 上启动。要再次尝试该图形处理器,请更改“首选项 › 性能 › 图形处理器”,或在设置 WGPU_BACKEND 后启动 @msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次启动时在列出图形处理器期间终止,因此本次仅使用 OpenGL 在 {summary} 上启动。要再次尝试其他图形后端,请更改“首选项 › 性能 › 图形处理器”,或在设置 WGPU_BACKEND 后启动 @msg not a DXF drawing 不是 DXF 图纸 -@msg scale must be a number from {_1} to {_2}, not {s} 比例必须是 {_1} 到 {_2} 之间的数字,而不是 {s} +@msg scale must be a number from {_1} to {_2}, not {s} scale 必须是 {_1} 到 {_2} 之间的数字,而不是 {s} @msg the artboard to fit must be a positive size up to {_1} pt 要适合的画板尺寸必须为正数,且不超过 {_1} pt @msg the drawing has no layout `{_1}` (it has {_2}) 图纸中没有布局 `{_1}` (它包含 {_2}) @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that 这是 DWG 图纸,无法读取:请在 CAD 应用中将其保存为 ASCII DXF,然后打开该文件 @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard 按此比例,图纸为 {w} × {h} pt,超出画布的容量:请选择较小的比例,或使其适合画板 @msg the scale is not a usable number 比例不是可用的数字 @msg the drawing is too large to open 图纸过大,无法打开 -@msg scale must be a number from {_1} to {_2}, not {_3} 比例必须是 {_1} 到 {_2} 之间的数字,而不是 {_3} +@msg scale must be a number from {_1} to {_2}, not {_3} scale 必须是 {_1} 到 {_2} 之间的数字,而不是 {_3} @msg an image has no pixels and was left out 已略去没有像素的图像 @msg object {0} cannot have children 对象 {0} 不能包含子对象 @msg range `{s}` is empty 范围 `{s}` 为空 @@ -3969,7 +3969,7 @@ imageTrace Outline 轮廓 @msg {name}: no local header {name}:没有本地文件头 @msg Flatten Transparency: select objects to flatten 拼合透明度:请选择要拼合的对象 @msg Flatten Transparency: {e} 拼合透明度:{e} -@msg balance must be between 0 and 100 平衡必须介于 0 到 100 之间 +@msg balance must be between 0 and 100 balance 必须介于 0 到 100 之间 @msg resolutions must be between 1 and 2400 ppi 分辨率必须介于 1 到 2400 ppi 之间 @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) 未知预设 `{name}` (high、medium、low 或已保存的预设:请参阅 flattener.presets.list) @msg `at` must be [x, y] `at` 必须是 [x, y] @@ -4135,7 +4135,7 @@ imageTrace Outline 轮廓 @msg no artboard {i} (0-based; the document has {_1}) 没有画板 {i} (从 0 开始计数;文档有 {_1} 个) @msg no selected slice in the image: select slices, or save all of them 图像中没有选中的切片:请选择切片,或全部保存 @msg nothing to save: the document has no visible art 没有可保存的内容:文档中没有可见的图稿 -@msg percent must be above 0 and at most 6400, not {p} 百分比必须大于 0 且不超过 6400,而不是 {p} +@msg percent must be above 0 and at most 6400, not {p} percent 必须大于 0 且不超过 6400,而不是 {p} @msg unknown preset `{name}` (see webExport.presets.list) 未知预设 `{name}` (请参阅 webExport.presets.list) @msg width and height are at least 1 pixel 宽度和高度至少为 1 像素 @msg `sampleSize` must be 1, 3 or 5 `sampleSize` 必须是 1、3 或 5 @@ -4292,7 +4292,7 @@ imageTrace Outline 轮廓 @msg is not an image VectorCraft reads 不是 VectorCraft 可读取的图像 @msg not found 未找到 @msg SVG parse error: {0} SVG 解析错误:{0} -@msg decimals must be {_1}–{_2} (got {_3}) 小数位数必须为 {_1}–{_2} (实际为 {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals 必须为 {_1}–{_2} (实际为 {_3}) @msg Exporting {_1} 正在导出 {_1} @msg Packaging {_1} 正在打包 {_1} @msg Placing {_1} 正在置入 {_1} @@ -4456,7 +4456,7 @@ imageTrace Outline 轮廓 @msg choose a folder to export to 请选择要导出到的文件夹 @msg the selection has no URL (or they differ) 所选对象没有 URL (或其 URL 各不相同) @msg select some type first 请先选择一些文字 -@msg kerning must be a number, "auto", or "optical" 字偶距必须是数字、“auto”或“optical” +@msg kerning must be a number, "auto", or "optical" kerning 必须是数字、“auto”或“optical” @msg the selection has no gradient fill 所选对象没有渐变填色 @msg the selection has no gradient stroke 所选对象没有渐变描边 @msg the selection has no pattern fill 所选对象没有图案填色 @@ -4621,7 +4621,7 @@ imageTrace Outline 轮廓 @msg before must name a panel before 必须指定面板 @msg docking action contains an unknown panel or an excessive split path 停靠操作包含未知面板或过长的拆分路径 @msg group must be true or false group 必须是 true 或 false -@msg panel is not in a dock group 面板不在停放组中 +@msg panel is not in a dock group panel 不在停放组中 @msg only a dock group above or below others collapses, not one beside another 只有位于其他组上方或下方的停放组可以折叠,并排的不行 @msg icon must be true or false icon 必须为 true 或 false @msg icon only docks a panel in the icon column, with no anchor icon 只会把面板停放到图标栏中,不能带 anchor diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 663a94c06..30fd19633 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1713,14 +1713,14 @@ file-filter Templates 範本 @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 VectorCraft 上次啟動時在 {name} 上於視窗出現前結束,因此這次在 {summary} 上啟動。若要再次嘗試該圖形處理器,請變更「偏好設定 › 效能 › 圖形處理器」,或設定 WGPU_BACKEND 後再啟動 @msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次啟動時在列出圖形處理器期間結束,因此這次只以 OpenGL 在 {summary} 上啟動。若要再次嘗試其他圖形後端,請變更「偏好設定 › 效能 › 圖形處理器」,或設定 WGPU_BACKEND 後再啟動 @msg not a DXF drawing 不是 DXF 圖面 -@msg scale must be a number from {_1} to {_2}, not {s} 比例必須是 {_1} 到 {_2} 之間的數字,而非 {s} +@msg scale must be a number from {_1} to {_2}, not {s} scale 必須是 {_1} 到 {_2} 之間的數字,而非 {s} @msg the artboard to fit must be a positive size up to {_1} pt 要符合的工作區域尺寸必須為正數,且不超過 {_1} pt @msg the drawing has no layout `{_1}` (it has {_2}) 圖面中沒有配置 `{_1}` (它有 {_2}) @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that 這是 DWG 圖面,無法讀取:請在 CAD 應用程式中將其儲存為 ASCII DXF,再開啟該檔案 @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard 以此比例,圖面為 {w} × {h} pt,超過畫布所能容納:請選擇較小的比例,或使其符合工作區域 @msg the scale is not a usable number 比例不是可用的數字 @msg the drawing is too large to open 圖面過大,無法開啟 -@msg scale must be a number from {_1} to {_2}, not {_3} 比例必須是 {_1} 到 {_2} 之間的數字,而非 {_3} +@msg scale must be a number from {_1} to {_2}, not {_3} scale 必須是 {_1} 到 {_2} 之間的數字,而非 {_3} @msg an image has no pixels and was left out 已略過沒有像素的影像 @msg object {0} cannot have children 物件 {0} 不能有子物件 @msg range `{s}` is empty 範圍 `{s}` 是空的 @@ -1823,7 +1823,7 @@ file-filter Templates 範本 @msg {name}: no local header {name}:沒有區域檔頭 @msg Flatten Transparency: select objects to flatten 透明度平面化:請選取要平面化的物件 @msg Flatten Transparency: {e} 透明度平面化:{e} -@msg balance must be between 0 and 100 平衡必須介於 0 到 100 之間 +@msg balance must be between 0 and 100 balance 必須介於 0 到 100 之間 @msg resolutions must be between 1 and 2400 ppi 解析度必須介於 1 到 2400 ppi 之間 @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) 未知的預設集 `{name}` (high、medium、low 或已儲存的預設集:請參閱 flattener.presets.list) @msg `at` must be [x, y] `at` 必須是 [x, y] @@ -1987,7 +1987,7 @@ file-filter Templates 範本 @msg no artboard {i} (0-based; the document has {_1}) 沒有工作區域 {i} (從 0 起算;文件有 {_1} 個) @msg no selected slice in the image: select slices, or save all of them 影像中沒有已選取的切片:請選取切片,或全部儲存 @msg nothing to save: the document has no visible art 沒有可儲存的內容:文件中沒有可見的圖稿 -@msg percent must be above 0 and at most 6400, not {p} 百分比必須大於 0 且不超過 6400,而非 {p} +@msg percent must be above 0 and at most 6400, not {p} percent 必須大於 0 且不超過 6400,而非 {p} @msg unknown preset `{name}` (see webExport.presets.list) 未知的預設集 `{name}` (請參閱 webExport.presets.list) @msg width and height are at least 1 pixel 寬度和高度至少為 1 像素 @msg `sampleSize` must be 1, 3 or 5 `sampleSize` 必須是 1、3 或 5 @@ -2147,7 +2147,7 @@ file-filter Templates 範本 @msg is not an image VectorCraft reads 不是 VectorCraft 可讀取的影像 @msg not found 找不到 @msg SVG parse error: {0} SVG 剖析錯誤:{0} -@msg decimals must be {_1}–{_2} (got {_3}) 小數位數必須為 {_1}–{_2} (收到 {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals 必須為 {_1}–{_2} (收到 {_3}) @msg Exporting {_1} 正在匯出 {_1} @msg Packaging {_1} 正在封裝 {_1} @msg Placing {_1} 正在置入 {_1} @@ -2311,7 +2311,7 @@ file-filter Templates 範本 @msg choose a folder to export to 請選擇要匯出至的資料夾 @msg the selection has no URL (or they differ) 選取範圍沒有 URL (或其 URL 各不相同) @msg select some type first 請先選取一些文字 -@msg kerning must be a number, "auto", or "optical" 字距微調必須是數字、「auto」或「optical」 +@msg kerning must be a number, "auto", or "optical" kerning 必須是數字、「auto」或「optical」 @msg the selection has no gradient fill 選取範圍沒有漸層填色 @msg the selection has no gradient stroke 選取範圍沒有漸層筆畫 @msg the selection has no pattern fill 選取範圍沒有圖樣填色 @@ -2471,7 +2471,7 @@ file-filter Templates 範本 @msg before must name a panel before 必須指定面板 @msg docking action contains an unknown panel or an excessive split path 停駐動作包含未知的面板或過長的分割路徑 @msg group must be true or false group 必須是 true 或 false -@msg panel is not in a dock group 面板不在停駐群組中 +@msg panel is not in a dock group panel 不在停駐群組中 @msg only a dock group above or below others collapses, not one beside another 只有位於其他群組上方或下方的停駐群組可以摺疊,並排的不行 @msg icon must be true or false icon 必須為 true 或 false @msg icon only docks a panel in the icon column, with no anchor icon 只會將面板停駐到圖示欄中,不能帶 anchor From fb413b39f3ba9d9c004826935fda48e57d8f933a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:27:21 +0700 Subject: [PATCH 09/93] Title bar: a Linux and BSD preference uses the app bar where the desktop draws the frame Squashed from #6101 onto main. --- apps/vectorcraft/src/main.rs | 5 +- apps/vectorcraft/src/tests.rs | 54 ++++++++++++++ apps/vectorcraft/src/titlebar.rs | 78 ++++++++++++++++---- crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/prefs.rs | 5 ++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 4 ++ crates/ui-egui/src/prefs_dialog.rs | 35 ++++++++- crates/ui-egui/src/prefs_dialog/tests.rs | 91 ++++++++++++++++++++++++ docs/control-protocol.md | 7 ++ docs/development.md | 2 +- docs/target-app-parity.md | 1 + 39 files changed, 347 insertions(+), 18 deletions(-) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 112c71370..e75452125 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -92,7 +92,9 @@ use titlebar::custom_titlebar_from; use titlebar::opted_into_system_title_bar; #[cfg(windows)] use titlebar::set_taskbar_icon; -use titlebar::{custom_titlebar, native_options}; +#[cfg(all(test, feature = "wgpu"))] +use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame}; +use titlebar::{custom_titlebar, native_options, titlebar_from_env}; struct App { app: VectorcraftApp, @@ -466,6 +468,7 @@ fn main() -> std::process::ExitCode { let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } + app.titlebar_from_env = titlebar_from_env(); app.custom_titlebar = custom_titlebar; app.desktop_frame = !custom_titlebar; if let Some(port) = control_port { diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 116651fc8..5f43124e7 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -164,6 +164,60 @@ fn the_system_title_bar_preference_keeps_the_window_decorations() { assert_eq!(super::native_options(true).viewport.decorations, Some(false)); } +/// Preferences › User Interface › VectorCraft Title Bar on Every Desktop gives the app frame where +/// the probe chose the desktop's (KDE, X11, a window opened through XWayland); without it the probe +/// decides, and `VECTORCRAFT_TITLEBAR` takes precedence over both. +#[test] +fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() { + assert!(!super::opted_into_app_title_bar(None), "no preferences: the default"); + let off = ui_state_with_prefs(serde_json::json!({"appTitleBar": false})); + assert!(!super::opted_into_app_title_bar(Some(&off))); + let on = ui_state_with_prefs(serde_json::json!({"appTitleBar": true})); + assert!(super::opted_into_app_title_bar(Some(&on))); + + // (probed_app, overridden, forced_to_x11): without the preference, then with it. + let cases = [ + ((false, None, false), false, true), // KDE, Sway, X11: the desktop frame + ((true, None, false), true, true), // GNOME on Wayland: the app frame either way + ((true, None, true), false, true), // GNOME, the window opened through XWayland + ((false, Some(true), false), true, true), // VECTORCRAFT_TITLEBAR=app + ((false, Some(true), true), true, true), // VECTORCRAFT_TITLEBAR=app, the window opened through XWayland + ((true, Some(false), false), false, false), // VECTORCRAFT_TITLEBAR=desktop takes precedence over the preference + ((false, Some(false), true), false, false), + ]; + for ((probed_app, overridden, forced_to_x11), without, with) in cases { + assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, false), without, "{probed_app} {overridden:?} {forced_to_x11}"); + assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, true), with, "{probed_app} {overridden:?} {forced_to_x11}"); + } +} + +/// The frame a start uses: the preference turns the desktop frame the probe chose into the app frame +/// with the default window buttons, a probed app frame keeps its own buttons, and the desktop frame +/// stays where the app frame is not drawn. System Title Bar takes precedence over the preference. +#[test] +fn the_start_frame_applies_the_app_title_bar_preference() { + use craft_window::{Button, Frame, Layout}; + let gnome = Layout { left: vec![Button::Close], right: vec![] }; + let cases = [ + // (probed, overridden, forced_to_x11, opted_into_app), expected + ((Frame::Desktop, None, false, false), Frame::Desktop), + ((Frame::Desktop, None, false, true), Frame::App(Layout::default())), + ((Frame::Desktop, None, true, true), Frame::App(Layout::default())), + ((Frame::App(gnome.clone()), None, false, false), Frame::App(gnome.clone())), + ((Frame::App(gnome.clone()), None, true, false), Frame::Desktop), + ((Frame::App(gnome.clone()), None, true, true), Frame::App(gnome.clone())), + ((Frame::Desktop, Some(false), false, true), Frame::Desktop), + ((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(gnome.clone())), + ]; + for ((probed, overridden, forced_to_x11, opted_into_app), expected) in cases { + let label = format!("{probed:?} {overridden:?} {forced_to_x11} {opted_into_app}"); + assert_eq!(super::start_frame(probed, overridden, forced_to_x11, opted_into_app), expected, "{label}"); + } + // With both preferences on, System Title Bar takes precedence: the desktop draws the frame. + let both = super::start_frame(Frame::Desktop, None, false, true); + assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference"); +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 63c9f7c13..6e7451ed9 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -46,6 +46,53 @@ pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::Ui saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) } +/// Whether the user asked for VectorCraft's own title bar in Preferences › User Interface › +/// VectorCraft Title Bar on Every Desktop (Linux and the BSDs). Read like +/// [`opted_into_system_title_bar`]; a missing or unreadable value is `false` (the default). +pub(crate) fn opted_into_app_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + saved.map(|ui| ui.engine_prefs.get("appTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) +} + +/// Whether the app draws the frame on this start, before the System Title Bar preference is +/// applied ([`custom_titlebar_from`]). `probed_app` is the `craft-window` probe's answer and +/// `overridden` its `VECTORCRAFT_TITLEBAR` override. In order: the override decides when it is +/// set; VectorCraft Title Bar on Every Desktop (`opted_into_app`) gives the app frame; a window +/// that opens through XWayland (`forced_to_x11`) gets the desktop frame; otherwise the probe +/// decides. +pub(crate) fn app_draws_frame(probed_app: bool, overridden: Option, forced_to_x11: bool, opted_into_app: bool) -> bool { + match overridden { + Some(app) => app, + None => opted_into_app || (probed_app && !forced_to_x11), + } +} + +/// The frame this start uses, before the System Title Bar preference is applied +/// ([`custom_titlebar_from`]): [`app_draws_frame`] applied to the frame the probe chose. A probed +/// app frame keeps its window-button layout; an app frame the probe did not choose gets the default +/// layout, since `custom_titlebar_from` reads only which frame it is. +pub(crate) fn start_frame(probed: craft_window::Frame, overridden: Option, forced_to_x11: bool, opted_into_app: bool) -> craft_window::Frame { + let probed_app = matches!(probed, craft_window::Frame::App(_)); + match (app_draws_frame(probed_app, overridden, forced_to_x11, opted_into_app), probed) { + (true, frame @ craft_window::Frame::App(_)) => frame, + (true, craft_window::Frame::Desktop) => craft_window::Frame::App(craft_window::Layout::default()), + (false, _) => craft_window::Frame::Desktop, + } +} + +/// The `craft-window` probe's answer for this process (its override variable is +/// `VECTORCRAFT_TITLEBAR`). The probe runs once; on a Wayland session it opens a connection. +fn probed_choice() -> &'static craft_window::Choice { + static CHOICE: std::sync::OnceLock = std::sync::OnceLock::new(); + CHOICE.get_or_init(|| craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"))) +} + +/// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › +/// User Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. Always +/// `false` on macOS and Windows. +pub(crate) fn titlebar_from_env() -> bool { + cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some() +} + /// Whether this start draws its own title bar, given the frame the shared `craft-window` crate /// decided and the user's System Title Bar preference. /// @@ -82,24 +129,27 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst /// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through /// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs /// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native -/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins. +/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins, +/// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]). pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")); + let choice = probed_choice(); let opted_into_system = opted_into_system_title_bar(saved); + // macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from` + // ignores the frame there). + let opted_into_app = cfg!(not(any(target_os = "macos", target_os = "windows"))) && opted_into_app_title_bar(saved); #[cfg(target_os = "linux")] - { + // When the window will actually open on X11 (XWayland) — the user asked for it *and* the X11 + // libraries are present, the same two conditions the launch path checks below — that run is + // effectively X11, so keep the native frame. If the libraries are missing, startup stays on + // Wayland, so the probe's own decision (app-drawn on GNOME) must stand. + let forced_to_x11 = { let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); - // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window - // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are - // present, the same two conditions the launch path checks below — that run is effectively - // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so - // the probe's own decision (app-drawn on GNOME) must stand. - let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found(); - if choice.overridden().is_none() && forced_to_x11 { - return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system); - } - } - custom_titlebar_from(choice.frame(), opted_into_system) + xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found() + }; + #[cfg(not(target_os = "linux"))] + let forced_to_x11 = false; + let frame = start_frame(choice.frame().clone(), choice.overridden(), forced_to_x11, opted_into_app); + custom_titlebar_from(&frame, opted_into_system) } pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 0267725ef..60f0f4565 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -349,6 +349,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool), p!("largeTabs", "User Interface", "", "Large Tabs", bool), p!("systemTitleBar", "User Interface", "", "System Title Bar", bool), + p!("appTitleBar", "User Interface", "", "VectorCraft Title Bar on Every Desktop", bool), p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")), p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool), // `auto` or a language code the shell registers (`zh-hant`); the shell shows it as a dropdown. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index fc2530c03..e1346b457 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -174,6 +174,10 @@ pub struct Prefs { /// acting as the title bar (tiling window managers, desktops that draw their own decorations). /// Read when the app starts. macOS always uses the system's. pub system_title_bar: bool, + /// Linux and the BSDs: draw the app bar as the title bar in sessions where the desktop draws + /// the frame (X11, Wayland compositors with server-side decorations, XWayland), as on GNOME. + /// Read when the app starts; System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it. + pub app_title_bar: bool, // Performance pub gpu_performance: bool, pub animated_zoom: bool, @@ -457,6 +461,7 @@ impl Default for Prefs { scale_cursor_with_ui: false, interface_language: s("auto"), system_title_bar: false, + app_title_bar: false, gpu_performance: true, animated_zoom: true, gpu_preference: s("automatic"), diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5b53073a3..33b2ae06f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -122,6 +122,7 @@ impl VectorcraftApp { frame_rate: Default::default(), canvas_rect: None, hover_doc: None, + titlebar_from_env: false, custom_titlebar: false, desktop_frame: false, last_window_title: String::new(), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index c83f7f43f..2f6f4b4a2 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4727,6 +4727,9 @@ axis Both И двете effect Soft Меко Edge Роб System Title Bar Заглавна лента на системата + VectorCraft Title Bar on Every Desktop Заглавна лента на VectorCraft на всеки работен плот + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Докато е зададена, променливата на средата VECTORCRAFT_TITLEBAR избира заглавната лента. + System Title Bar takes precedence over this option while it is on. Докато е включена, „Заглавна лента на системата“ има предимство пред тази опция. Value Axis Ос на стойностите On Left Side От лявата страна On Right Side От дясната страна diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 8f7649026..27bbcadd1 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -880,6 +880,9 @@ Stained Glass… Vitraž… Texturizer… Teksturizator… System Title Bar Sistemska naslovna traka + VectorCraft Title Bar on Every Desktop Naslovna traka VectorCrafta na svakoj radnoj površini + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dok je varijabla okruženja VECTORCRAFT_TITLEBAR postavljena, ona bira naslovnu traku. + System Title Bar takes precedence over this option while it is on. Dok je uključena, Sistemska naslovna traka ima prednost pred ovom opcijom. Exit Izlaz Distort Izobliči Diffuse Glow… Difuzni sjaj… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index a89022bf9..060c96e63 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4282,6 +4282,9 @@ digits Malayalam Malaiàlam Syntax newer than ES3 Sintaxi més nova que ES3 System Layout Disposició del sistema System Title Bar Barra de títol del sistema + VectorCraft Title Bar on Every Desktop Barra de títol de VectorCraft a tots els escriptoris + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mentre està definida, la variable d’entorn VECTORCRAFT_TITLEBAR determina la barra de títol. + System Title Bar takes precedence over this option while it is on. Mentre està activada, Barra de títol del sistema té prioritat sobre aquesta opció. Tab Tabulació Tab Stops Aturades de tabulació Table Taula diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1fa584fd0..469d64acf 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -894,6 +894,9 @@ Stained Glass… Vitráž… Texturizer… Texturovač… System Title Bar Systémové záhlaví okna + VectorCraft Title Bar on Every Desktop Záhlaví okna VectorCraftu v každém desktopovém prostředí + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dokud je proměnná prostředí VECTORCRAFT_TITLEBAR nastavena, záhlaví okna volí ona. + System Title Bar takes precedence over this option while it is on. Dokud je Systémové záhlaví okna zapnuté, má přednost před touto volbou. Exit Ukončit Distort Zkreslení Diffuse Glow… Rozptýlená záře… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 3c68b09fe..f89ebed0a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -821,6 +821,9 @@ Stained Glass… Blyindfattede ruder… Texturizer… Teksturér… System Title Bar Systemtitellinje + VectorCraft Title Bar on Every Desktop VectorCrafts titellinje på alle skriveborde + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljøvariablen VECTORCRAFT_TITLEBAR vælger titellinjen, så længe den er angivet. + System Title Bar takes precedence over this option while it is on. Systemtitellinje har forrang for denne indstilling, så længe den er slået til. Exit Afslut Distort Forvræng Diffuse Glow… Diffus glød… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5061ce431..6cb87a87b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4771,6 +4771,9 @@ theme Appearance Erscheinungsbild @plural {n} linked image changed on disk. Show the new version?|{n} linked images changed on disk. Show the new versions? {n} verknüpftes Bild wurde auf dem Datenträger geändert. Neue Version anzeigen?|{n} verknüpfte Bilder wurden auf dem Datenträger geändert. Neue Versionen anzeigen? # Window: the system's title bar (Windows, Linux). System Title Bar Systemtitelleiste + VectorCraft Title Bar on Every Desktop VectorCraft-Titelleiste auf jedem Desktop + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Solange die Umgebungsvariable VECTORCRAFT_TITLEBAR gesetzt ist, bestimmt sie die Titelleiste. + System Title Bar takes precedence over this option while it is on. Solange Systemtitelleiste eingeschaltet ist, hat sie Vorrang vor dieser Option. # Brush Options: Scatter, Art and Pattern brushes. Scatter Brush Options Optionen für Spezialpinsel Art Brush Options Optionen für Bildpinsel diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 25dac9e83..25b0422e5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4786,6 +4786,9 @@ digits Malayalam Μαλαγιάλαμ RGB: RGB: CMYK: CMYK: System Title Bar Γραμμή τίτλου συστήματος + VectorCraft Title Bar on Every Desktop Γραμμή τίτλου του VectorCraft σε κάθε επιφάνεια εργασίας + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Όσο είναι ορισμένη, η μεταβλητή περιβάλλοντος VECTORCRAFT_TITLEBAR επιλέγει τη γραμμή τίτλου. + System Title Bar takes precedence over this option while it is on. Όσο είναι ενεργή, η Γραμμή τίτλου συστήματος υπερισχύει αυτής της επιλογής. Value Axis Άξονας τιμών On Left Side Στην αριστερή πλευρά On Right Side Στη δεξιά πλευρά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 60897a54d..dafa078ed 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4875,6 +4875,9 @@ axis Both Ambos Bottom Right Abajo a la derecha Edge Borde System Title Bar Barra de título del sistema + VectorCraft Title Bar on Every Desktop Barra de título de VectorCraft en todos los escritorios + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mientras está definida, la variable de entorno VECTORCRAFT_TITLEBAR elige la barra de título. + System Title Bar takes precedence over this option while it is on. Mientras está activada, Barra de título del sistema tiene prioridad sobre esta opción. Value Axis Eje de valores On Left Side En el lado izquierdo On Right Side En el lado derecho diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8bb2da5c7..ea04404d5 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -878,6 +878,9 @@ Stained Glass… Lasimaalaus… Texturizer… Teksturoija… System Title Bar Järjestelmän otsikkorivi + VectorCraft Title Bar on Every Desktop VectorCraftin otsikkorivi kaikissa työpöytäympäristöissä + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Ympäristömuuttuja VECTORCRAFT_TITLEBAR valitsee otsikkorivin niin kauan kuin se on asetettu. + System Title Bar takes precedence over this option while it is on. Järjestelmän otsikkorivi ohittaa tämän asetuksen niin kauan kuin se on käytössä. Exit Lopeta Distort Vääristä Diffuse Glow… Hajautettu hehku… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 824d6b2af..2168c4bde 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4884,6 +4884,9 @@ axis Both Les deux effect Soft Doux Edge Bord System Title Bar Barre de titre du système + VectorCraft Title Bar on Every Desktop Barre de titre de VectorCraft sur tous les bureaux + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Tant qu’elle est définie, la variable d’environnement VECTORCRAFT_TITLEBAR choisit la barre de titre. + System Title Bar takes precedence over this option while it is on. Tant qu’elle est activée, Barre de titre du système a priorité sur cette option. Value Axis Axe des valeurs On Left Side À gauche On Right Side À droite diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9416bfd5b..94e3ce3e4 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4658,6 +4658,9 @@ System सिस्टम System Layout सिस्टम लेआउट System Title Bar सिस्टम टाइटल बार + VectorCraft Title Bar on Every Desktop हर डेस्कटॉप पर VectorCraft टाइटल बार + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. जब तक एनवायरनमेंट वेरिएबल VECTORCRAFT_TITLEBAR सेट है, टाइटल बार वही चुनता है। + System Title Bar takes precedence over this option while it is on. जब तक सिस्टम टाइटल बार चालू है, उसे इस विकल्प पर प्राथमिकता मिलती है। Tab Tab Tab Stops टैब स्टॉप्स Table तालिका diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index db219a38b..f60dd9f86 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4081,6 +4081,9 @@ mark-style Japanese Japán Symbols Szimbólumok System Layout Rendszerszintű elrendezés System Title Bar Rendszer címsora + VectorCraft Title Bar on Every Desktop A VectorCraft címsora minden asztali környezetben + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Amíg a VECTORCRAFT_TITLEBAR környezeti változó be van állítva, az választja ki a címsort. + System Title Bar takes precedence over this option while it is on. Amíg a Rendszer címsora be van kapcsolva, elsőbbséget élvez ezzel a beállítással szemben. Tab Tabulátor Tab Stops Tabulátorpontok Table Táblázat diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3ac90fabf..8d84f8200 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -821,6 +821,9 @@ Stained Glass… Kaca patri… Texturizer… Texturizer… System Title Bar Bilah judul sistem + VectorCraft Title Bar on Every Desktop Bilah judul VectorCraft di setiap desktop + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Selama ditetapkan, variabel lingkungan VECTORCRAFT_TITLEBAR memilih bilah judul. + System Title Bar takes precedence over this option while it is on. Selama aktif, Bilah judul sistem lebih diutamakan daripada opsi ini. Exit Keluar Distort Distorsi Diffuse Glow… Pendar difusi… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0d54ffe59..57e135ad2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4919,6 +4919,9 @@ effect Soft Morbido effect Regular Normale Edge Bordo System Title Bar Barra del titolo di sistema + VectorCraft Title Bar on Every Desktop Barra del titolo di VectorCraft su ogni desktop + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Finché è impostata, la variabile d'ambiente VECTORCRAFT_TITLEBAR sceglie la barra del titolo. + System Title Bar takes precedence over this option while it is on. Finché è attiva, Barra del titolo di sistema ha la precedenza su questa opzione. Value Axis Asse dei valori On Left Side Sul lato sinistro On Right Side Sul lato destro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 284f8cffb..451b1a5f7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4961,6 +4961,9 @@ effect Horizontal 水平 effect Vertical 垂直 Edge エッジ System Title Bar システムのタイトルバー + VectorCraft Title Bar on Every Desktop すべてのデスクトップで VectorCraft のタイトルバー + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 環境変数 VECTORCRAFT_TITLEBAR が設定されている間は、この変数でタイトルバーが決まります。 + System Title Bar takes precedence over this option while it is on. システムのタイトルバーがオンの間は、そちらがこのオプションより優先されます。 Value Axis 数値軸 On Left Side 左側 On Right Side 右側 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index cf9abe5bb..48b81a301 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -774,6 +774,9 @@ Stained Glass… ვიტრაჟი… Texturizer… ტექსტურირება… System Title Bar სისტემის სათაურის ზოლი + VectorCraft Title Bar on Every Desktop VectorCraft-ის სათაურის ზოლი ყველა სამუშაო მაგიდაზე + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. სანამ გარემოს ცვლადი VECTORCRAFT_TITLEBAR დაყენებულია, სათაურის ზოლს ის ირჩევს. + System Title Bar takes precedence over this option while it is on. სანამ სისტემის სათაურის ზოლი ჩართულია, მას ამ პარამეტრზე უპირატესობა აქვს. Exit გასვლა Distort დეფორმაცია Diffuse Glow… გაბნეული ნათება… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f341e1776..b6a60e185 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4888,6 +4888,9 @@ effect Horizontal 가로 effect Vertical 세로 Edge 가장자리 System Title Bar 시스템 제목 표시줄 + VectorCraft Title Bar on Every Desktop 모든 데스크톱에서 VectorCraft 제목 표시줄 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 환경 변수 VECTORCRAFT_TITLEBAR가 설정되어 있는 동안에는 이 변수가 제목 표시줄을 정합니다. + System Title Bar takes precedence over this option while it is on. 시스템 제목 표시줄이 켜져 있는 동안에는 그 설정이 이 옵션보다 우선합니다. Value Axis 값 축 On Left Side 왼쪽 On Right Side 오른쪽 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 3f0c7365b..f59503b55 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -835,6 +835,9 @@ Stained Glass… Glas in lood… Texturizer… Texturizer… System Title Bar Systeemtitelbalk + VectorCraft Title Bar on Every Desktop VectorCraft-titelbalk op elk bureaublad + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Zolang de omgevingsvariabele VECTORCRAFT_TITLEBAR is ingesteld, kiest die de titelbalk. + System Title Bar takes precedence over this option while it is on. Zolang Systeemtitelbalk is ingeschakeld, heeft die voorrang op deze optie. Exit Afsluiten Distort Vervormen Diffuse Glow… Diffuse gloed… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a1b2bb462..a36e96485 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -836,6 +836,9 @@ Stained Glass… Witraż… Texturizer… Teksturowanie… System Title Bar Systemowy pasek tytułu + VectorCraft Title Bar on Every Desktop Pasek tytułu VectorCraft na każdym pulpicie + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dopóki zmienna środowiskowa VECTORCRAFT_TITLEBAR jest ustawiona, to ona wybiera pasek tytułu. + System Title Bar takes precedence over this option while it is on. Dopóki Systemowy pasek tytułu jest włączony, ma pierwszeństwo przed tą opcją. Exit Zakończ Distort Zniekształcenie Diffuse Glow… Rozproszony blask… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index efd6d8047..777c8ac78 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3272,6 +3272,9 @@ imageTrace Outline Contorno Stained Glass… Vitral… Texturizer… Texturizador… System Title Bar Barra de título do sistema + VectorCraft Title Bar on Every Desktop Barra de título do VectorCraft em todas as áreas de trabalho + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Enquanto estiver definida, a variável de ambiente VECTORCRAFT_TITLEBAR escolhe a barra de título. + System Title Bar takes precedence over this option while it is on. Enquanto estiver ativada, Barra de título do sistema tem prioridade sobre esta opção. Exit Sair Distort Distorcer Diffuse Glow… Brilho difuso… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 18931dce6..d0a28ef56 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4914,6 +4914,9 @@ axis Both Оба Bottom Right Снизу справа Edge Край System Title Bar Системная строка заголовка + VectorCraft Title Bar on Every Desktop Строка заголовка VectorCraft на любом рабочем столе + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Пока переменная среды VECTORCRAFT_TITLEBAR задана, строку заголовка выбирает она. + System Title Bar takes precedence over this option while it is on. Пока включена «Системная строка заголовка», она имеет приоритет над этим параметром. Value Axis Ось значений On Left Side Слева On Right Side Справа diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 19a6f11c3..59f2be4a3 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4793,6 +4793,9 @@ axis Both Båda effect Soft Mjuk Edge Kant System Title Bar Systemets namnlist + VectorCraft Title Bar on Every Desktop VectorCrafts namnlist på alla skrivbord + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljövariabeln VECTORCRAFT_TITLEBAR väljer namnlisten så länge den är angiven. + System Title Bar takes precedence over this option while it is on. Systemets namnlist har företräde framför det här alternativet så länge den är på. Value Axis Värdeaxel On Left Side På vänster sida On Right Side På höger sida diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 02587229e..2ad972032 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4626,6 +4626,9 @@ axis Both Her İkisi effect Soft Yumuşak Edge Kenar System Title Bar Sistem Başlık Çubuğu + VectorCraft Title Bar on Every Desktop Her Masaüstünde VectorCraft Başlık Çubuğu + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. VECTORCRAFT_TITLEBAR ortam değişkeni ayarlı olduğu sürece başlık çubuğunu o seçer. + System Title Bar takes precedence over this option while it is on. Sistem Başlık Çubuğu açık olduğu sürece bu seçeneğe göre önceliklidir. Value Axis Değer Ekseni On Left Side Sol Tarafta On Right Side Sağ Tarafta diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e8e7bb937..08c6f9e74 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4901,6 +4901,9 @@ axis Both Обидві Bottom Right Знизу праворуч Edge Край System Title Bar Системний рядок заголовка + VectorCraft Title Bar on Every Desktop Рядок заголовка VectorCraft на будь-якій стільниці + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Поки змінну середовища VECTORCRAFT_TITLEBAR задано, рядок заголовка вибирає вона. + System Title Bar takes precedence over this option while it is on. Поки ввімкнено «Системний рядок заголовка», він має пріоритет над цим параметром. Value Axis Вісь значень On Left Side Ліворуч On Right Side Праворуч diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7bd7ad97f..b3c2fdf6f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3799,6 +3799,9 @@ mark-style Japanese Kiểu Nhật Symbols Ký hiệu System Layout Bố cục hệ thống System Title Bar Thanh tiêu đề hệ thống + VectorCraft Title Bar on Every Desktop Thanh tiêu đề VectorCraft trên mọi màn hình nền + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Khi biến môi trường VECTORCRAFT_TITLEBAR được đặt, biến này chọn thanh tiêu đề. + System Title Bar takes precedence over this option while it is on. Khi Thanh tiêu đề hệ thống được bật, tùy chọn đó được ưu tiên hơn tùy chọn này. Tab Tab Tab Stops Vị trí tab Table Bảng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ed3d1da78..74bed9ef6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5213,6 +5213,9 @@ effect Horizontal 水平 effect Vertical 垂直 Edge 边缘 System Title Bar 系统标题栏 + VectorCraft Title Bar on Every Desktop 在所有桌面上使用 VectorCraft 标题栏 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 设置了环境变量 VECTORCRAFT_TITLEBAR 时,由它选择标题栏。 + System Title Bar takes precedence over this option while it is on. 开启系统标题栏时,它优先于此选项。 Value Axis 数值轴 On Left Side 左侧 On Right Side 右侧 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 30fd19633..771c86f98 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5030,6 +5030,9 @@ axis Both 兩者 effect Soft 柔和 Edge 邊緣 System Title Bar 系統標題列 + VectorCraft Title Bar on Every Desktop 在所有桌面上使用 VectorCraft 標題列 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 設定了環境變數 VECTORCRAFT_TITLEBAR 時,由它選擇標題列。 + System Title Bar takes precedence over this option while it is on. 開啟系統標題列時,它優先於此選項。 Value Axis 數值軸 On Left Side 左側 On Right Side 右側 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9b054a683..4b931aaef 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -195,6 +195,10 @@ pub struct VectorcraftApp { clipboard_publishing: sysclip::Publishing, /// A URL to open through egui next frame (when the host has no `open_url` service). pending_url: Option, + /// `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › User + /// Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. `false` + /// unless the host sets it. + pub titlebar_from_env: bool, /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. pub custom_titlebar: bool, diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 8c3fa35d6..8822dc69e 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -19,6 +19,10 @@ pub const UI_FIELDS: &[(&str, &str, &str)] = &[ ("__taskBar", "General", "Enable Contextual Task Bar"), ]; +/// Whether User Interface shows VectorCraft Title Bar on Every Desktop (`appTitleBar`): Linux and the +/// BSDs, where the desktop may draw the window's frame. macOS and Windows never read it. +const APP_TITLE_BAR_SHOWN: bool = cfg!(not(any(target_arch = "wasm32", target_os = "macos", target_os = "windows"))); + pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { // One field per preference key or group (the flattener presets have commands of their own). let mut fields: Map = app.session.prefs.to_json().as_object().cloned().unwrap_or_default(); @@ -32,6 +36,9 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides)); fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid)); fields.insert("__taskBar".into(), json!(app.ui.task_bar)); + // Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (VectorCraft Title Bar on + // Every Desktop's note). + fields.insert("__titlebarFromEnv".into(), json!(app.titlebar_from_env)); fields.insert("__search".into(), json!("")); fields.insert("__focusSearch".into(), json!(true)); app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields }); @@ -52,6 +59,7 @@ fn settings() -> impl Iterator { let ui_fields = UI_FIELDS.iter().map(|&(key, category, label)| Setting { key, category, section: "", label }); let shown = |key: &str| match key { "systemTitleBar" => cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))), + "appTitleBar" => APP_TITLE_BAR_SHOWN, "linuxDisplayServer" => cfg!(target_os = "linux"), _ => true, }; @@ -474,8 +482,31 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, PrefKind::Bool if sp.key == "systemTitleBar" => { if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { bool_row(ui, d, sp.key, sp.label); - // The window's decorations are chosen once, when the app starts. - ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0)); + // The window's decorations are chosen once, when the app starts. On Linux and the + // BSDs the note goes under the next row, which also applies at the next start. + if !APP_TITLE_BAR_SHOWN { + ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0)); + } + } + } + // Linux and the BSDs only, where the desktop may draw the frame: dimmed while System + // Title Bar is on or VECTORCRAFT_TITLEBAR is set, which take precedence over it. A note + // names each one that is in effect. + PrefKind::Bool if sp.key == "appTitleBar" => { + if APP_TITLE_BAR_SHOWN { + let from_env = d.bool("__titlebarFromEnv"); + let system = d.bool("systemTitleBar"); + ui.add_enabled_ui(!from_env && !system, |ui| bool_row(ui, d, sp.key, sp.label)); + if system { + let note = tl!("System Title Bar takes precedence over this option while it is on."); + ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); + } + let note = if from_env { + tl!("The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set.") + } else { + tl!("Applies the next time VectorCraft starts.") + }; + ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); } } PrefKind::Bool => bool_row(ui, d, sp.key, sp.label), diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 8e0db81d9..99bc68c6b 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -301,6 +301,97 @@ fn system_title_bar_preference_shows_and_persists() { assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true)); } +/// User Interface › VectorCraft Title Bar on Every Desktop: shown on Linux and the BSDs only, under +/// System Title Bar with the next-launch note once, and saved under the key the desktop app reads +/// before the window opens. +#[test] +fn app_title_bar_preference_shows_and_persists() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut a = app(); + open(&mut a, Some("User Interface")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + let supported = cfg!(not(any(target_arch = "wasm32", target_os = "macos", target_os = "windows"))); + assert_eq!(shown.iter().any(|t| t == "VectorCraft Title Bar on Every Desktop"), supported, "{shown:?}"); + let notes = shown.iter().filter(|t| *t == "Applies the next time VectorCraft starts.").count(); + assert_eq!(notes, usize::from(cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos")))), "{shown:?}"); + a.ui.dialog.as_mut().unwrap().fields.insert("appTitleBar".into(), json!(true)); + confirm(&mut a).unwrap(); + assert!(a.session.prefs.app_title_bar); + let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap(); + assert_eq!(saved["engine_prefs"]["appTitleBar"], json!(true)); +} + +/// VectorCraft Title Bar on Every Desktop does not take clicks while System Title Bar is on or +/// VECTORCRAFT_TITLEBAR is set. With the variable set its note names the variable in place of the +/// next-launch note, and with System Title Bar on a note says that System Title Bar takes precedence. +#[test] +fn app_title_bar_is_dimmed_while_something_takes_precedence() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let note = "The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set."; + let system_note = "System Title Bar takes precedence over this option while it is on."; + let restart_note = "Applies the next time VectorCraft starts."; + let supported = cfg!(not(any(target_arch = "wasm32", target_os = "macos", target_os = "windows"))); + for (from_env, system) in [(false, false), (true, false), (false, true), (true, true)] { + let mut a = app(); + a.titlebar_from_env = from_env; + open(&mut a, Some("User Interface")); + a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(system)); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + assert_eq!(shown.iter().any(|t| t == note), supported && from_env, "{shown:?}"); + assert_eq!(shown.iter().any(|t| t == system_note), supported && system, "{shown:?}"); + // The next-launch note stays under this row on Linux and the BSDs, where it also covers + // System Title Bar, and under System Title Bar on Windows. + let restart_shown = if supported { !from_env } else { cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) }; + assert_eq!(shown.iter().filter(|t| *t == restart_note).count(), usize::from(restart_shown), "{shown:?}"); + // A click on the checkbox's label turns the preference on only when nothing takes precedence. + let label = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "VectorCraft Title Bar on Every Desktop" => Some(t.visual_bounding_rect()), + _ => None, + }); + assert_eq!(label.is_some(), supported); + if let Some(r) = label { + for pressed in [true, false] { + let input = egui::RawInput { + events: vec![ + egui::Event::PointerMoved(r.center()), + egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + } + assert_eq!(a.ui.dialog.as_ref().unwrap().bool("appTitleBar"), !from_env && !system, "variable {from_env}, System Title Bar {system}"); + } + } +} + /// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and /// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop /// app reads before the window opens. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3fcec09b8..66721a9eb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -331,6 +331,13 @@ instead: the OS title then shows the active document (`*name — VectorCraft` wi drops its brand mark. On Linux the app bar also drops its brand mark whenever the desktop draws the window's frame, with System Title Bar on or off. On macOS the window buttons are centred on the app bar; the web build is unchanged. +On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (`appTitleBar`, read +when the app starts, off by default) turns off the desktop's frame and uses the app bar as the title bar in the +sessions where the desktop would draw the frame: X11 sessions, Wayland compositors that draw server-side decorations +such as KDE Plasma, and a window opened through XWayland. System Title Bar and `VECTORCRAFT_TITLEBAR` (`app`, or +`system` or `desktop`) take precedence over it. The checkbox is dimmed while either applies, and a note under it names +each one that applies. + Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer); a swatch clicked there runs `paint.setFill` / `paint.setStroke`. The Properties panel has them with nothing selected diff --git a/docs/development.md b/docs/development.md index f7dd5ac59..3204c445b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -118,7 +118,7 @@ A file opened from the file manager (double-click, Open With, a drop on the exec ## Linux: Wayland and X11 -On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. +On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. On X11, on a Wayland compositor that draws server-side decorations such as KDE Plasma, and in a window opened through XWayland, enable **Preferences › User Interface › VectorCraft Title Bar on Every Desktop** and restart to use the app bar as the title bar instead of the desktop's frame. `VECTORCRAFT_TITLEBAR=app`, `=system` or `=desktop` chooses the frame for one run and takes precedence over VectorCraft Title Bar on Every Desktop. The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 326166c27..7c43638fe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -788,6 +788,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). - Edit › Keyboard Shortcuts › Export Text… saves the set shown in the dialog as a UTF-8 text file, as the reference app's [Export keyboard shortcuts list as a text document](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/export-keyboard-shortcuts-list.html) page describes (`shortcuts.exportText {path?}`). The layout is VectorCraft's own: the set's name, then the Tools and Menu Commands rows in the dialog's order, each with its name, a tab and its shortcuts as the dialog shows them, and nothing after the tab for a row with no shortcut. Tests `shortcut_editor::tests::export_text_*` and `tests::picks::keyboard_shortcuts_export_text_writes_the_working_set_as_text`. - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. + - On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it. - Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do. - A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559). - On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723). From 1d007bc3c80ed085a5130ebbeec665fe53a6d221 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:27:52 +0700 Subject: [PATCH 10/93] Artboard tool: buttons on the selected artboard's edges add an artboard beside it Squashed from #6111 onto main. --- crates/doc/src/artboard.rs | 54 ++++++++++++++ crates/doc/src/lib.rs | 2 +- crates/engine/src/cmd/layer.rs | 31 +++++++- crates/engine/src/cmd/panelcmds.rs | 8 +- crates/engine/src/tests/panelcmds.rs | 54 ++++++++++++++ crates/tools/src/xform/artboard.rs | 76 +++++++++++++++++++ crates/tools/src/xform/artboard/tests.rs | 94 ++++++++++++++++++++++++ docs/mcp.md | 9 +++ docs/target-app-parity.md | 1 + 9 files changed, 322 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index 6d5d50d4d..d7bfcafca 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -106,6 +106,60 @@ impl super::Document { d.layers.insert(0, Arc::new(layer)); Some(d) } + + /// Where an artboard the size of artboard `i` goes on `side` of it, [`ARTBOARD_GAP`] away, + /// when there is room for it there: every side within [`crate::MAX_COORD`] of the origin and + /// no other artboard overlapping it (touching edges don't overlap). `None` when there is no + /// such artboard or no room. + pub fn room_beside_artboard(&self, i: usize, side: ArtboardSide) -> Option { + let src = self.artboards.get(i)?.rect; + let r = src + side.offset(src); + let in_canvas = [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD); + let clear = self.artboards.iter().all(|a| !(r.x0 < a.rect.x1 && a.rect.x0 < r.x1 && r.y0 < a.rect.y1 && a.rect.y0 < r.y1)); + (in_canvas && clear).then_some(r) + } +} + +/// The space between an artboard and one added or duplicated beside it (VectorCraft's own value, +/// the space New Artboard and Duplicate Artboards leave right of the last artboard). +pub const ARTBOARD_GAP: f64 = 20.0; + +/// A side of an artboard, where an artboard added or duplicated beside it goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArtboardSide { + Left, + Right, + Top, + Bottom, +} + +impl ArtboardSide { + pub const ALL: [Self; 4] = [Self::Top, Self::Right, Self::Bottom, Self::Left]; + + /// The side a command's `side` param names: "left", "right", "top" or "bottom". + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|s| s.name() == name) + } + + pub fn name(self) -> &'static str { + match self { + Self::Left => "left", + Self::Right => "right", + Self::Top => "top", + Self::Bottom => "bottom", + } + } + + /// How far a copy of `r` moves to sit on this side of it, [`ARTBOARD_GAP`] away (y down). + pub fn offset(self, r: Rect) -> vectorcraft_geom::Vec2 { + let (w, h) = (r.width() + ARTBOARD_GAP, r.height() + ARTBOARD_GAP); + match self { + Self::Left => vectorcraft_geom::Vec2::new(-w, 0.0), + Self::Right => vectorcraft_geom::Vec2::new(w, 0.0), + Self::Top => vectorcraft_geom::Vec2::new(0.0, -h), + Self::Bottom => vectorcraft_geom::Vec2::new(0.0, h), + } + } } /// A document's locked artboards ([`Document::locked_artboards`]). diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8eeb1786d..c995158d8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -141,7 +141,7 @@ mod limits; mod records; mod units; -pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; +pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; pub use canvas_range::BeyondCanvas; #[cfg(test)] use document_ext::free_id; diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index d4adaf2fd..d5437d017 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling}; +use vectorcraft_doc::{Artboard, ArtboardSide, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, Rect}; use super::{ @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1)} (default place: right of the last)", + "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", has_doc, artboard_new ), @@ -869,6 +869,17 @@ fn artboard_str<'a>(p: &'a Value, key: &str, cmd: &str) -> Result Result> { + let bad_side = || bad(cmd, "`side` must be \"left\", \"right\", \"top\" or \"bottom\""); + match p.get("side") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => ArtboardSide::parse(v).map(Some).ok_or_else(bad_side), + Some(_) => Err(bad_side()), + } +} + /// An artboard command's `videoRulerPixelAspectRatio` param: None when absent, else a number within /// [`vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE`]. fn video_pixel_aspect(p: &Value, cmd: &str) -> Result> { @@ -886,10 +897,24 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { let d0 = &s.doc()?.doc; let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1); - let r = rect_from(p, def); let color = artboard_color(p, "artboard.new")?.flatten(); check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?; + let r = match artboard_side(p, "artboard.new")? { + Some(side) => { + if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { + return Err(bad("artboard.new", "`side` places the artboard: leave out x, y, width and height")); + } + let beside = match p.get("beside") { + None | Some(Value::Null) => d0.artboards.len().saturating_sub(1), + Some(_) => super::appearance::index_param(p, "beside", "artboard.new")?, + }; + let src = d0.artboards.get(beside).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + src + side.offset(src) + } + None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), + None => rect_from(p, def), + }; let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty()); let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false)); let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 6eeafd7c3..073031a8f 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Duplicate Artboard", ["Window", "Artboards"], None, - "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art`, in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", + "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the copies go together on that side of the artboards they copy, 20 pt away)} a copy of each artboard, the copies placed together right of the last artboard as they lay (or on `side`), with copies of the art fully inside each when `art`, in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", has_doc, artboard_duplicate ), @@ -297,9 +297,11 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec } /// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep -/// their places relative to each other, together right of the last artboard. +/// their places relative to each other, together right of the last artboard, or with `side` +/// together on that side of the artboards they copy. fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { check_switches(p, &["patterns"], "artboard.duplicate")?; + let side = super::layer::artboard_side(p, "artboard.duplicate")?; let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?; let locked_art = locked_and_hidden(s, p, art, "artboard.duplicate")?; let arts = artboards_art(s, &indices, art, locked_art, false)?; @@ -311,7 +313,7 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { .filter_map(|(i, _)| d.artboards.get(*i)) .map(|a| a.rect) .reduce(|a, b| a.union(b)) - .map(|r| beside_artboards(d, r)) + .map(|r| side.map_or_else(|| beside_artboards(d, r), |side| side.offset(r))) .unwrap_or_default(); let (mut index, mut ids) = (vec![], vec![]); for (i, art) in &arts { diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 0f95fc2f6..0870ffb87 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -119,6 +119,60 @@ fn artboards_reorder_and_duplicate() { assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second"); } +/// `artboard.new {beside, side}` adds an empty artboard the size of artboard `beside`, 20 pt from +/// that side of it, and `artboard.duplicate {index, side}` puts the copy and its art there; each is +/// one undo step. An unknown side, `beside` without `side`, and `side` with a size or place are +/// refused and change nothing. +#[test] +fn artboards_are_added_and_duplicated_on_a_side() { + let mut s = session(); + let id = rect(&mut s); + let board = |s: &Session, i: usize| s.doc().unwrap().doc.artboards[i].rect; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let src = board(&s, 0); + let before = undo(&s); + assert_eq!(s.execute("artboard.new", &json!({"beside": 0, "side": "bottom"})).unwrap()["index"], 1); + assert_eq!(board(&s, 1), src + vectorcraft_geom::Vec2::new(0.0, src.height() + 20.0), "same size, 20 pt below"); + assert_eq!(undo(&s), before + 1, "one undo step"); + assert!(!s.doc().unwrap().doc.artboard_has_art(board(&s, 1)), "the new artboard is empty"); + s.execute("artboard.new", &json!({"beside": 0, "side": "left"})).unwrap(); + assert_eq!(board(&s, 2), src + vectorcraft_geom::Vec2::new(-(src.width() + 20.0), 0.0)); + s.execute("artboard.new", &json!({"side": "top"})).unwrap(); + assert_eq!(board(&s, 3), board(&s, 2) + vectorcraft_geom::Vec2::new(0.0, -(src.height() + 20.0)), "by default beside the last"); + s.execute("artboard.new", &json!({"side": "top", "beside": null, "x": null})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + + let before = undo(&s); + let out = s.execute("artboard.duplicate", &json!({"index": 0, "side": "right", "art": true})).unwrap(); + assert_eq!(out["index"], 4); + let dv = vectorcraft_geom::Vec2::new(src.width() + 20.0, 0.0); + assert_eq!(board(&s, 4), src + dv, "the copy sits 20 pt right of the artboard it copies"); + let copies: Vec = out["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 1); + let bounds = |s: &Session, id: NodeId| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + assert_eq!(bounds(&s, copies[0]), bounds(&s, id) + dv, "its art goes with it"); + assert_eq!(undo(&s), before + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 4, "undo takes the copy and its art away together"); + assert!(s.doc().unwrap().doc.node(copies[0]).is_none()); + + let before = s.doc().unwrap().doc.clone(); + let steps = undo(&s); + for (cmd, p, msg) in [ + ("artboard.new", json!({"beside": 0, "side": "middle"}), "`side` must be"), + ("artboard.new", json!({"beside": 0, "side": 3}), "`side` must be"), + ("artboard.new", json!({"beside": 0}), "`beside` needs `side`"), + ("artboard.new", json!({"side": "left", "width": 100}), "leave out x, y, width and height"), + ("artboard.new", json!({"beside": 9, "side": "left"}), "no such artboard"), + ("artboard.duplicate", json!({"index": 0, "side": "inside"}), "`side` must be"), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{cmd} {p}: {e}"); + assert_eq!(*s.doc().unwrap().doc, *before, "{cmd} {p} changes nothing"); + assert_eq!(undo(&s), steps, "{cmd} {p}"); + } +} + /// An artboard's background color (Properties › Artboards › Fill): `artboard.setProps {color}` sets /// it and `{color: null}` clears it, each in one undo step. A change without `color` leaves it, a /// duplicate keeps it, `inspect_document` reports it, `artboard.new {color}` starts with it, an diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index c3710c1bd..4ffd435af 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -21,8 +21,17 @@ //! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]). A locked artboard (the //! Artboards panel's lock column) is selected by a click, but a drag or an arrow key doesn't move it //! or copy it, it shows no handles, and Delete leaves it. +//! +//! While one artboard is selected and nothing is being dragged, a button with a plus sits just +//! outside the middle of each of its edges where an artboard of the same size fits on that side +//! ([`vectorcraft_doc::Document::room_beside_artboard`]: within the canvas, 20 pt away, overlapping +//! no other artboard). Clicking one adds an empty artboard there (`artboard.new {beside, side}`); +//! Alt-clicking duplicates the artboard with its art there (`artboard.duplicate {index, side}`), +//! except on a locked artboard, where an Alt-click does nothing. Each is one undo step, and the new +//! artboard becomes the active one. use serde_json::{Value, json}; +use vectorcraft_doc::ArtboardSide; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::{BLUE, polygon, rect_corners}; @@ -282,6 +291,44 @@ fn bleed_offset(cx: &ToolContext, rect: Rect, handle: Handle) -> Vec2 { Vec2::new(if ax { d.x } else { 0.0 }, if ay { d.y } else { 0.0 }) } +/// How far outside the middle of an edge its add button sits, in screen pixels: past the handle +/// on that edge. +const EDGE_BUTTON_OFFSET: f64 = 18.0; +/// The add button's radius in screen pixels: a press this near its center takes it. +const EDGE_BUTTON_RADIUS: f64 = 7.0; + +impl ArtboardTool { + /// The add buttons on the active artboard's edges, each with its side and center: shown while + /// it is the only one selected and nothing is being dragged, and only on the sides with room + /// for an artboard of the same size ([`vectorcraft_doc::Document::room_beside_artboard`]). + fn edge_buttons(&self, cx: &ToolContext) -> Vec<(ArtboardSide, Point)> { + let Some(r) = self.active_rect(cx) else { return vec![] }; + if self.drag.is_some() || self.selected(cx).len() != 1 { + return vec![]; + } + let off = cx.tol(EDGE_BUTTON_OFFSET); + let c = r.center(); + ArtboardSide::ALL + .into_iter() + .filter(|side| cx.doc.room_beside_artboard(self.active, *side).is_some()) + .map(|side| { + let at = match side { + ArtboardSide::Left => Point::new(r.x0 - off, c.y), + ArtboardSide::Right => Point::new(r.x1 + off, c.y), + ArtboardSide::Top => Point::new(c.x, r.y0 - off), + ArtboardSide::Bottom => Point::new(c.x, r.y1 + off), + }; + (side, at) + }) + .collect() + } + + /// The add button under `p`, if any. + fn edge_button_at(&self, cx: &ToolContext, p: Point) -> Option { + self.edge_buttons(cx).into_iter().find(|(_, at)| at.distance(p) <= cx.tol(EDGE_BUTTON_RADIUS)).map(|(side, _)| side) + } +} + /// The grid's snapping step (gridline spacing over subdivisions), as `guides::snap_draw` uses. impl Tool for ArtboardTool { fn id(&self) -> &'static str { @@ -305,6 +352,21 @@ impl Tool for ArtboardTool { match (ev.kind, self.drag) { (PointerKind::Down, _) => { self.click_art = None; + // An add button: an empty artboard of the same size on that side, or with Alt a + // copy of the artboard and its art there; the new one becomes active. An Alt-click + // on a locked artboard's button does nothing: the tool doesn't copy a locked artboard. + if let Some(side) = self.edge_button_at(cx, p) { + let index = self.active; + if m.alt && cx.doc.artboards.get(index).is_some_and(|a| a.locked) { + return vec![]; + } + let (id, v) = if m.alt { + ("artboard.duplicate", json!({ "index": index, "side": side.name(), "art": true })) + } else { + ("artboard.new", json!({ "beside": index, "side": side.name() })) + }; + return vec![Action::Exec(id.into(), v), Action::Notify("created".into())]; + } if let Some(r) = self.resizable_rect(cx) && let Some(handle) = hit_handle(r, p, cx.tol(5.0)) { @@ -593,10 +655,24 @@ impl Tool for ArtboardTool { if self.drag.is_some() { o.extend(self.guides.iter().cloned()); } + // The add buttons: a ring with a plus in it. + let (radius, arm) = (cx.tol(EDGE_BUTTON_RADIUS), cx.tol(3.5)); + for (_, at) in self.edge_buttons(cx) { + let ring: Vec = (0..24).map(|k| at + Vec2::from_angle(f64::from(k) * std::f64::consts::TAU / 24.0) * radius).collect(); + let mut path = polygon(&ring, true); + for arm in [Vec2::new(arm, 0.0), Vec2::new(0.0, arm)] { + path.move_to(at - arm); + path.line_to(at + arm); + } + o.push(Overlay::Path { path, color: BLUE, width: 1.0, dashed: false }); + } o } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + if self.edge_button_at(cx, p).is_some() { + return Cursor::Arrow; + } if let Some(r) = self.resizable_rect(cx) && let Some(h) = hit_handle(r, p, cx.tol(5.0)) { diff --git a/crates/tools/src/xform/artboard/tests.rs b/crates/tools/src/xform/artboard/tests.rs index 3c8900ff5..98410703f 100644 --- a/crates/tools/src/xform/artboard/tests.rs +++ b/crates/tools/src/xform/artboard/tests.rs @@ -562,3 +562,97 @@ fn alt_drag_moves_a_copy_of_the_artboard() { assert_eq!(a, vec![Action::Cancel, Action::Begin("Duplicate Artboard".into()), moved(70.0, true)]); assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 100.0).with_mods(alt)), vec![Action::Commit]); } + +/// The sides of the active artboard that show an add button. +fn button_sides(t: &ArtboardTool, c: &ToolContext) -> Vec { + t.edge_buttons(c).into_iter().map(|(side, _)| side).collect() +} + +/// The rings the add buttons draw. +fn button_rings(t: &ArtboardTool, c: &ToolContext) -> usize { + t.overlays(c).iter().filter(|o| matches!(o, Overlay::Path { dashed: false, .. })).count() +} + +#[test] +fn add_buttons_show_only_on_sides_with_room_for_an_artboard_of_the_same_size() { + let mut d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let t = ArtboardTool::default(); + // Artboard 1 (0–500): a copy on its right (520–1020) would overlap artboard 2 (600–800). + let c = cx(&d, &s, &p); + assert_eq!(button_sides(&t, &c), vec![ArtboardSide::Top, ArtboardSide::Bottom, ArtboardSide::Left]); + assert_eq!(button_rings(&t, &c), 3); + // Artboard 2 (600–800 × 0–200): its left would overlap artboard 1, the rest has room. + let t2 = ArtboardTool { active: 1, ..Default::default() }; + assert_eq!(button_sides(&t2, &c), vec![ArtboardSide::Top, ArtboardSide::Right, ArtboardSide::Bottom]); + // At the canvas's edge there is no room past it. + let m = vectorcraft_doc::MAX_COORD; + d.artboards[1].rect = Rect::new(m - 200.0, 0.0, m, 200.0); + let c = cx(&d, &s, &p); + assert_eq!(button_sides(&t2, &c), vec![ArtboardSide::Top, ArtboardSide::Bottom, ArtboardSide::Left]); + // The buttons sit just outside the middle of each edge. + let off = c.tol(EDGE_BUTTON_OFFSET); + assert_eq!(t.edge_buttons(&c)[0].1, Point::new(250.0, -off)); +} + +#[test] +fn add_buttons_hide_with_several_artboards_selected_or_during_a_drag() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let t = ArtboardTool { active: 1, others: vec![0], ..Default::default() }; + assert!(button_sides(&t, &c).is_empty()); + assert_eq!(button_rings(&t, &c), 0); + let off = c.tol(EDGE_BUTTON_OFFSET); + assert_eq!(t.edge_button_at(&c, Point::new(700.0, 200.0 + off)), None, "no button takes a press"); + let mut t = ArtboardTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); + t.pointer(&c, &ev(PointerKind::Drag, 720.0, 100.0)); + assert!(button_sides(&t, &c).is_empty(), "none while an artboard is dragged"); +} + +#[test] +fn an_add_button_adds_an_empty_artboard_and_alt_duplicates_on_that_side() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let off = c.tol(EDGE_BUTTON_OFFSET); + let below = Point::new(250.0, 500.0 + off); + let mut t = ArtboardTool::default(); + assert_eq!(t.cursor(&c, below, Mods::default()), Cursor::Arrow); + let a = t.pointer(&c, &ev(PointerKind::Down, below.x, below.y)); + assert_eq!(a, vec![Action::Exec("artboard.new".into(), json!({"beside": 0, "side": "bottom"})), Action::Notify("created".into())]); + // The release that follows adds nothing more. + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, below.x, below.y)), vec![]); + let alt = Mods { alt: true, ..Mods::default() }; + let left = Point::new(-off, 250.0); + let a = t.pointer(&c, &ev(PointerKind::Down, left.x, left.y).with_mods(alt)); + assert_eq!( + a, + vec![Action::Exec("artboard.duplicate".into(), json!({"index": 0, "side": "left", "art": true})), Action::Notify("created".into())] + ); + // The new artboard, last in the document, becomes the active one. + let mut d3 = d.clone(); + d3.artboards.push(d.artboards[0].clone()); + t.notify(&cx(&d3, &s, &p), "created"); + assert_eq!((t.active, t.others.clone()), (2, vec![])); +} + +/// On a locked artboard, a click on an add button still adds an empty artboard beside it, but an +/// Alt-click does nothing: the tool doesn't copy a locked artboard. +#[test] +fn an_alt_click_on_a_locked_artboards_add_button_does_not_duplicate_it() { + let mut d = two_boards(); + d.artboards[0].locked = true; + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let off = c.tol(EDGE_BUTTON_OFFSET); + let left = Point::new(-off, 250.0); + let mut t = ArtboardTool::default(); + let alt = Mods { alt: true, ..Mods::default() }; + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, left.x, left.y).with_mods(alt)), vec![]); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, left.x, left.y).with_mods(alt)), vec![]); + assert_eq!((t.active, t.others.clone()), (0, vec![])); + let a = t.pointer(&c, &ev(PointerKind::Down, left.x, left.y)); + assert_eq!(a, vec![Action::Exec("artboard.new".into(), json!({"beside": 0, "side": "left"})), Action::Notify("created".into())]); +} diff --git a/docs/mcp.md b/docs/mcp.md index b5a6a2d3c..1a6189800 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4186,6 +4186,15 @@ selects them. `edit.paste {artboardsAt: [x, y]}` instead centers the copied artb value that is not `[x, y]` fails with `` `artboardsAt` must be [x, y] ``. `artboard.duplicate {index? | indices?, art?}` does the same in one step → `{index, indices, ids}`. Window › Artboards › Duplicate Artboards passes the selected artboards (those the Artboard tool has selected, else the active one) as `indices`, and a row dragged onto New Artboard passes its artboard as `index`. +`artboard.new {beside?, side}` adds an empty artboard the size of artboard `beside` (default: the last), 20 pt from +that side of it, and `artboard.duplicate {index, side}` puts the copies there instead, together as they lay on that side +of the artboards they copy; `side` is `"left"`, `"right"`, `"top"` or `"bottom"`. Another `side` fails with +`` `side` must be "left", "right", "top" or "bottom" ``, `beside` without `side` fails with `` `beside` needs `side` ``, +and `artboard.new` refuses `side` given with `x`, `y`, `width` or `height`; each leaves the document and its undo +history as they were. With the Artboard tool and one artboard selected, a button with a plus shows just outside the +middle of each edge where an artboard of the same size fits on that side (within the canvas and overlapping no other +artboard): a click runs `artboard.new {beside, side}`, an Alt-click `artboard.duplicate {index, side, art: true}`, and +the new artboard becomes the active one. An Alt-click on a locked artboard's button does nothing. `artboard.move {copy: true, moveArt: true}` is the Artboard tool's Alt-drag. `artboard.duplicate` and `artboard.move` also move the pattern tiles of the copies when `patterns` is true (default: prefs `transformPatternTiles`); `edit.paste` leaves the tiles of a pasted artboard's art where they were. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7c43638fe..615976277 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -620,6 +620,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard Escape routing:** Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Escape in the distort tools:** Escape during a Puppet Warp pin drag or turn, a Perspective Grid tool handle or plane widget drag, a Perspective Selection move, resize or plane widget drag, or a Liquify stroke cancels it: the art goes back to how the press found it, no undo step is added, and the tool stays chosen. Puppet Warp also selects again the pins selected before the press, and Escape during a Liquify Alt-drag puts the brush size back. Regression tests: `escape_cancels_*` in `crates/tools/src/distort/puppet.rs`, `perspective.rs` and `liquify.rs`, and `escape_cancels_a_puppet_warp_perspective_or_liquify_drag` in `tests/distortkeys.rs`. - **Shift with the Artboard tool:** a Shift-click adds an artboard to the selection or takes it out when the button comes up. A Shift-drag inside an artboard draws a new artboard there, and a Shift-drag on the pasteboard is a marquee that toggles the artboards it touches, as the [default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (a Shift-drag draws an artboard within an existing one) and [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy) describe. Shift no longer keeps an artboard move at 45° steps or a drawn artboard square. Escape during a marquee or while a new artboard is being drawn cancels it and keeps the Artboard tool. Pointer and key tests in `crates/tools/src/xform/artboard.rs` cover the four gestures and Escape. +- **Artboard edge buttons:** with the Artboard tool and one artboard selected, a button with a plus shows just outside the middle of each edge where an artboard of the same size fits on that side; clicking it adds an empty artboard of that size there (`artboard.new {beside, side}`), and Alt-clicking duplicates the artboard with its art there (`artboard.duplicate {index, side}`), each in one undo step, with the new artboard active. An Alt-click on a locked artboard's button does nothing, as the tool doesn't copy a locked artboard. The 20 pt gap and the room rule (within the canvas, overlapping no other artboard) are VectorCraft's own. Pointer tests in `crates/tools/src/xform/artboard/tests.rs` and an engine test in `tests/panelcmds.rs` cover it. Public references: [Add artboards instantly](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html) and [Duplicate artboards instantly](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/duplicate-artboards.html). - **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests/distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo. - **Locked artboards:** a click in the Artboards panel's lock column locks or unlocks an artboard in one undo step (`artboard.setProps {index, locked}`), and the native format saves the lock. The Artboard tool doesn't move, copy, resize or delete a locked artboard, Rearrange and Fit to Artwork Bounds leave it as it is, and its name isn't edited on the canvas. The art on it (each object whose bounds overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, `select.set`, `select.all` and the tools pass over it, and commands that act on the selection leave it; art drawn or pasted onto it stays selected and editable until the selection leaves it out. Tests in `crates/doc/src/lib.rs`, `tests/panelcmds.rs`, `xform/artboard.rs`, `panels/artboards.rs` and `canvas.rs` cover them. Public references: [Lock artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/lock-artboards.html) and [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/). - **Linux RPM packaging:** 64-bit runtime capabilities replace Fedora-specific required package names (#1275); glibc 2.35, libm and libgcc_s are declared alongside dynamically loaded xkbcommon and Vulkan/EGL. Generated x86_64/aarch64 metadata and isolated synthetic-provider resolution checked; real-distro install/start-up QA remains pending. From f2e6c6d219886900dd0c787caced6e09e44865c3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:28:03 +0700 Subject: [PATCH 11/93] .ai save: the editing copy keeps symbols and their instances Squashed from #6136 onto main. --- crates/eps/src/ai_editing/tests.rs | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index f25944060..42d3c7d73 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -833,6 +833,40 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { assert_eq!(layers.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); } +/// On a canvas of the usual size, nested symbols whose instances multiply, written as their art in +/// art cut to a clipping object, stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art +/// in all, with a warning. +#[test] +fn clipped_nested_symbol_instances_stop_at_the_symbol_art_limit() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + // Empty paths: counted, but written as nothing, so the test stays small; and one filled square. + let empty = Arc::new(Node::path(doc.alloc_id(), PathData::new(vec![]), Appearance::default())); + let mut members = vec![empty; 4999]; + let square_art = shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)); + members.push(Arc::new(Node::path(doc.alloc_id(), square_art, Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0)))); + let leaves = Node::group(doc.alloc_id(), members); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaves) }); + let leaf = Arc::new(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Leaf".into(), xf: Affine::IDENTITY })); + let trees = Node::group(doc.alloc_id(), vec![leaf; 5000]); + doc.symbols.push(Symbol { dynamic: false, name: "Tree".into(), art: Arc::new(trees) }); + let tree = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Tree".into(), xf: Affine::IDENTITY }); + // A group as the clipping object cuts the art instead of clipping it with a path, so the + // instances in it are written as their art. Its square is unpainted, so the only filled squares + // are the leaves'. + let outline = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default()); + let clip = group(&mut doc, vec![outline], false); + let clipped = group(&mut doc, vec![clip, tree], true); + doc.insert(Some(layer), usize::MAX, clipped).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_LIMIT), "{:?}", out.warnings); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + // 5,001 objects for the tree and each leaf: 3,198 leaves fit in 16,000,000. + let text = String::from_utf8(out.program).unwrap(); + let layers = text.split_once("%%EndSetup").unwrap().1; + assert_eq!(layers.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); +} + #[test] fn a_stained_symbol_instance_is_written_in_its_stained_colors() { let mut doc = Document::new(200.0, 100.0); From f74c7e8056a48bb950b19f927984f00462e09f24 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:28:12 +0700 Subject: [PATCH 12/93] Path ops: region building lists the shapes covering each region instead of a flag per shape Squashed from #6144 onto main. --- crates/pathops/src/boolean.rs | 26 ++++++--- crates/pathops/src/pathfinder.rs | 94 +++++++++++++++++++++++--------- docs/target-app-parity.md | 1 + 3 files changed, 88 insertions(+), 33 deletions(-) diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 3a9aaaec8..2201894b4 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -5,7 +5,8 @@ //! [`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::collections::BTreeSet; +use std::cmp::Reverse; +use std::collections::HashSet; use kurbo::{BezPath, CubicBez, PathSeg, Point, Shape as _}; use linesweeper::topology::{ContourIdx, Contours, Topology, WindingNumber}; @@ -433,19 +434,28 @@ impl Arrangement { std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.contours(pred))).map_err(|_| PathOpsError::Degenerate) } - /// All distinct non-empty coverage masks that occur next to some edge, sorted. - pub(crate) fn distinct_masks(&self) -> Vec> { - let mut set = BTreeSet::new(); + /// All distinct non-empty coverages that occur next to some edge, each as the inputs it covers + /// (ascending, as [`Self::covered`] gives them), in the order of their masks ([`Self::mask`]) + /// sorted. + pub(crate) fn distinct_masks(&self) -> Vec> { + let mut set: HashSet> = HashSet::new(); + let mut cover = Vec::new(); for s in self.top.segment_indices() { let h = s.first_half(); for w in [self.top.winding_clockwise(h), self.top.winding_counter_clockwise(h)] { - let m = self.mask(w); - if m.iter().any(|&b| b) { - set.insert(m); + cover.clear(); + cover.extend(self.covered(w)); + if !cover.is_empty() && !set.contains(cover.as_slice()) { + set.insert(cover.clone()); } } } - set.into_iter().collect() + let mut masks: Vec> = set.into_iter().collect(); + // Masks sort by the first input only one of them covers: the one without it comes first. + // That input is the smaller of the first two entries that differ, or the next entry of the + // longer list where the shorter one ends. + masks.sort_unstable_by(|a, b| a.iter().map(Reverse).cmp(b.iter().map(Reverse))); + masks } /// Vertices where three or more edges meet (true intersections / touch points). diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index d6d85c9ad..ee9db37f2 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -235,7 +235,7 @@ fn regions_of(arr: &Arrangement) -> Vec { /// without building the faces after it. fn regions_of_within(arr: &Arrangement, limit: usize) -> Option> { let masks = arr.distinct_masks(); - let refs: Vec<&[bool]> = masks.iter().map(Vec::as_slice).collect(); + let refs: Vec<&[usize]> = masks.iter().map(Vec::as_slice).collect(); let mut faces: Vec> = vec![Vec::new(); masks.len()]; let mut found = 0usize; let walk = each_face_until(arr, &refs, |m, c, group| { @@ -258,9 +258,8 @@ fn regions_of_within(arr: &Arrangement, limit: usize) -> Option> { return None; } let mut out = Vec::new(); - for (mask, paths) in masks.iter().zip(faces) { - let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); - out.extend(paths.into_iter().map(|path| Region { path, sources: sources.clone() })); + for (sources, paths) in masks.iter().zip(faces) { + out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() })); } Some(out) } @@ -361,17 +360,18 @@ fn each_classified_face_until( ControlFlow::Continue(()) } -/// Calls `f` with each face of `arr` whose coverage is one of `masks` (distinct): the index of -/// its mask in `masks`, the contour pass it is in, and its contours, an outer contour and the -/// holes of its mask directly inside it ([`face_groups`]). Each mask's faces come in the order -/// of that mask's own contour pass. +/// Calls `f` with each face of `arr` whose coverage is one of `masks` (distinct, each the inputs +/// it covers, ascending as [`Arrangement::covered`] gives them): the index of its mask in +/// `masks`, the contour pass it is in, and its contours, an outer contour and the holes of its +/// mask directly inside it ([`face_groups`]). Each mask's faces come in the order of that mask's +/// own contour pass. /// /// A contour pass walks the whole arrangement, so one pass per mask would cost masks × edges /// (two blends crossing make thousands of each). Instead the masks go into batches whose faces /// share no vertex (so no edge either): one pass per batch walks each face's contours exactly as /// its own mask's pass would, and the mask a contour bounds is the one of its batch at its first /// point. -fn each_face(arr: &Arrangement, masks: &[&[bool]], mut f: impl FnMut(usize, &Contours, &[ContourIdx])) { +fn each_face(arr: &Arrangement, masks: &[&[usize]], mut f: impl FnMut(usize, &Contours, &[ContourIdx])) { let _ = each_face_until(arr, masks, |m, c, group| { f(m, c, group); ControlFlow::Continue(()) @@ -379,12 +379,12 @@ fn each_face(arr: &Arrangement, masks: &[&[bool]], mut f: impl FnMut(usize, &Con } /// [`each_face`] until `f` breaks: no face after that one is walked. -fn each_face_until(arr: &Arrangement, masks: &[&[bool]], f: impl FnMut(usize, &Contours, &[ContourIdx]) -> ControlFlow<()>) -> ControlFlow<()> { +fn each_face_until(arr: &Arrangement, masks: &[&[usize]], f: impl FnMut(usize, &Contours, &[ContourIdx]) -> ControlFlow<()>) -> ControlFlow<()> { if masks.is_empty() { return ControlFlow::Continue(()); } // Each mask is found by the inputs it covers. - let ids = CoverIds::new(masks.iter().map(|m| m.iter().enumerate().filter(|(_, b)| **b).map(|(k, _)| k).collect()).collect()); + let ids = CoverIds::new(masks.iter().map(|m| m.to_vec()).collect()); each_classified_face_until(arr, masks.len(), |w| ids.of(arr, w), f) } @@ -635,19 +635,21 @@ impl FaceMerger { /// The contours of the faces of `arr` that are `faces`, each found by its coverage and a point /// inside it. fn contours_of(&self, arr: &Arrangement, faces: &[&Region]) -> Option { - let mut picks: Vec<(Vec, Point)> = Vec::with_capacity(faces.len()); + let mut picks: Vec<(Vec, Point)> = Vec::with_capacity(faces.len()); for r in faces { - let mut mask = vec![false; self.shapes]; - for &s in &r.sources { - *mask.get_mut(s)? = true; + if r.sources.iter().any(|&s| s >= self.shapes) { + return None; } + let mut mask = r.sources.clone(); + mask.sort_unstable(); + mask.dedup(); picks.push((mask, crate::planar::interior_point(&r.path)?)); } // `masks` holds the coverages in the order they were first picked, `points` the picked // points of each, and `found` the faces of each that contain one of its points. - let mut masks: Vec<&[bool]> = vec![]; + let mut masks: Vec<&[usize]> = vec![]; let mut points: Vec> = vec![]; - let mut index: HashMap<&[bool], usize> = HashMap::new(); + let mut index: HashMap<&[usize], usize> = HashMap::new(); for (mask, p) in &picks { let m = *index.entry(mask.as_slice()).or_insert_with(|| { masks.push(mask); @@ -720,17 +722,14 @@ fn add_slivers(arr: &Arrangement, faces: &[&Region], rest: Option<&Rest>, raw: & let ours = |w: &Multi| rest.is_none_or(|r| arr.covered(w).any(|i| i == r.shape)); // The coverage of each face next to a picked one, if only shapes of `faces` cover it, and the // ends of the edges along faces of `rest` that would go to their merge. - let mut next_to: Vec> = Vec::new(); + let mut next_to: Vec> = Vec::new(); let mut taken: HashSet<(u64, u64)> = HashSet::new(); for s in top.segment_indices() { let h = s.first_half(); let (a, b) = (top.winding_clockwise(h), top.winding_counter_clockwise(h)); for (w, other) in [(a, b), (b, a)] { - if within(w, &cover) && ours(w) && !is_picked(w) && is_picked(other) { - let mask = arr.mask(w); - if !next_to.contains(&mask) { - next_to.push(mask); - } + if within(w, &cover) && ours(w) && !is_picked(w) && is_picked(other) && !next_to.iter().any(|m| arr.covered(w).eq(m.iter().copied())) { + next_to.push(arr.covered(w).collect()); } if let Some(r) = rest && within(w, &merged_cover) @@ -744,7 +743,7 @@ fn add_slivers(arr: &Arrangement, faces: &[&Region], rest: Option<&Rest>, raw: & } } let along_merged = |path: &BezPath| path.elements().iter().any(|el| el.end_point().is_some_and(|p| taken.contains(&vertex_key(p)))); - let masks: Vec<&[bool]> = next_to.iter().map(Vec::as_slice).collect(); + let masks: Vec<&[usize]> = next_to.iter().map(Vec::as_slice).collect(); let mut found: Vec> = vec![Vec::new(); masks.len()]; each_face(arr, &masks, |m, c, group| { if contours_to_path(c, group.iter().copied(), &arr.tidy).is_empty() @@ -1079,7 +1078,7 @@ mod tests { /// face lies inside a hole of its own mask (`grouped` puts such an island in the frame's face). fn regions_one_mask_at_a_time(arr: &Arrangement) -> Vec { let mut out = Vec::new(); - for mask in arr.distinct_masks() { + for mask in flag_masks(arr) { let c = arr.contours(|m| m == mask.as_slice()); let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); for g in c.grouped() { @@ -1092,8 +1091,27 @@ mod tests { out } + /// The distinct non-empty coverages next to some edge as a flag per input, sorted. + fn flag_masks(arr: &Arrangement) -> Vec> { + let mut set = std::collections::BTreeSet::new(); + for s in arr.top.segment_indices() { + let h = s.first_half(); + for w in [arr.top.winding_clockwise(h), arr.top.winding_counter_clockwise(h)] { + let m = arr.mask(w); + if m.iter().any(|&b| b) { + set.insert(m); + } + } + } + set.into_iter().collect() + } + + /// [`Arrangement::distinct_masks`] lists the inputs of each of [`flag_masks`] in its order, and + /// [`regions_of`] finds the faces one contour pass per flag mask finds, in the same order. fn same_as_one_mask_at_a_time(shapes: &[Shape]) { let arr = arrangement(shapes, DEFAULT_PRECISION).expect("arrangement"); + let listed: Vec> = flag_masks(&arr).iter().map(|m| m.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect()).collect(); + assert_eq!(arr.distinct_masks(), listed); assert_eq!(regions_of(&arr), regions_one_mask_at_a_time(&arr)); } @@ -1210,6 +1228,32 @@ mod tests { assert_eq!(regions(&v).len(), 12 * 12 + 2 * 12 * 13); } + /// The coverages come in the order of their flag masks, with the same regions, for shapes that + /// overlap, nest, touch or lie apart, and for more than 64 shapes stacked out of their order + /// across the page. + #[test] + fn coverages_keep_the_order_of_their_flag_masks() { + let r = |x0: f64, y0: f64, x1: f64, y1: f64| Shape::new(rect(x0, y0, x1, y1), FillRule::NonZero, 1); + let overlapping = vec![r(0.0, 0.0, 100.0, 100.0), r(50.0, 50.0, 150.0, 150.0), r(25.0, 75.0, 125.0, 175.0)]; + let nested = vec![r(0.0, 0.0, 300.0, 300.0), r(50.0, 50.0, 250.0, 250.0), r(100.0, 100.0, 200.0, 200.0), r(140.0, 140.0, 160.0, 160.0)]; + let touching = vec![r(0.0, 0.0, 100.0, 100.0), r(100.0, 0.0, 200.0, 100.0), r(0.0, 100.0, 100.0, 200.0), r(100.0, 100.0, 200.0, 200.0)]; + let apart = vec![r(0.0, 0.0, 10.0, 10.0), r(50.0, 0.0, 60.0, 10.0), r(0.0, 50.0, 10.0, 60.0)]; + // 70 strips, each overlapping its neighbors, stacked in an order other than across the + // page, and two bars across them. + let mut many: Vec = (0..70) + .map(|i| { + let x = 10.0 * f64::from((i * 37) % 70); + r(x, 0.0, x + 15.0, 300.0) + }) + .collect(); + many.insert(20, r(-10.0, 100.0, 730.0, 110.0)); + many.push(r(-10.0, 200.0, 730.0, 205.0)); + for (what, shapes) in [("overlapping", overlapping), ("nested", nested), ("touching", touching), ("apart", apart), ("many", many)] { + assert!(!regions(&shapes).is_empty(), "{what}"); + same_as_one_mask_at_a_time(&shapes); + } + } + proptest! { #![proptest_config(ProptestConfig { cases: 256, failure_persistence: None, rng_seed: proptest::test_runner::RngSeed::Fixed(0x5eed_9a7f), ..ProptestConfig::default() })] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 615976277..72ef93f84 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1402,6 +1402,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. 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). 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). From 657a99b6291847e8325b962434a8e80b23584d70 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:28:25 +0700 Subject: [PATCH 13/93] =?UTF-8?q?Actions:=20Load=20Actions=E2=80=A6=20read?= =?UTF-8?q?s=20action=20sets=20saved=20as=20.aia=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from #6149 onto main. --- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/actionset.rs | 560 +++++++++++++++++++++ crates/engine/src/cmd/scripts.rs | 2 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/actionset.rs | 184 +++++++ crates/engine/tests/import_fuzz.rs | 89 +++- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tests.rs | 3 +- crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/actions.rs | 185 +++++++ crates/ui-egui/src/panels/actions/files.rs | 160 +++++- crates/ui-egui/src/shortcut_editor.rs | 11 + crates/ui-egui/src/tests/automation.rs | 22 + docs/mcp.md | 39 +- docs/target-app-parity.md | 1 + docs/ui-parity.md | 3 +- 42 files changed, 1337 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/cmd/actionset.rs create mode 100644 crates/engine/src/tests/actionset.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c88f47c79..a7194fb2e 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -1,5 +1,6 @@ //! The command registry. Ids follow Illustrator's menu structure. +pub mod actionset; mod addons; pub(crate) mod appearance; mod assets; @@ -344,6 +345,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(halftone::specs()); v.extend(perspgrid::specs()); v.extend(shortcutset::specs()); + v.extend(actionset::specs()); v.extend(importmatches::specs()); v.extend(addons::specs()); v diff --git a/crates/engine/src/cmd/actionset.rs b/crates/engine/src/cmd/actionset.rs new file mode 100644 index 000000000..b6a911838 --- /dev/null +++ b/crates/engine/src/cmd/actionset.rs @@ -0,0 +1,560 @@ +//! Action sets another app saves as text (`.aia` files that start `/version`), read into their +//! actions and steps without changing anything (`actionSet.read`). +//! +//! The text is a set of fields and blocks. A slash name followed by a value sets a field, and a +//! slash name followed by `{` opens a block that `}` closes. A value is a decimal number, text in +//! parentheses, a byte count and hexadecimal bytes in square brackets (`[ 5 48656c6c6f ]`, UTF-8 +//! text, which can run over several words), or a byte count and hexadecimal bytes in angle brackets +//! (`< 3 0a0b0c >`, raw bytes). The set's `/version` comes first, and its `/name` is hexadecimal +//! text. Each action is an `/action-N` block with a hexadecimal `/name`; each step is an `/event-N` +//! block inside one, with its `/internalName` in parentheses, the `/localizedName` it is shown with +//! as hexadecimal text, and `/isOn` 0 when it is left out of playback; each value a step was +//! recorded with is a `/parameter-N` block inside it, which is counted, and whose hexadecimal +//! `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount` +//! and a step's `/parameterCount` give how many actions, steps and recorded values follow. +//! The layout follows the README of Inventsable's Redesigning-Illustrator-Actions-in-Scripting +//! (https://github.com/Inventsable/Redesigning-Illustrator-Actions-in-Scripting), the forum post +//! "Made a two-way aia to json converter" +//! (https://community.adobe.com/t5/illustrator-discussions/made-a-two-way-aia-to-json-converter/m-p/13751909), +//! sttk3's article on names as UTF-8 hexadecimal +//! (https://sttk3.com/blog/tips/illustrator/dynamic-generate-action.html), and the MIT-licensed +//! reader in zachshallbetter/adobe-mcp (https://github.com/zachshallbetter/adobe-mcp). The raw form +//! follows the forum thread "Creating a dynamic action" +//! (https://community.adobe.com/questions-652/creating-a-dynamic-action-to-use-with-app-doscript-method-814406). + +use serde_json::{Value, json}; + +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, text_param}; + +/// The most bytes of a file read. +const MAX_BYTES: usize = 8 << 20; +/// The most bytes taken in: [`MAX_BYTES`] after a byte order mark, and one more that shows the text +/// goes on. +const READ_LIMIT: usize = MAX_BYTES + 4; +/// The most blocks open at once. +const MAX_DEPTH: usize = 16; +/// The most actions read. +const MAX_ACTIONS: usize = 1000; +/// The most steps read in one action. +const MAX_STEPS: usize = 10_000; +/// The most steps read in the set: a small text can hold hundreds of thousands of empty steps, and +/// the answer holds an object for each. +const MAX_SET_STEPS: usize = 100_000; +/// The longest name kept (bytes). +const MAX_NAME: usize = 1024; +/// The most tokens listed as unread. +const MAX_UNREAD: usize = 1000; +/// The longest unread token shown (characters). +const UNREAD_SHOWN: usize = 80; + +/// The extensions of action sets other apps save. +pub const EXTS: &[&str] = &["aia"]; + +pub fn specs() -> Vec { + vec![cmd!( + query "actionSet.read", + "Read Action Set File", + [], + None, + "{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error", + always, + read + )] +} + +/// A step of an action: an `/event-N` block. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaStep { + /// Its `/internalName`. + pub name: String, + /// Its `/localizedName`, if it has one. + pub label: Option, + /// Its `/isOn`, if it has one. + pub on: Option, + /// How many `/parameter-N` blocks it has. + pub params: usize, + /// Its `/parameterCount`, if it has one. + pub declared: Option, + /// The line its block opens on (from 1). + pub line: usize, +} + +/// An action: an `/action-N` block. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaAction { + pub name: String, + pub steps: Vec, + /// Its `/eventCount`, if it has one. + pub declared: Option, +} + +/// An action set read from text. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaSet { + pub version: i64, + /// The set's `/name`, if it has one. + pub name: Option, + pub actions: Vec, + /// Its `/actionCount`, if it has one. + pub declared: Option, + /// The tokens that weren't read, with their lines. + pub unread: Vec<(usize, String)>, + /// A cap stopped the reading or cut a name, the text ends inside a block or a value or after a + /// field's name, its counts differ from the actions, steps and recorded values read (a negative + /// count, one too large for an `i64` and one that isn't a number always differ), or a hexadecimal + /// name, label or recorded `/value` isn't as many bytes as its count says. + pub truncated: bool, + /// How many steps were read in all the actions. + steps: usize, +} + +/// A token of the text. +enum Token { + /// A slash name. + Name(Vec), + /// An integer; `None` when it doesn't fit an `i64`. + Int(Option), + /// Text in parentheses, a backslash taking the next byte as it is. + Text(Vec), + /// The bytes of `[ count hex… ]`; `None` when the hexadecimal digits don't make whole bytes or + /// as many bytes as the count says. + Hex(Option>), + /// `< count hex… >`. + Raw, + Open, + Close, + /// Anything else, as written. + Other(Vec), +} + +/// The tokens of `text`, each with its line. +struct Tokens<'a> { + text: &'a [u8], + at: usize, + line: usize, + /// The text ended inside a value in parentheses, square brackets or angle brackets. + unclosed: bool, +} + +impl<'a> Tokens<'a> { + /// Past one byte, counting a line at LF, at CR alone, and once for CRLF. + fn step(&mut self) -> Option { + let b = *self.text.get(self.at)?; + if b == b'\n' || b == b'\r' && self.text.get(self.at + 1) != Some(&b'\n') { + self.line += 1; + } + self.at += 1; + Some(b) + } + + /// The bytes up to `end` (not taken in); `None` when the text ends first. + fn until(&mut self, end: u8) -> Option<&'a [u8]> { + let (text, start) = (self.text, self.at); + while text.get(self.at).is_some_and(|&b| b != end) { + self.step(); + } + let inside = text.get(start..self.at)?; + text.get(self.at)?; + self.at += 1; + Some(inside) + } + + fn next_token(&mut self) -> Option<(usize, Token)> { + while self.text.get(self.at).is_some_and(|&b| b.is_ascii_whitespace() || b == 0) { + self.step(); + } + let line = self.line; + let b = *self.text.get(self.at)?; + let ends = |b: u8| b.is_ascii_whitespace() || b == 0 || b"{}[]()<>/".contains(&b); + let token = match b { + b'{' | b'}' => { + self.at += 1; + if b == b'{' { Token::Open } else { Token::Close } + } + b'(' => { + self.at += 1; + let (mut text, mut depth) = (vec![], 0usize); + self.unclosed = true; + while let Some(c) = self.step() { + match c { + b'\\' => text.extend(self.step()), + b'(' => { + depth += 1; + text.push(c); + } + b')' if depth == 0 => { + self.unclosed = false; + break; + } + b')' => { + depth -= 1; + text.push(c); + } + _ => text.push(c), + } + } + Token::Text(text) + } + b'[' | b'<' => { + self.at += 1; + let close = if b == b'[' { b']' } else { b'>' }; + let inside = self.until(close); + self.unclosed = inside.is_none(); + let inside = inside.unwrap_or_default().to_vec(); + if b == b'<' { + Token::Raw + } else { + // The first word is the byte count; the bytes follow in one or more words. + let mut words = inside.split(|c| c.is_ascii_whitespace()).filter(|w| !w.is_empty()); + let count = words.next().and_then(|w| std::str::from_utf8(w).ok()).and_then(|w| w.parse::().ok()); + let digits: Vec = words.flatten().copied().collect(); + Token::Hex(hex_bytes(&digits).filter(|b| Some(b.len()) == count)) + } + } + b'/' => { + self.at += 1; + let start = self.at; + while self.text.get(self.at).is_some_and(|&c| !ends(c)) { + self.at += 1; + } + Token::Name(self.text.get(start..self.at).unwrap_or_default().to_vec()) + } + _ => { + let start = self.at; + while self.text.get(self.at).is_some_and(|&c| !ends(c)) { + self.at += 1; + } + // Always past at least one byte. + self.at = self.at.max(start + 1); + let word = self.text.get(start..self.at).unwrap_or_default(); + let number = word.strip_prefix(b"-").or_else(|| word.strip_prefix(b"+")).unwrap_or(word); + if !number.is_empty() && number.iter().all(u8::is_ascii_digit) { + Token::Int(std::str::from_utf8(word).ok().and_then(|s| s.parse().ok())) + } else { + Token::Other(word.to_vec()) + } + } + }; + Some((line, token)) + } +} + +/// The bytes hexadecimal `digits` stand for, two digits a byte. +fn hex_bytes(digits: &[u8]) -> Option> { + let nibble = |c: u8| (c as char).to_digit(16).and_then(|d| u8::try_from(d).ok()); + if !digits.len().is_multiple_of(2) { + return None; + } + digits.chunks(2).map(|pair| Some((nibble(*pair.first()?)? << 4) | nibble(*pair.get(1)?)?)).collect() +} + +/// What a block is. +enum Kind { + Action, + Step, + Param, + /// A block that isn't read, or one past a cap. + Other, +} + +/// `name` ends with `-` and a number after `prefix`, as `/action-1` does. +fn numbered(name: &[u8], prefix: &[u8]) -> bool { + name.strip_prefix(prefix).is_some_and(|n| !n.is_empty() && n.iter().all(u8::is_ascii_digit)) +} + +/// A name as text: at most [`MAX_NAME`] bytes, read as UTF-8 with replacement characters. +fn name_text(name: &[u8], set: &mut AiaSet) -> String { + if name.len() > MAX_NAME { + set.truncated = true; + } + let mut s = String::from_utf8_lossy(name.get(..MAX_NAME).unwrap_or(name)).into_owned(); + // A character cut at the limit reads as one replacement character. + if name.len() > MAX_NAME && s.ends_with('\u{FFFD}') { + s.pop(); + } + s +} + +/// How the unread list shows a value in square brackets that isn't as many bytes as its count says. +const BAD_HEX: &str = "[ … ] (not the hexadecimal bytes its count says)"; + +/// List a token that isn't read. +fn unread(set: &mut AiaSet, line: usize, text: impl AsRef<[u8]>) { + if set.unread.len() < MAX_UNREAD { + let text: String = String::from_utf8_lossy(text.as_ref()).chars().take(UNREAD_SHOWN).collect(); + set.unread.push((line, text)); + } +} + +/// What the block `name` that opens on `line` inside the blocks `stack` is: a new action, step or +/// recorded value of `set`, or a block that isn't read (listed when it is out of place). +fn open_block(set: &mut AiaSet, stack: &[Kind], name: &[u8], line: usize) -> Kind { + match stack.last() { + _ if stack.len() >= MAX_DEPTH => { + set.truncated = true; + Kind::Other + } + None if numbered(name, b"action-") => { + if set.actions.len() < MAX_ACTIONS { + set.actions.push(AiaAction::default()); + Kind::Action + } else { + set.truncated = true; + Kind::Other + } + } + Some(Kind::Action) if numbered(name, b"event-") => match set.actions.last_mut() { + Some(a) if a.steps.len() < MAX_STEPS && set.steps < MAX_SET_STEPS => { + a.steps.push(AiaStep { line, ..AiaStep::default() }); + set.steps += 1; + Kind::Step + } + _ => { + set.truncated = true; + Kind::Other + } + }, + Some(Kind::Step) if numbered(name, b"parameter-") => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.params += 1; + } + Kind::Param + } + Some(Kind::Param | Kind::Other) => Kind::Other, + _ => { + unread(set, line, format!("/{} {{", String::from_utf8_lossy(name))); + Kind::Other + } + } +} + +/// Set the field `name` of the block `kind` (the set when `None`) to the hexadecimal text `bytes` +/// on `line`: the set's or an action's `/name`, or a step's `/localizedName`. +fn set_hex(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, bytes: Option>, line: usize) { + let Some(bytes) = bytes else { + set.truncated = true; + unread(set, line, BAD_HEX); + return; + }; + match (kind, name) { + (None, Some(b"name")) => set.name = Some(name_text(&bytes, set)), + (Some(Kind::Action), Some(b"name")) => { + let text = name_text(&bytes, set); + if let Some(a) = set.actions.last_mut() { + a.name = text; + } + } + (Some(Kind::Step), Some(b"localizedName")) => { + let text = name_text(&bytes, set); + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.label = Some(text); + } + } + (_, Some(_)) => {} + (_, None) => unread(set, line, "[ … ]"), + } +} + +/// Mark `set` truncated when the hexadecimal `/value` on `line` isn't as many bytes as its count +/// says. +fn check_value(set: &mut AiaSet, bytes: Option>, line: usize) { + if bytes.is_none() { + set.truncated = true; + unread(set, line, BAD_HEX); + } +} + +/// Mark `set` truncated when the field `name` of the block `kind` (the set when `None`) is a count +/// (the set's `/actionCount`, an action's `/eventCount` or a step's `/parameterCount`) whose value is +/// not a number that fits an `i64`. +fn bad_count(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>) { + if matches!((kind, name), (None, Some(b"actionCount")) | (Some(Kind::Action), Some(b"eventCount")) | (Some(Kind::Step), Some(b"parameterCount"))) + { + set.truncated = true; + } +} + +/// Set the field `name` of the block `kind` (the set when `None`) to the integer `value` on `line`: +/// the set's `/actionCount`, an action's `/eventCount`, a step's `/parameterCount`, or a step's +/// `/isOn`. A count too large for an `i64` marks the set truncated. +fn set_int(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, value: Option, line: usize) { + if value.is_none() { + bad_count(set, kind, name); + } + match (kind, name) { + (None, Some(b"actionCount")) => set.declared = value, + (Some(Kind::Action), Some(b"eventCount")) => { + if let Some(a) = set.actions.last_mut() { + a.declared = value; + } + } + (Some(Kind::Step), Some(b"parameterCount")) => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.declared = value; + } + } + (Some(Kind::Step), Some(b"isOn")) => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.on = value; + } + } + (_, Some(_)) => {} + (_, None) => unread(set, line, value.map_or_else(|| "(a number too large)".to_string(), |v| v.to_string())), + } +} + +/// Read an action set saved as text. An error when the text isn't one: it doesn't start with +/// `/version` and an integer. +pub fn read_aia(bytes: &[u8]) -> std::result::Result { + let mut set = AiaSet::default(); + let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + let text = match text.get(..MAX_BYTES) { + Some(head) if text.len() > MAX_BYTES => { + set.truncated = true; + head + } + _ => text, + }; + let mut tokens = Tokens { text, at: 0, line: 1, unclosed: false }; + match (tokens.next_token(), tokens.next_token()) { + (Some((_, Token::Name(n))), Some((_, Token::Int(Some(v))))) if n == b"version" => set.version = v, + _ => return Err("not an action set: the text doesn't start with /version and a number".into()), + } + let mut stack: Vec = vec![]; + // A slash name waiting for its value or its `{`: (line, name). + let mut pending: Option<(usize, Vec)> = None; + while let Some((line, token)) = tokens.next_token() { + let in_step = matches!(stack.last(), Some(Kind::Step)); + let field = |pending: &mut Option<(usize, Vec)>| pending.take().map(|(_, n)| n); + match token { + Token::Name(name) => { + if let Some((l, n)) = pending.replace((line, name)) { + bad_count(&mut set, stack.last(), Some(&n)); + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + } + Token::Open => { + let (l, name) = pending.take().unwrap_or((line, vec![])); + bad_count(&mut set, stack.last(), Some(&name)); + let kind = open_block(&mut set, &stack, &name, l); + stack.push(kind); + } + Token::Close => { + if let Some((l, n)) = pending.take() { + bad_count(&mut set, stack.last(), Some(&n)); + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + if stack.pop().is_none() { + unread(&mut set, line, "}"); + } + } + Token::Hex(bytes) if matches!(stack.last(), Some(Kind::Param)) => { + if field(&mut pending).as_deref() == Some(b"value".as_slice()) { + check_value(&mut set, bytes, line); + } + } + // The other values inside a recorded value, or a block that isn't read. + _ if matches!(stack.last(), Some(Kind::Param | Kind::Other)) => { + pending = None; + } + Token::Hex(bytes) => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + set_hex(&mut set, stack.last(), name.as_deref(), bytes, line); + } + Token::Text(t) => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + match name { + Some(n) if in_step && n == b"internalName" => { + let name = name_text(&t, &mut set); + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.name = name; + } + } + Some(_) => {} + None => unread(&mut set, line, [b"(".as_slice(), &t, b")"].concat()), + } + } + Token::Int(v) => { + let name = field(&mut pending); + set_int(&mut set, stack.last(), name.as_deref(), v, line); + } + Token::Raw => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + if name.is_none() { + unread(&mut set, line, "< … >"); + } + } + Token::Other(word) => { + // A real number, or another word as a field's value. + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + if name.is_none() { + unread(&mut set, line, word); + } + } + } + } + // The text ends after a field's name, before its value. + if let Some((l, n)) = pending { + set.truncated = true; + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + // An unclosed value is the last token: it takes the rest of the text. + if tokens.unclosed { + set.truncated = true; + unread(&mut set, tokens.line, "a value not closed at the end"); + } + if !stack.is_empty() { + set.truncated = true; + unread(&mut set, tokens.line, format!("blocks left open at the end: {}", stack.len())); + } + // Not as many actions, steps or recorded values as the counts give; a negative count never matches. + let differs = |declared: Option, read: usize| declared.is_some_and(|d| !usize::try_from(d).is_ok_and(|d| d == read)); + let step_differs = |a: &AiaAction| a.steps.iter().any(|s| differs(s.declared, s.params)); + if differs(set.declared, set.actions.len()) || set.actions.iter().any(|a| differs(a.declared, a.steps.len()) || step_differs(a)) { + set.truncated = true; + unread(&mut set, tokens.line, "not as many actions, steps or recorded values as the counts give"); + } + Ok(set) +} + +fn read(_: &mut Session, p: &Value) -> Result { + const C: &str = "actionSet.read"; + let (path, data, b64) = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?); + let name = text_param(p, "name", C)?; + let bytes: std::borrow::Cow<[u8]> = match (path, data, b64) { + (Some(path), None, None) => super::fileio::read_file_head(path, READ_LIMIT)?.into(), + (None, Some(text), None) => text.as_bytes().get(..READ_LIMIT).unwrap_or(text.as_bytes()).into(), + (None, None, Some(b64)) => super::scripts::decode_head(b64, READ_LIMIT).ok_or_else(|| bad(C, "bad dataBase64"))?.into(), + _ => return Err(bad(C, "give exactly one of `path`, `data` and `dataBase64`")), + }; + let set = read_aia(&bytes).map_err(EngineError::Other)?; + let file = name.or(path).unwrap_or_default(); + let stem = std::path::Path::new(file).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let name = set.name.clone().filter(|n| !n.trim().is_empty()).unwrap_or(stem); + let actions: Vec = set + .actions + .iter() + .map(|a| { + let steps: Vec = a + .steps + .iter() + .map(|s| { + let label = s.label.clone().filter(|l| !l.trim().is_empty()); + json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "line": s.line }) + }) + .collect(); + json!({ "name": a.name, "steps": steps }) + }) + .collect(); + Ok(json!({ + "set": name, + "version": set.version, + "actions": actions, + "unread": set.unread.iter().map(|(line, text)| json!({ "line": line, "text": text })).collect::>(), + "truncated": set.truncated, + })) +} diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index d175b4469..45f267048 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -62,7 +62,7 @@ impl Loader for Folder { /// The bytes the base64 text `b64` holds, up to `max` and a few more: only the text they take is /// decoded. -fn decode_head(b64: &str, max: usize) -> Option> { +pub(super) fn decode_head(b64: &str, max: usize) -> Option> { let chars = max.div_ceil(3).saturating_mul(4); let mut kept = 0usize; let end = b64 diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b708c25ca..2eb70a449 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4,6 +4,7 @@ use super::*; use vectorcraft_geom::Point; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; +mod actionset; pub(super) mod adjust; mod ai_open_path; mod appearance; diff --git a/crates/engine/src/tests/actionset.rs b/crates/engine/src/tests/actionset.rs new file mode 100644 index 000000000..d80e649ec --- /dev/null +++ b/crates/engine/src/tests/actionset.rs @@ -0,0 +1,184 @@ +//! Action sets another app saved as text (`actionSet.read`): actions, steps, the tokens left unread +//! and the caps. Every set here is written for the test, with invented names. + +use serde_json::json; + +use crate::cmd::actionset::{AiaStep, read_aia}; +use crate::*; + +/// `text` as a bracketed hexadecimal value. +fn hex(text: &str) -> String { + let digits: String = text.bytes().map(|b| format!("{b:02x}")).collect(); + format!("[ {} {digits} ]", text.len()) +} + +/// A set written from scratch in the layout the module describes: two actions, the second with a +/// step left out of playback, a step with recorded values of each form, and names in several +/// scripts. +fn sample() -> String { + format!( + "/version 3\n/name {set}\n/isOpen 1\n/actionCount 2\n/action-1 {{\n\t/name {a1}\n\t/keyIndex 0\n\t/eventCount 1\n\t/event-1 {{\n\t\t/internalName (sample_join)\n\t\t/localizedName {join}\n\t\t/isOn 1\n\t\t/parameterCount 3\n\t\t/parameter-1 {{\n\t\t\t/key 1\n\t\t\t/type (unit real)\n\t\t\t/value 2.5\n\t\t}}\n\t\t/parameter-2 {{\n\t\t\t/type (ustring)\n\t\t\t/value {txt}\n\t\t}}\n\t\t/parameter-3 {{\n\t\t\t/type (raw)\n\t\t\t/value < 3\n\t\t\t\t0a7d0b\n\t\t\t>\n\t\t\t/size 3\n\t\t}}\n\t}}\n}}\r\n/action-2 {{\r\n\t/name {a2}\r\n\t/event-1 {{\r\n\t\t/internalName (sample\\)one)\r\n\t\t/isOn 0\r\n\t\t/parameterCount 0\r\n\t}}\r\n\t/event-2 {{\r\n\t\t/internalName (sample_two)\r\n\t\t/localizedName {two}\r\n\t}}\r\n}}\r\n", + set = hex("Prep Sätze"), + a1 = hex("First"), + join = hex("Join"), + txt = hex("a } brace"), + a2 = hex("二番目"), + two = hex("Second Step"), + ) +} + +/// The set's name, its actions and their steps come in with the step's names, its on state and how +/// many values it was recorded with, and nothing is listed unread. +#[test] +fn a_set_reads_its_actions_and_steps() { + let set = read_aia(sample().as_bytes()).unwrap(); + assert_eq!((set.version, set.name.as_deref(), set.truncated), (3, Some("Prep Sätze"), false)); + assert!(set.unread.is_empty(), "{:?}", set.unread); + let names: Vec<&str> = set.actions.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["First", "二番目"]); + let step = |a: usize, s: usize| set.actions[a].steps[s].clone(); + let join = AiaStep { name: "sample_join".into(), label: Some("Join".into()), on: Some(1), params: 3, declared: Some(3), line: 9 }; + assert_eq!(step(0, 0), join); + assert_eq!((step(1, 0).name.as_str(), step(1, 0).label.clone(), step(1, 0).on), ("sample)one", None, Some(0))); + assert_eq!((step(1, 1).label.as_deref(), step(1, 1).on, step(1, 1).line), (Some("Second Step"), None, 39)); +} + +/// Every recorded value of a step is counted. A hexadecimal `/value` with the wrong byte count marks +/// the set truncated; a long one or one that isn't UTF-8 does not. +#[test] +fn recorded_values_are_counted_and_checked() { + let set = |values: &[String]| { + let params: String = values.iter().enumerate().map(|(i, v)| format!("/parameter-{} {{ /key 1 {v} }} ", i + 1)).collect(); + read_aia(format!("/version 3 /action-1 {{ /event-1 {{ {params} }} }}").as_bytes()).unwrap() + }; + let value = |t: &str| format!("/type (ustring) /value {}", hex(t)); + let read = set(&[value("one"), value("two"), value("three")]); + assert_eq!((read.actions[0].steps[0].params, read.truncated), (3, false)); + let read = set(&[value(&"n".repeat(2000)), "/value [ 1 ff ]".to_string(), "/type (real) /value 4.5".to_string()]); + assert!(!read.truncated, "a long value or one that isn't UTF-8 is not damage"); + assert!(set(&["/value [ 3 4142 ]".to_string()]).truncated); + assert!(set(&[value("one"), "/value [ 1 zz ]".to_string()]).truncated, "a second value that isn't hexadecimal"); +} + +/// `actionSet.read` takes exactly one of `path`, `data` and `dataBase64`, and text for the text +/// params; any other value is an error. +#[test] +fn read_command_refuses_bad_params() { + let mut s = Session::new(); + let text = "/version 3"; + let dir = std::env::temp_dir().join(format!("vc-actionset-params-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Mine.aia"); + std::fs::write(&file, text).unwrap(); + let path = file.to_string_lossy(); + assert!(s.execute("actionSet.read", &json!({ "path": path })).is_ok()); + let b64 = vectorcraft_format::base64_encode(text.as_bytes()); + for p in [ + json!({ "path": 5, "data": text }), + json!({ "data": 5 }), + json!({ "dataBase64": 5 }), + json!({ "data": text, "name": 7 }), + json!({ "path": path, "data": text }), + json!({ "path": path, "dataBase64": b64 }), + json!({ "data": text, "dataBase64": b64 }), + ] { + assert!(s.execute("actionSet.read", &p).is_err(), "{p}"); + } + let r = s.execute("actionSet.read", &json!({ "data": text, "name": null })).unwrap(); + assert_eq!(r["set"], "", "no set name and no file name"); + let _ = std::fs::remove_dir_all(dir); +} + +/// `actionSet.read` names the set after the file when it has no name, gives a step without a label, +/// or with a blank one, a null label, and refuses text that isn't an action set. +#[test] +fn read_command_answers_with_the_set() { + let mut s = Session::new(); + let text = sample().replacen(&format!("/name {}", hex("Prep Sätze")), "", 1); + let r = s.execute("actionSet.read", &json!({ "data": text, "name": "/sets/Mine.aia" })).unwrap(); + assert_eq!(r["set"], "Mine"); + assert_eq!(r["actions"][1]["steps"][0], json!({"name": "sample)one", "label": null, "on": false, "params": 0, "line": 34})); + assert_eq!(r["actions"][0]["steps"][0]["on"], true); + assert_eq!(r["actions"][0]["steps"][0]["label"], "Join"); + let blank = format!("/version 3 /action-1 {{ /event-1 {{ /internalName (x) /localizedName {} }} }}", hex(" ")); + assert_eq!(s.execute("actionSet.read", &json!({ "data": blank })).unwrap()["actions"][0]["steps"][0]["label"], json!(null)); + for text in ["/Menus { }", "", "version 3", "/version three"] { + assert!(s.execute("actionSet.read", &json!({ "data": text })).is_err(), "{text}"); + } + assert!(s.execute("actionSet.read", &json!({})).is_err()); +} + +/// `path` and `dataBase64` read the same set as `data`, and a set's name falls back to the path's. +#[test] +fn a_set_reads_from_a_path_and_from_base64() { + let dir = std::env::temp_dir().join(format!("vc-actionset-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Mine.aia"); + let text = "/version 3 /action-1 { /name [ 1 41 ] }"; + std::fs::write(&path, text).unwrap(); + let mut s = Session::new(); + let r = s.execute("actionSet.read", &json!({ "path": path.to_string_lossy() })).unwrap(); + assert_eq!((r["set"].as_str(), r["actions"][0]["name"].as_str()), (Some("Mine"), Some("A"))); + let b64 = vectorcraft_format::base64_encode(text.as_bytes()); + assert_eq!(s.execute("actionSet.read", &json!({ "dataBase64": b64, "name": "B.aia" })).unwrap()["set"], "B"); + let _ = std::fs::remove_dir_all(dir); +} + +/// Tokens out of place are listed with their lines, and a cap marks the set truncated. +#[test] +fn stray_tokens_are_listed_and_caps_truncate() { + let set = read_aia(b"/version 3\n7 /name [ 1 zz ]\n/name [ 3 4142 ]\n/odd { /x 1 }\n}\n/action-1 { /name [ 1 41 ]").unwrap(); + let unread: Vec<&str> = set.unread.iter().map(|(_, t)| t.as_str()).collect(); + let bad = "[ … ] (not the hexadecimal bytes its count says)"; + assert_eq!(unread, ["7", bad, bad, "/odd {", "}", "blocks left open at the end: 1"]); + let short = |text: &str| read_aia(text.as_bytes()).unwrap().truncated; + assert!(short("/version 3 /actionCount 1 /odd { /action-1 { } }"), "the action in a block that isn't read"); + assert!(short("/version 3 /name [ 3 4142 ]"), "a set's name with the wrong byte count"); + assert!(short("/version 3 /action-1 { /name [ 1 zz ] }"), "an action's name that isn't hexadecimal"); + assert!(short("/version 3 /action-1 { /event-1 { /localizedName [ 3 4142 ] } }"), "a label with the wrong byte count"); + let cuts = ["/name [ 4 4142", "/name (ab", "/value < 2 0a", "/name", "/action-2 {"]; + let whole: Vec<&str> = cuts.into_iter().filter(|cut| !short(&format!("/version 3 /action-1 {{ }} {cut}"))).collect(); + assert!(whole.is_empty(), "{whole:?}"); + assert_eq!(set.name, None); + assert_eq!(set.actions[0].name, "A"); + let many = format!("/version 3 {}", "/action-1 { } ".repeat(1001)); + assert!(read_aia(many.as_bytes()).unwrap().truncated); + let deep = format!("/version 3 /action-1 {{ /event-1 {{ {} }} }}", "/parameter-1 { ".repeat(40)); + assert!(read_aia(deep.as_bytes()).unwrap().truncated); + let long = format!("/version 3 /name {}", hex(&"n".repeat(2000))); + assert!(read_aia(long.as_bytes()).unwrap().truncated); +} + +/// A count that differs from the actions, steps or recorded values that follow, or that isn't a +/// number that fits an `i64`, marks the set truncated. A count is read only in its own block. +#[test] +fn counts_that_differ_truncate() { + let short = |text: &str| read_aia(text.as_bytes()).unwrap().truncated; + assert!(short("/version 3 /actionCount 2 /action-1 { }"), "one action of two"); + assert!(short("/version 3 /action-1 { /eventCount 2 /event-1 { } }"), "one step of two"); + assert!(!short("/version 3 /actionCount 1 /action-1 { /eventCount 1 /event-1 { } }")); + assert!(short("/version 3 /actionCount 1 /action-1 { } /action-2 { }"), "two actions of one"); + assert!(short("/version 3 /action-1 { /eventCount 1 /event-1 { } /event-2 { } }"), "two steps of one"); + assert!(short("/version 3 /action-1 { /event-1 { /parameterCount 1 } }"), "no recorded value of one"); + assert!(short("/version 3 /action-1 { /event-1 { /parameterCount 0 /parameter-1 { } } }"), "a recorded value of none"); + assert!(!short("/version 3 /action-1 { /event-1 { /parameterCount 1 /parameter-1 { } } }")); + assert!(short("/version 3 /actionCount -1"), "a negative count"); + assert!(short("/version 3 /action-1 { /eventCount -1 }"), "a negative step count"); + assert!(short("/version 3 /actionCount 99999999999999999999"), "a count too large for a number"); + for count in ["nope", "1.5", "(1)", "[ 1 31 ]", "< 1 31 >", "{ }"] { + assert!(short(&format!("/version 3 /actionCount {count}")), "a count that isn't a number: {count}"); + assert!(short(&format!("/version 3 /action-1 {{ /eventCount {count} }}")), "a step count: {count}"); + assert!(short(&format!("/version 3 /action-1 {{ /event-1 {{ /parameterCount {count} }} }}")), "{count}"); + } + assert!(short("/version 3 /actionCount /name [ 1 41 ]"), "a count followed by another field"); + assert!(short("/version 3 /action-1 { /eventCount }"), "a count at the end of its block"); + assert!(!short("/version 3 /action-1 { /event-1 { /parameter-1 { /actionCount nope } } }"), "a field inside a recorded value"); + assert!(!short("/version 3 /action-1 { /actionCount (x) }"), "a set's count in an action is not read"); +} + +#[test] +fn the_command_takes_only_the_keys_it_describes() { + // The control channel, MCP and the CLI check params against the description (#1442). + check_params("actionSet.read", &json!({"path": "/x/a.aia", "data": "", "dataBase64": "", "name": "a.aia"})).unwrap(); + assert!(check_params("actionSet.read", &json!({"pth": "/x/a.aia"})).is_err()); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d0bcedb73..f74d1c7a4 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1,6 +1,6 @@ //! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input, //! mutated raster images (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset -//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets other apps save as text (`.kys`), and native files (compressed, damaged, saved for older versions), +//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may //! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records //! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files @@ -1617,6 +1617,93 @@ fn shortcut_sets_past_the_caps_are_truncated() { } } +// ---------- action sets ---------- + +/// Action set `bytes` read with `actionSet.read`: an error, or a set within the caps. +fn action_set(bytes: &[u8]) -> Result<(), TestCaseError> { + let read = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": "fuzz.aia"}); + if let Ok(r) = no_panic(vectorcraft_engine::Session::new().execute("actionSet.read", &read)) { + let actions = r["actions"].as_array().cloned().unwrap_or_default(); + prop_assert!(actions.len() <= 1000); + prop_assert!(actions.iter().all(|a| a["steps"].as_array().map_or(0, Vec::len) <= 10_000)); + prop_assert!(actions.iter().map(|a| a["steps"].as_array().map_or(0, Vec::len)).sum::() <= 100_000); + prop_assert!(r["unread"].as_array().map_or(0, Vec::len) <= 1000); + } + Ok(()) +} + +/// A token of a text action set: a block or field name, a number of any size and sign, text in +/// parentheses (unclosed, nested or escaped too), bracketed or angled hexadecimal, a brace, a line +/// end, NUL or bytes that aren't UTF-8. +fn arb_aia_token() -> impl Strategy> { + prop_oneof![ + prop::sample::select(vec![ + "/action-1", + "/event-2", + "/parameter-1", + "/name", + "/internalName", + "/localizedName", + "/isOn", + "/value", + "/x", + "/version", + ]) + .prop_map(|t| t.as_bytes().to_vec()), + any::().prop_map(|n| n.to_string().into_bytes()), + Just(b"99999999999999999999999".to_vec()), + "[a-z ()\\\\]{0,12}".prop_map(|t| format!("({t})").into_bytes()), + prop::sample::select(vec!["(", ")", "\\", "[ 2 4142 ]", "[ 3 41 4", "[ 1 zz ]", "[", "]", "< 2 0a0b >", "<", ">", "2.5"]) + .prop_map(|t| t.as_bytes().to_vec()), + prop::sample::select(vec!["{", "}", "\r\n", "\r", "\n"]).prop_map(|t| t.as_bytes().to_vec()), + prop::sample::select(vec![vec![0u8], vec![0xFF, 0xFE], vec![0xC3]]), + ] +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn action_set_garbage_never_panics(bytes in prop::collection::vec(any::(), 0..600), head in any::()) { + let mut data = if head { b"/version 3 ".to_vec() } else { vec![] }; + data.extend(bytes); + action_set(&data)?; + } + + #[test] + fn hostile_action_sets_never_panic(tokens in prop::collection::vec(arb_aia_token(), 0..200), sep in prop::sample::select(vec![" ", "", "\t"])) { + let mut data = b"/version 3".to_vec(); + for t in tokens { + data.extend(sep.as_bytes()); + data.extend(t); + } + action_set(&data)?; + } +} + +/// An action set past any cap (nesting, actions, steps, name length, file size) is read up to it +/// and marked truncated. +#[test] +fn action_sets_past_the_caps_are_truncated() { + let read = |text: String| vectorcraft_engine::Session::new().execute("actionSet.read", &json!({ "data": text })).unwrap(); + let deep = format!("/version 3 /action-1 {{ {} }}", "/x { ".repeat(40)); + let actions = format!("/version 3 {}", "/action-1 { } ".repeat(1001)); + let steps = format!("/version 3 /action-1 {{ {} }}", "/event-1 { } ".repeat(10_001)); + let action = format!("/action-1 {{ {} }} ", "/event-1 { } ".repeat(10_000)); + let all_steps = format!("/version 3 {}", action.repeat(11)); + let long = format!("/version 3 /name [ 2000 {} ]", "6e".repeat(2000)); + let big = format!("/version 3 /action-1 {{ }} {}", " ".repeat(8 << 20)); + let uncapped: Vec<&str> = [("deep", deep), ("actions", actions), ("steps", steps), ("long", long), ("big", big)] + .into_iter() + .filter(|(_, text)| read(text.clone())["truncated"] != true) + .map(|(name, _)| name) + .collect(); + assert!(uncapped.is_empty(), "{uncapped:?}"); + let r = read(all_steps); + assert_eq!(r["truncated"], true); + assert_eq!(r["actions"].as_array().unwrap().iter().map(|a| a["steps"].as_array().unwrap().len()).sum::(), 100_000); +} + /// A small image of each raster format Place reads, with resolution metadata where it has some. fn raster_samples() -> Vec<(&'static str, Vec)> { let img = image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10])); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2f6f4b4a2..638a5f102 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1666,6 +1666,7 @@ font-technology Variable Променливи Report copied Отчетът е копиран Line {line} Ред {line} Line {line}: {name}: {status} Ред {line}: {name}: {status} + Read Action Set File Четене на файл с набор от действия Read Shortcut Set File Четене на файл с набор от клавишни комбинации Import Matches Импортиране на съответствия Remember Import Matches Запомняне на съответствията при импортиране @@ -4222,6 +4223,8 @@ axis Both И двете @msg no such action set няма такъв набор от действия @msg the action sets changed; nothing was deleted наборите от действия се промениха; нищо не беше изтрито @msg no action set to save няма набор от действия за запазване +@msg the action set file can't be read in full файлът с набор от действия не може да се прочете изцяло +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Зареден е „{name}“: {n} стъпка е пропусната|Зареден е „{name}“: {n} стъпки са пропуснати @msg not an action set file не е файл с набор от действия @msg the action set file is larger than 8 MiB файлът с набор от действия е по-голям от 8 MiB @msg the action set file holds more than 1,000 actions файлът с набор от действия съдържа повече от 1000 действия diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 27bbcadd1..70055e848 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -142,6 +142,7 @@ Report copied Izvještaj kopiran Line {line} Red {line} Line {line}: {name}: {status} Red {line}: {name}: {status} + Read Action Set File Pročitaj datoteku skupa akcija Read Shortcut Set File Pročitaj datoteku skupa prečica Import Matches Uparivanja uvoza Remember Import Matches Zapamti uparivanja uvoza @@ -4616,6 +4617,8 @@ imageTrace Outline Obris @msg no such action set nema takvog skupa akcija @msg the action sets changed; nothing was deleted skupovi akcija su se promijenili; ništa nije izbrisano @msg no action set to save nema skupa akcija za čuvanje +@msg the action set file can't be read in full datoteka skupa akcija ne može se pročitati u cijelosti +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Učitano „{name}”: {n} korak je izostavljen|Učitano „{name}”: {n} koraka su izostavljena|Učitano „{name}”: {n} koraka je izostavljeno @msg not an action set file nije datoteka skupa akcija @msg the action set file is larger than 8 MiB datoteka skupa akcija je veća od 8 MiB @msg the action set file holds more than 1,000 actions datoteka skupa akcija sadrži više od 1.000 akcija diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 060c96e63..aca952436 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3218,6 +3218,7 @@ digits Malayalam Malaiàlam Rating: Valoració: Ratio Proporció Rays Raigs + Read Action Set File Llegeix el fitxer de conjunt d’accions Read Shortcut Set File Llegeix el fitxer de conjunt de drets de teclat Reads or sets environment variables Llegeix o defineix variables d’entorn Ready A punt @@ -5375,6 +5376,8 @@ digits Malayalam Malaiàlam @msg no such action set no existeix aquest conjunt d’accions @msg the action sets changed; nothing was deleted els conjunts d’accions han canviat; no s’ha suprimit res @msg no action set to save no hi ha cap conjunt d’accions per desar +@msg the action set file can't be read in full el fitxer de conjunt d’accions no es pot llegir sencer +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out S’ha carregat “{name}”: s’ha omès {n} pas|S’ha carregat “{name}”: s’han omès {n} passos @msg not an action set file no és un fitxer de conjunt d’accions @msg the action set file is larger than 8 MiB el fitxer de conjunt d’accions supera els 8 MiB @msg the action set file holds more than 1,000 actions el fitxer de conjunt d’accions conté més de 1.000 accions diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 469d64acf..8a4aa807a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -150,6 +150,7 @@ Report copied Zpráva zkopírována Line {line} Řádek {line} Line {line}: {name}: {status} Řádek {line}: {name}: {status} + Read Action Set File Přečíst soubor sady akcí Read Shortcut Set File Přečíst soubor sady klávesových zkratek Import Matches Přiřazení z importů Remember Import Matches Zapamatovat přiřazení z importů @@ -4668,6 +4669,8 @@ imageTrace Outline Obrys @msg no such action set taková sada akcí neexistuje @msg the action sets changed; nothing was deleted sady akcí se změnily; nic nebylo odstraněno @msg no action set to save žádná sada akcí k uložení +@msg the action set file can't be read in full soubor sady akcí nelze přečíst celý +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Načteno „{name}“: vynechán {n} krok|Načteno „{name}“: vynechány {n} kroky|Načteno „{name}“: vynecháno {n} kroků @msg not an action set file není to soubor sady akcí @msg the action set file is larger than 8 MiB soubor sady akcí je větší než 8 MiB @msg the action set file holds more than 1,000 actions soubor sady akcí obsahuje více než 1 000 akcí diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f89ebed0a..29aa686dd 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -135,6 +135,7 @@ Report copied Rapporten er kopieret Line {line} Linje {line} Line {line}: {name}: {status} Linje {line}: {name}: {status} + Read Action Set File Læs handlingssætfil Read Shortcut Set File Læs fil med genvejssæt Import Matches Importér match Remember Import Matches Husk importmatch @@ -4330,6 +4331,8 @@ imageTrace Outline Kontur @msg no such action set intet sådant handlingssæt @msg the action sets changed; nothing was deleted handlingssættene er ændret; intet blev slettet @msg no action set to save intet handlingssæt at gemme +@msg the action set file can't be read in full handlingssætfilen kan ikke læses helt +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Indlæste »{name}«: {n} trin blev udeladt|Indlæste »{name}«: {n} trin blev udeladt @msg not an action set file ikke en handlingssætfil @msg the action set file is larger than 8 MiB handlingssætfilen er større end 8 MiB @msg the action set file holds more than 1,000 actions handlingssætfilen indeholder mere end 1.000 handlinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6cb87a87b..b6c9220c1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1714,6 +1714,7 @@ file-filter Templates Vorlagen Line {line} Zeile {line} Line {line}: {name}: {status} Zeile {line}: {name}: {status} Imported. Press OK to keep the imported set. Importiert. Klicken Sie auf „OK“, um den importierten Satz zu behalten. + Read Action Set File Aktionssatzdatei lesen Read Shortcut Set File Tastaturbefehlssatz-Datei lesen Import Matches Import-Zuordnungen Remember Import Matches Import-Zuordnungen merken @@ -4274,6 +4275,8 @@ theme Appearance Erscheinungsbild @msg no such action set kein solcher Aktionssatz @msg the action sets changed; nothing was deleted die Aktionssätze haben sich geändert; nichts wurde gelöscht @msg no action set to save kein Aktionssatz zum Speichern +@msg the action set file can't be read in full die Aktionssatzdatei kann nicht vollständig gelesen werden +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}“ geladen: {n} Schritt wurde ausgelassen|„{name}“ geladen: {n} Schritte wurden ausgelassen @msg not an action set file keine Aktionssatzdatei @msg the action set file is larger than 8 MiB die Aktionssatzdatei ist größer als 8 MiB @msg the action set file holds more than 1,000 actions die Aktionssatzdatei enthält mehr als 1.000 Aktionen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 25b0422e5..23d376654 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1624,6 +1624,7 @@ font-technology Variable Μεταβλητές Report copied Η αναφορά αντιγράφηκε Line {line} Γραμμή {line} Line {line}: {name}: {status} Γραμμή {line}: {name}: {status} + Read Action Set File Ανάγνωση αρχείου συνόλου ενεργειών Read Shortcut Set File Ανάγνωση αρχείου συνόλου συντομεύσεων Import Matches Αντιστοιχίσεις εισαγωγής Remember Import Matches Απομνημόνευση αντιστοιχίσεων εισαγωγής @@ -4313,6 +4314,8 @@ digits Malayalam Μαλαγιάλαμ @msg no such action set δεν υπάρχει τέτοιο σύνολο ενεργειών @msg the action sets changed; nothing was deleted τα σύνολα ενεργειών άλλαξαν· δεν διαγράφηκε τίποτα @msg no action set to save δεν υπάρχει σύνολο ενεργειών για αποθήκευση +@msg the action set file can't be read in full το αρχείο συνόλου ενεργειών δεν μπορεί να διαβαστεί ολόκληρο +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Φορτώθηκε το «{name}»: παραλείφθηκε {n} βήμα|Φορτώθηκε το «{name}»: παραλείφθηκαν {n} βήματα @msg not an action set file δεν είναι αρχείο συνόλου ενεργειών @msg the action set file is larger than 8 MiB το αρχείο συνόλου ενεργειών είναι μεγαλύτερο από 8 MiB @msg the action set file holds more than 1,000 actions το αρχείο συνόλου ενεργειών περιέχει περισσότερες από 1.000 ενέργειες diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index dafa078ed..5741993ca 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1717,6 +1717,7 @@ font-technology Variable Variables Report copied Informe copiado Line {line} Línea {line} Line {line}: {name}: {status} Línea {line}: {name}: {status} + Read Action Set File Leer archivo de conjunto de acciones Read Shortcut Set File Leer archivo de conjunto de métodos abreviados Import Matches Asociaciones de importación Remember Import Matches Recordar asociaciones de importación @@ -4358,6 +4359,8 @@ axis Both Ambos @msg no such action set no existe ese conjunto de acciones @msg the action sets changed; nothing was deleted los conjuntos de acciones han cambiado; no se ha eliminado nada @msg no action set to save no hay ningún conjunto de acciones que guardar +@msg the action set file can't be read in full el archivo de conjunto de acciones no se puede leer completo +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Se cargó “{name}”: se omitió {n} paso|Se cargó “{name}”: se omitieron {n} pasos @msg not an action set file no es un archivo de conjunto de acciones @msg the action set file is larger than 8 MiB el archivo de conjunto de acciones supera los 8 MiB @msg the action set file holds more than 1,000 actions el archivo de conjunto de acciones contiene más de 1000 acciones diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index ea04404d5..ec8f0c23f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -157,6 +157,7 @@ Report copied Raportti kopioitu Line {line} Rivi {line} Line {line}: {name}: {status} Rivi {line}: {name}: {status} + Read Action Set File Lue toimintosarjatiedosto Read Shortcut Set File Lue pikanäppäinjoukkotiedosto Import Matches Tuonnin vastaavuudet Remember Import Matches Muista tuonnin vastaavuudet @@ -4576,6 +4577,8 @@ imageTrace Outline Ääriviiva @msg no such action set toimintosarjaa ei ole @msg the action sets changed; nothing was deleted toimintosarjat muuttuivat; mitään ei poistettu @msg no action set to save ei tallennettavaa toimintosarjaa +@msg the action set file can't be read in full toimintosarjatiedostoa ei voi lukea kokonaan +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Ladattiin ”{name}”: jätettiin pois {n} vaihe|Ladattiin ”{name}”: jätettiin pois {n} vaihetta @msg not an action set file ei ole toimintosarjatiedosto @msg the action set file is larger than 8 MiB toimintosarjatiedosto on suurempi kuin 8 MiB @msg the action set file holds more than 1,000 actions toimintosarjatiedostossa on yli 1 000 toimintoa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2168c4bde..a6aada7f9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1711,6 +1711,7 @@ font-technology Variable Variables Report copied Rapport copié Line {line} Ligne {line} Line {line}: {name}: {status} Ligne {line}, {name}, {status} + Read Action Set File Lire un fichier de jeu de scripts Read Shortcut Set File Lire un fichier de jeu de raccourcis Import Matches Correspondances d’importation Remember Import Matches Mémoriser les correspondances d’importation @@ -4348,6 +4349,8 @@ axis Both Les deux @msg no such action set jeu de scripts inexistant @msg the action sets changed; nothing was deleted les jeux de scripts ont changé ; rien n’a été supprimé @msg no action set to save aucun jeu de scripts à enregistrer +@msg the action set file can't be read in full le fichier de jeu de scripts ne peut pas être lu en entier +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out « {name} » chargé : {n} étape a été ignorée|« {name} » chargé : {n} étapes ont été ignorées @msg not an action set file ce n’est pas un fichier de jeu de scripts @msg the action set file is larger than 8 MiB le fichier de jeu de scripts dépasse 8 Mio @msg the action set file holds more than 1,000 actions le fichier de jeu de scripts contient plus de 1 000 scripts diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 94e3ce3e4..b458a309f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3509,6 +3509,7 @@ Rating: रेटिंग: Ratio अनुपात Rays रे + Read Action Set File एक्शन सेट फ़ाइल पढ़ें Read Shortcut Set File शॉर्टकट सेट फ़ाइल पढ़ें Reads or sets environment variables पर्यावरण चर पढ़ता है या सेट करता है Ready तैयार @@ -6857,6 +6858,8 @@ @msg not a Word document: its main part is not a WordprocessingML document वर्ड डॉक्यूमेंट नहीं: इसका मुख्य हिस्सा वर्डप्रोसेसिंगएमएल डॉक्यूमेंट नहीं है @msg not a Word document: its package has no main document part नहीं एक वर्ड डॉक्यूमेंट: इसके पैकेज में कोई मुख्य डॉक्यूमेंट हिस्सा नहीं है @msg not a Word document: its WordDocument stream doesn't start with a file information block वर्ड डॉक्यूमेंट नहीं: इसकी WordDocument धारा फ़ाइल सूचना ब्लॉक के साथ शुरू नहीं होती है +@msg the action set file can't be read in full एक्शन सेट फ़ाइल पूरी तरह नहीं पढ़ी जा सकती +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” लोड किया गया: {n} चरण छोड़ा गया|“{name}” लोड किया गया: {n} चरण छोड़े गए @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल @msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f60dd9f86..5c1b1b253 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2156,6 +2156,7 @@ file-filter Templates Sablonok Report copied Jelentés másolva Line {line} {line}. sor Line {line}: {name}: {status} {line}. sor: {name}: {status} + Read Action Set File Műveletkészlet beolvasása fájlból Read Shortcut Set File Billentyűparancs-készlet beolvasása fájlból Import Matches Importálási megfeleltetések Remember Import Matches Importálási megfeleltetések megjegyzése @@ -5040,6 +5041,8 @@ imageTrace Outline Körvonal @msg no such action set nincs ilyen műveletkészlet @msg the action sets changed; nothing was deleted a műveletkészletek megváltoztak; semmi sem törlődött @msg no action set to save nincs menthető műveletkészlet +@msg the action set file can't be read in full a műveletkészlet-fájl nem olvasható be teljesen +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” betöltve; kihagyott lépések: {n} @msg not an action set file nem műveletkészlet-fájl @msg the action set file is larger than 8 MiB a műveletkészlet-fájl nagyobb 8 MiB-nál @msg the action set file holds more than 1,000 actions a műveletkészlet-fájl több mint 1000 műveletet tartalmaz diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8d84f8200..bce0c1acb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -135,6 +135,7 @@ Report copied Laporan disalin Line {line} Baris {line} Line {line}: {name}: {status} Baris {line}: {name}: {status} + Read Action Set File Baca berkas set tindakan Read Shortcut Set File Baca berkas set pintasan Import Matches Impor kecocokan Remember Import Matches Ingat kecocokan impor @@ -4330,6 +4331,8 @@ imageTrace Outline Kontur @msg no such action set tidak ada set tindakan seperti itu @msg the action sets changed; nothing was deleted set tindakan telah berubah; tidak ada yang dihapus @msg no action set to save tidak ada set tindakan untuk disimpan +@msg the action set file can't be read in full file set tindakan tidak dapat dibaca seluruhnya +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” dimuat: {n} langkah dilewati @msg not an action set file bukan file set tindakan @msg the action set file is larger than 8 MiB file set tindakan lebih besar dari 8 MiB @msg the action set file holds more than 1,000 actions file set tindakan berisi lebih dari 1.000 tindakan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 57e135ad2..490d0c646 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1746,6 +1746,7 @@ file-filter Templates Modelli Line {line} Riga {line} Line {line}: {name}: {status} Riga {line}: {name}: {status} Imported. Press OK to keep the imported set. Importato. Premi OK per mantenere il set importato. + Read Action Set File Leggi file di set di azioni Read Shortcut Set File Leggi file di set di scelte rapide Import Matches Abbinamenti di importazione Remember Import Matches Ricorda abbinamenti di importazione @@ -4400,6 +4401,8 @@ axis Both Entrambi @msg no such action set set di azioni inesistente @msg the action sets changed; nothing was deleted i set di azioni sono cambiati; non è stato eliminato nulla @msg no action set to save nessun set di azioni da salvare +@msg the action set file can't be read in full il file di set di azioni non può essere letto per intero +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Caricato “{name}”: {n} passaggio è stato omesso|Caricato “{name}”: {n} passaggi sono stati omessi @msg not an action set file non è un file di set di azioni @msg the action set file is larger than 8 MiB il file del set di azioni supera 8 MiB @msg the action set file holds more than 1,000 actions il file del set di azioni contiene più di 1.000 azioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 451b1a5f7..f2f438876 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2008,6 +2008,7 @@ digits Malayalam マラヤーラム数字 Line {line} {line} 行目 Line {line}: {name}: {status} {line} 行目: {name}: {status} Imported. Press OK to keep the imported set. 読み込みました。読み込んだセットを保持するには OK を押してください。 + Read Action Set File アクションセットファイルを読む Read Shortcut Set File ショートカットセットファイルを読む Import Matches 読み込み時の対応付け Remember Import Matches 読み込み時の対応付けを記憶 @@ -4735,6 +4736,8 @@ swatch Bright Violet ビビッドバイオレット @msg no such action set 指定されたアクションセットが見つかりません @msg the action sets changed; nothing was deleted アクションセットが変更されたため、何も削除しませんでした @msg no action set to save 保存するアクションセットがありません +@msg the action set file can't be read in full アクションセットファイルをすべて読み込めません +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 「{name}」を読み込みました。{n} 個のステップは省きました @msg not an action set file アクションセットファイルではありません @msg the action set file is larger than 8 MiB アクションセットファイルが 8 MiB を超えています @msg the action set file holds more than 1,000 actions アクションセットファイルのアクションが 1,000 個を超えています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 48b81a301..7c893c418 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4127,6 +4127,8 @@ font-technology Variable ცვლადი @msg no such action set ასეთი მოქმედებების ნაკრები არ არის @msg the action sets changed; nothing was deleted მოქმედებების ნაკრებები შეიცვალა; არაფერი წაშლილა @msg no action set to save შესანახი მოქმედებების ნაკრები არ არის +@msg the action set file can't be read in full მოქმედებების ნაკრების ფაილის სრულად წაკითხვა შეუძლებელია +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out ჩაიტვირთა „{name}“; გამოტოვებული ნაბიჯები: {n} @msg not an action set file ეს არ არის მოქმედებების ნაკრების ფაილი @msg the action set file is larger than 8 MiB მოქმედებების ნაკრების ფაილი 8 MiB-ზე დიდია @msg the action set file holds more than 1,000 actions მოქმედებების ნაკრების ფაილი 1000-ზე მეტ მოქმედებას შეიცავს @@ -5104,6 +5106,7 @@ imageTrace Outline კონტური Precise Cursors ზუსტი კურსორები Image Trace Preview გამოსახულების ვექტორიზაციის წინასწარი ნახვა Press a key… დააჭირეთ კლავიშს… + Read Action Set File მოქმედებების ნაკრების ფაილის წაკითხვა Read Shortcut Set File მალსახმობების ნაკრების ფაილის წაკითხვა Ready მზადაა Remember Import Matches იმპორტის შესაბამისობების დამახსოვრება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b6a60e185..838bb7e58 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1954,6 +1954,7 @@ digits Malayalam 말라얄람어 Line {line} {line}행 Line {line}: {name}: {status} {line}행: {name}: {status} Imported. Press OK to keep the imported set. 가져왔습니다. 가져온 세트를 유지하려면 확인을 누르세요. + Read Action Set File 액션 세트 파일 읽기 Read Shortcut Set File 단축키 세트 파일 읽기 Import Matches 가져오기 대응 Remember Import Matches 가져오기 대응 기억 @@ -4662,6 +4663,8 @@ theme Appearance 외관 @msg no such action set 해당 액션 세트가 없습니다 @msg the action sets changed; nothing was deleted 액션 세트가 바뀌어 아무것도 삭제하지 않았습니다 @msg no action set to save 저장할 액션 세트가 없습니다 +@msg the action set file can't be read in full 액션 세트 파일을 전부 읽을 수 없습니다 +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}”을(를) 불러왔습니다. 단계 {n}개를 제외했습니다 @msg not an action set file 액션 세트 파일이 아닙니다 @msg the action set file is larger than 8 MiB 액션 세트 파일이 8 MiB보다 큽니다 @msg the action set file holds more than 1,000 actions 액션 세트 파일의 액션이 1,000개를 넘습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f59503b55..f846de4d6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -135,6 +135,7 @@ Report copied Rapport gekopieerd Line {line} Regel {line} Line {line}: {name}: {status} Regel {line}: {name}: {status} + Read Action Set File Actiesetbestand lezen Read Shortcut Set File Sneltoetsensetbestand lezen Import Matches Overeenkomsten importeren Remember Import Matches Importovereenkomsten onthouden @@ -4342,6 +4343,8 @@ imageTrace Outline Contour @msg no such action set actieset bestaat niet @msg the action sets changed; nothing was deleted de actiesets zijn gewijzigd; er is niets verwijderd @msg no action set to save geen actieset om op te slaan +@msg the action set file can't be read in full het actiesetbestand kan niet volledig worden gelezen +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” geladen: {n} stap is weggelaten|„{name}” geladen: {n} stappen zijn weggelaten @msg not an action set file geen actiesetbestand @msg the action set file is larger than 8 MiB het actiesetbestand is groter dan 8 MiB @msg the action set file holds more than 1,000 actions het actiesetbestand bevat meer dan 1.000 acties diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a36e96485..f8fa28e93 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4491,6 +4491,8 @@ imageTrace Outline Kontur @msg no such action set brak takiego zestawu operacji @msg the action sets changed; nothing was deleted zestawy operacji się zmieniły; nic nie usunięto @msg no action set to save brak zestawu operacji do zapisania +@msg the action set file can't be read in full nie można odczytać całego pliku zestawu operacji +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Wczytano „{name}”: pominięto {n} krok|Wczytano „{name}”: pominięto {n} kroki|Wczytano „{name}”: pominięto {n} kroków @msg not an action set file to nie jest plik zestawu operacji @msg the action set file is larger than 8 MiB plik zestawu operacji jest większy niż 8 MiB @msg the action set file holds more than 1,000 actions plik zestawu operacji zawiera ponad 1000 operacji @@ -5060,6 +5062,7 @@ imageTrace Outline Kontur Report copied Skopiowano raport Line {line} Wiersz {line} Line {line}: {name}: {status} Wiersz {line}: {name}: {status} + Read Action Set File Odczytaj plik zestawu operacji Read Shortcut Set File Odczytaj plik zestawu skrótów Import Matches Dopasowania importu Remember Import Matches Zapamiętaj dopasowania importu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 777c8ac78..df73efa99 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3941,6 +3941,8 @@ axis Both Ambos @msg no such action set conjunto de ações inexistente @msg the action sets changed; nothing was deleted os conjuntos de ações mudaram; nada foi excluído @msg no action set to save nenhum conjunto de ações para salvar +@msg the action set file can't be read in full o arquivo de conjunto de ações não pode ser lido por inteiro +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” carregado: {n} etapa foi omitida|“{name}” carregado: {n} etapas foram omitidas @msg not an action set file não é um arquivo de conjunto de ações @msg the action set file is larger than 8 MiB o arquivo de conjunto de ações tem mais de 8 MiB @msg the action set file holds more than 1,000 actions o arquivo de conjunto de ações contém mais de 1.000 ações @@ -4698,6 +4700,7 @@ axis Both Ambos Report copied Relatório copiado Line {line} Linha {line} Line {line}: {name}: {status} Linha {line}: {name}: {status} + Read Action Set File Ler Arquivo de Conjunto de Ações Read Shortcut Set File Ler Arquivo de Conjunto de Atalhos Import Matches Importar Correspondências Remember Import Matches Lembrar Correspondências de Importação diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d0a28ef56..c90226537 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1743,6 +1743,7 @@ file-filter Templates Шаблоны Line {line} Строка {line} Line {line}: {name}: {status} Строка {line}: {name}: {status} Imported. Press OK to keep the imported set. Импортировано. Нажмите «ОК», чтобы сохранить импортированный набор. + Read Action Set File Прочитать файл набора действий Read Shortcut Set File Прочитать файл набора сочетаний клавиш Import Matches Сопоставления импорта Remember Import Matches Запомнить сопоставления импорта @@ -4395,6 +4396,8 @@ axis Both Оба @msg no such action set такого набора действий нет @msg the action sets changed; nothing was deleted наборы действий изменились; ничего не удалено @msg no action set to save нет набора действий для сохранения +@msg the action set file can't be read in full файл набора действий невозможно прочитать целиком +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Загружено «{name}»: пропущен {n} шаг|Загружено «{name}»: пропущено {n} шага|Загружено «{name}»: пропущено {n} шагов @msg not an action set file это не файл набора действий @msg the action set file is larger than 8 MiB файл набора действий больше 8 MiB @msg the action set file holds more than 1,000 actions файл набора действий содержит более 1000 действий diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 59f2be4a3..75ee16447 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1672,6 +1672,7 @@ font-technology Variable Variabla Report copied Rapporten har kopierats Line {line} Rad {line} Line {line}: {name}: {status} Rad {line}: {name}: {status} + Read Action Set File Läs fil med åtgärdsuppsättning Read Shortcut Set File Läs fil med kortkommandon Import Matches Importera matchningar Remember Import Matches Kom ihåg importmatchningar @@ -4263,6 +4264,8 @@ axis Both Båda @msg no such action set åtgärdsuppsättningen finns inte @msg the action sets changed; nothing was deleted åtgärdsuppsättningarna har ändrats; inget raderades @msg no action set to save ingen åtgärdsuppsättning att spara +@msg the action set file can't be read in full filen med åtgärdsuppsättning kan inte läsas helt +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Läste in ”{name}”: {n} steg utelämnades|Läste in ”{name}”: {n} steg utelämnades @msg not an action set file inte en fil med åtgärdsuppsättning @msg the action set file is larger than 8 MiB filen med åtgärdsuppsättningen är större än 8 MiB @msg the action set file holds more than 1,000 actions filen med åtgärdsuppsättningen innehåller fler än 1 000 åtgärder diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index ee18b78a2..40dab2ea0 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1674,9 +1674,10 @@ const COUNTED_STATUS_MESSAGES: &[(&str, &str)] = &[ ("Copied the CSS of 1 object", "Copied the CSS of 2 objects"), ("Downloaded 1 file", "Downloaded 2 files"), ("Exported 1 file to /out", "Exported 2 files to /out"), + ("Loaded “Tidy”: 1 step was left out", "Loaded “Tidy”: 2 steps were left out"), ]; -/// The link, font and export status messages name a count of 1 in the singular and other counts in +/// The link, font, export and action set status messages name a count of 1 in the singular and other counts in /// the plural, and every complete catalog translates both, in the form its plural rule picks. #[test] fn counted_status_messages_take_the_plural_form() { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2ad972032..9d807d760 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1553,6 +1553,7 @@ font-technology Variable Değişken Report copied Rapor kopyalandı Line {line} {line} Hattı Line {line}: {name}: {status} Hat {line}: {name}: {status} + Read Action Set File Eylem Seti Dosyasını Oku Read Shortcut Set File Kısayol Seti Dosyasını Oku Import Matches Eşleşmeleri İçe Aktar Remember Import Matches İçe Aktarılan Eşleşmeleri Hatırla @@ -4124,6 +4125,8 @@ axis Both Her İkisi @msg no such action set öyle bir eylem seti yok @msg the action sets changed; nothing was deleted eylem setleri değişti; hiçbir şey silinmedi @msg no action set to save kaydedilecek eylem seti yok +@msg the action set file can't be read in full eylem seti dosyası tamamen okunamıyor +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” yüklendi: {n} adım atlandı @msg not an action set file bir eylem seti dosyası değil @msg the action set file is larger than 8 MiB eylem seti dosyası 8 MiB'den büyük @msg the action set file holds more than 1,000 actions eylem seti dosyası 1.000'den fazla eylem içeriyor diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 08c6f9e74..6d7e38cfb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1740,6 +1740,7 @@ file-filter Templates Шаблони Line {line} Рядок {line} Line {line}: {name}: {status} Рядок {line}: {name}: {status} Imported. Press OK to keep the imported set. Імпортовано. Натисніть «ОК», щоб зберегти імпортований набір. + Read Action Set File Прочитати файл набору дій Read Shortcut Set File Прочитати файл набору сполучень клавіш Import Matches Зіставлення імпорту Remember Import Matches Запам’ятати зіставлення імпорту @@ -4384,6 +4385,8 @@ axis Both Обидві @msg no such action set такого набору дій немає @msg the action sets changed; nothing was deleted набори дій змінилися; нічого не видалено @msg no action set to save немає набору дій для збереження +@msg the action set file can't be read in full файл набору дій неможливо прочитати повністю +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Завантажено «{name}»: пропущено {n} крок|Завантажено «{name}»: пропущено {n} кроки|Завантажено «{name}»: пропущено {n} кроків @msg not an action set file це не файл набору дій @msg the action set file is larger than 8 MiB файл набору дій більший за 8 MiB @msg the action set file holds more than 1,000 actions файл набору дій містить понад 1000 дій diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b3c2fdf6f..5d9150df2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1891,6 +1891,7 @@ digits Hindi Hindi Line {line} Dòng {line} Line {line}: {name}: {status} Dòng {line}: {name}: {status} Imported. Press OK to keep the imported set. Đã nhập. Nhấn OK để giữ bộ đã nhập. + Read Action Set File Đọc tệp bộ hành động Read Shortcut Set File Đọc tệp bộ phím tắt Import Matches Cặp ghép khi nhập Remember Import Matches Ghi nhớ cặp ghép khi nhập @@ -4692,6 +4693,8 @@ theme Appearance Giao diện @msg no such action set không có bộ hành động như vậy @msg the action sets changed; nothing was deleted các bộ hành động đã thay đổi; không có gì bị xóa @msg no action set to save không có bộ hành động nào để lưu +@msg the action set file can't be read in full không thể đọc hết tệp bộ hành động +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Đã tải “{name}”: bỏ qua {n} bước @msg not an action set file không phải tệp bộ hành động @msg the action set file is larger than 8 MiB tệp bộ hành động lớn hơn 8 MiB @msg the action set file holds more than 1,000 actions tệp bộ hành động chứa hơn 1.000 hành động diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 74bed9ef6..849a626eb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4448,6 +4448,8 @@ imageTrace Outline 轮廓 @msg no such action set 没有此动作组 @msg the action sets changed; nothing was deleted 动作组已更改;未删除任何内容 @msg no action set to save 没有要存储的动作组 +@msg the action set file can't be read in full 无法完整读取动作组文件 +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已载入“{name}”:已略过 {n} 个步骤 @msg not an action set file 不是动作组文件 @msg the action set file is larger than 8 MiB 动作组文件大于 8 MiB @msg the action set file holds more than 1,000 actions 动作组文件包含超过 1,000 个动作 @@ -4715,6 +4717,7 @@ imageTrace Outline 轮廓 Report copied 已复制报告 Line {line} 第 {line} 行 Line {line}: {name}: {status} 第 {line} 行:{name}:{status} + Read Action Set File 读取动作组文件 Read Shortcut Set File 读取快捷键组文件 Import Matches 导入对应 Remember Import Matches 记住导入对应 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 771c86f98..7a5f8aa43 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2303,6 +2303,8 @@ file-filter Templates 範本 @msg no such action set 沒有這個動作組 @msg the action sets changed; nothing was deleted 動作組已變更;未刪除任何內容 @msg no action set to save 沒有要儲存的動作組 +@msg the action set file can't be read in full 無法完整讀取動作組檔案 +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已載入「{name}」:已略過 {n} 個步驟 @msg not an action set file 不是動作組檔案 @msg the action set file is larger than 8 MiB 動作組檔案大於 8 MiB @msg the action set file holds more than 1,000 actions 動作組檔案包含超過 1,000 個動作 @@ -2597,6 +2599,7 @@ file-filter Templates 範本 Line {line} 第 {line} 行 Line {line}: {name}: {status} 第 {line} 行:{name}:{status} Imported. Press OK to keep the imported set. 已匯入。按一下「確定」以保留匯入的快速鍵組。 + Read Action Set File 讀取動作組檔案 Read Shortcut Set File 讀取快速鍵組檔案 Import Matches 匯入對應 Remember Import Matches 記住匯入對應 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 94f375c4f..7508370e9 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1365,7 +1365,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::LOAD_SET, "Load Actions…", "", - "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}", + "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", ), ( crate::panels::actions::DELETE_SETS, diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 64c8e6fbc..93d781e5a 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -2680,6 +2680,191 @@ mod tests { assert_eq!(*asked.borrow(), [files::MAX_SET_FILE + 1; 2], "each read stops one byte past the limit"); } + /// An action set as another app saves it as text, written from scratch: the set `name` (none + /// when empty) and actions of steps, each a label and whether it is on, with no recorded values. + fn aia_text(name: &str, actions: &[(&str, &[(&str, bool)])]) -> String { + let hex = |t: &str| format!("[ {} {} ]", t.len(), t.bytes().map(|b| format!("{b:02x}")).collect::()); + let mut out = String::from("/version 3\n"); + if !name.is_empty() { + out += &format!("/name {}\n", hex(name)); + } + out += &format!("/isOpen 1\n/actionCount {}\n", actions.len()); + for (i, (action, steps)) in actions.iter().enumerate() { + out += &format!("/action-{} {{\n\t/name {}\n\t/eventCount {}\n", i + 1, hex(action), steps.len()); + for (j, (label, on)) in steps.iter().enumerate() { + out += &format!( + "\t/event-{} {{\n\t\t/internalName (sample_step_{j})\n\t\t/localizedName {}\n\t\t/isOn {}\n\t\t/parameterCount 0\n\t}}\n", + j + 1, + hex(label), + u8::from(*on) + ); + } + out += "}\n"; + } + out + } + + /// Load Actions… reads an action set another app saved as text: its actions come in with the + /// steps with no recorded values whose label is a VectorCraft command's that takes no values and + /// has no dialog, a step that is off stays excluded from playback, the other steps are left out + /// and listed, and the status bar says how many. An action plays the steps that are on. + #[test] + fn load_actions_reads_another_apps_action_set() { + let mut app = app_with_sets(); + let text = aia_text( + "Tidy Up", + &[("Group Them", &[("group", true), ("Move", true), ("Ungroup", false)]), ("Nothing", &[]), ("", &[("Ungroup", true)])], + ); + let r = app.run(LOAD_SET, json!({ "data": text })).unwrap(); + let skipped = json!([{"action": "Group Them", "step": "Move"}]); + assert_eq!(r, json!({"set": 3, "name": "Tidy Up", "actions": 3, "skipped": skipped, "leftOut": 1})); + let set = &app.ui.action_sets[3]; + assert_eq!(set.actions[0].name, "Group Them"); + assert_eq!(set.actions[0].steps, [("object.group".to_string(), json!({})), ("object.ungroup".to_string(), json!({}))]); + assert_eq!(set.actions[0].excluded_steps, [1].into()); + assert!(set.actions[1].steps.is_empty()); + assert_eq!(set.actions[2].name, "Action 3", "an action with no name"); + assert_eq!(app.ui.status, "Loaded “Tidy Up”: 1 step was left out"); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + for x in [0, 50] { + app.run("shape.rectangle", json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 2); + play(&mut app, 3, 0).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 1, "the action grouped the rectangles and did not ungroup them"); + } + + /// A step recorded with values is left out even when a command has its label: a step that set an + /// opacity does not select by opacity. A step whose values name a menu command, in two or three + /// values, is left out too. The status bar counts the steps left out. + #[test] + fn recorded_values_keep_a_step_out() { + let hex = |t: &str| format!("[ {} {} ]", t.len(), t.bytes().map(|b| format!("{b:02x}")).collect::()); + let text = |t: &str| format!("/type (ustring) /value {}", hex(t)); + let number = || "/type (real) /value 60.0".to_string(); + let menu = |shown: &str| vec![text("some_command"), text(shown), number()]; + let steps = [ + ("Opacity", vec![number()]), + ("Stroke Weight", vec![number()]), + ("A Menu Step", menu("Group")), + ("A Menu Step", vec![text("some_command"), text("Ungroup")]), + ("Rename", vec![text("OldLayer"), text("Group")]), + ("Group", vec![number(), text("Group"), number()]), + ("Group", vec![]), + ]; + let events: String = steps + .iter() + .enumerate() + .map(|(i, (label, values))| { + let params: String = values.iter().enumerate().map(|(n, v)| format!("/parameter-{} {{ {v} }} ", n + 1)).collect(); + format!("/event-{} {{ /localizedName {} /parameterCount {} {params} }} ", i + 1, hex(label), values.len()) + }) + .collect(); + let set = format!("/version 3 /name {} /action-1 {{ /name {} {events} }}", hex("Values"), hex("A")); + let mut app = app_with_sets(); + let r = app.run(LOAD_SET, json!({ "data": set })).unwrap(); + assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.group".to_string(), json!({}))]); + assert_eq!(r["leftOut"], 6); + let left_out: Vec<&str> = r["skipped"].as_array().unwrap().iter().map(|s| s["step"].as_str().unwrap()).collect(); + assert_eq!(left_out, ["Opacity", "Stroke Weight", "A Menu Step", "A Menu Step", "Rename", "Group"]); + assert_eq!(app.ui.status, "Loaded “Values”: 6 steps were left out"); + } + + /// A step with no label, or a blank one, is left out and listed by its internal name, even when + /// that name is a command's label. + #[test] + fn a_step_with_no_label_is_left_out() { + let set = "/version 3 /name [ 4 4e6f6e65 ] /action-1 { /name [ 1 41 ] \ + /event-1 { /internalName (group) /parameterCount 0 } \ + /event-2 { /internalName (Ungroup) /localizedName [ 1 20 ] /parameterCount 0 } \ + /event-3 { /internalName (x) /localizedName [ 5 47726f7570 ] /parameterCount 0 } }"; + let mut app = app_with_sets(); + let r = app.run(LOAD_SET, json!({ "data": set })).unwrap(); + assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.group".to_string(), json!({}))]); + assert_eq!(r["skipped"], json!([{"action": "A", "step": "group"}, {"action": "A", "step": "Ungroup"}])); + assert_eq!(r["leftOut"], 2); + } + /// Load Actions… picks action set files of both kinds, and a set another app saved without a + /// name takes the file's name, or `Actions` when there is no file; one named like a set in the + /// panel gets the next free number. A file that isn't UTF-8 still reads. A set past the reader's + /// caps, one whose text ends inside a block, one whose counts differ from what follows and one + /// with a damaged name are refused, and text that isn't an action set is not read. + #[test] + fn load_actions_names_another_apps_set_after_its_file() { + let mut app = app_with_sets(); + let files = with_files(&mut app, "/sets/Mine.aia"); + let mut bytes = aia_text("", &[("One", &[("Ungroup", true)])]).into_bytes(); + bytes.extend(b"/note (\xFF)\n"); + files.borrow_mut().insert("/sets/Mine.aia".into(), bytes); + let r = app.run(LOAD_SET, json!({})).unwrap(); + assert_eq!((r["name"].clone(), r["leftOut"].clone()), (json!("Mine 2"), json!(0))); + assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.ungroup".to_string(), json!({}))]); + let actions = vec![("A", [("Group", true)].as_slice()); 1001]; + let r = app.run(LOAD_SET, json!({ "data": aia_text("Big", &actions) })); + assert_eq!(r.unwrap_err(), "the action set file can't be read in full"); + let cut = aia_text("Cut", &[("A", &[("Group", true)])]); + let cut = cut.trim_end().strip_suffix('}').unwrap().to_string(); + assert_eq!(app.run(LOAD_SET, json!({ "data": cut })).unwrap_err(), "the action set file can't be read in full"); + let counts = aia_text("Counts", &[("A", &[]), ("B", &[])]).replace("/actionCount 2", "/actionCount 1"); + assert_eq!(app.run(LOAD_SET, json!({ "data": counts })).unwrap_err(), "the action set file can't be read in full"); + for damaged in ["/version 3 /action-1 { /name [ 3 41 ] }", "/version 3 /name [ 3 41 ]"] { + assert_eq!(app.run(LOAD_SET, json!({ "data": damaged })).unwrap_err(), "the action set file can't be read in full"); + } + for not_a_set in ["/version three", "/versionX 3"] { + assert_eq!(app.run(LOAD_SET, json!({ "data": not_a_set })).unwrap_err(), "not an action set file"); + } + assert_eq!(app.ui.action_sets.len(), 4, "no set added"); + let r = app.run(LOAD_SET, json!({ "data": aia_text("", &[]) })).unwrap(); + assert_eq!(r["name"], "Actions"); + } + + /// A step stands for a command only when no other menu command has its label, and the command is + /// recordable, runs with no values, has no dialog and sits directly in a top menu. The item of a + /// submenu stands for nothing: its label alone does not say which command it is. + #[test] + fn steps_stand_for_commands_that_run_with_no_values() { + assert_eq!(files::step_command("Group"), Some("object.group")); + assert_eq!(files::step_command(" GROUP... "), Some("object.group")); + for label in ["Move", "Move…", "", "No Such Command"] { + assert_eq!(files::step_command(label), None, "{label}"); + } + let submenu_items = ["Opacity", "Stroke Weight", "Fill Color", "Blending Mode", "Grid", "Radial", "Create", "Delete All"]; + let matched: Vec<&str> = submenu_items.into_iter().filter(|label| files::step_command(label).is_some()).collect(); + assert!(matched.is_empty(), "{matched:?}"); + // The menu bar's Crop Image item runs another command, which opens the crop tool. + let not_on_the_menu_bar = ["Crop Image", "Expand Gradient Mesh", "Duplicate"]; + let matched: Vec<&str> = not_on_the_menu_bar.into_iter().filter(|label| files::step_command(label).is_some()).collect(); + assert!(matched.is_empty(), "{matched:?}"); + } + + /// A step does not stand for a command that closes, reverts or writes documents, for Paste, for + /// Undo or Redo, or for an add-on's command: such a step is left out, and nothing is closed on + /// playback. + #[test] + fn steps_do_not_stand_for_file_commands_paste_undo_or_add_ons() { + let labels = ["Close", "Close All", "Revert", "Export for Screens", "Paste", "Undo", "Redo", "Close Path", "Save a Timestamped Copy"]; + let matched: Vec<&str> = labels.into_iter().filter(|label| files::step_command(label).is_some()).collect(); + assert!(matched.is_empty(), "{matched:?}"); + let mut app = app_with_sets(); + let r = app.run(LOAD_SET, json!({ "data": aia_text("Closes", &[("A", &[("Close", true), ("Group", true)])]) })).unwrap(); + assert_eq!(r["leftOut"], 1); + assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.group".to_string(), json!({}))]); + } + + /// A set loaded from another app's file saves to VectorCraft's own file with its excluded steps, + /// and loads back the same. + #[test] + fn another_apps_set_saves_and_loads_back_with_its_excluded_steps() { + let mut app = app_with_sets(); + let text = aia_text("Tidy", &[("A", &[("Group", true), ("Ungroup", false)])]); + app.run(LOAD_SET, json!({ "data": text })).unwrap(); + let saved = files::write_set(&app.ui.action_sets[3]); + let r = app.run(LOAD_SET, json!({ "data": saved })).unwrap(); + assert_eq!(r, json!({"set": 4, "name": "Tidy 2", "actions": 1})); + assert_eq!(app.ui.action_sets[4].actions[0].excluded_steps, [1].into()); + } + /// Save Actions… does not write a set that Load Actions… would refuse: more than 1,000 actions. #[test] fn save_actions_does_not_write_a_set_load_actions_refuses() { diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index 4e5092cef..f7df78ee9 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -9,6 +9,8 @@ //! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay //! excluded steps, and sets with stops use version 3 so older builds refuse them rather than fail //! at a stop. Other sets use version 1, and earlier version 1 and 2 files still work. +//! Load Actions… also reads the action sets another app saves as text (`.aia`, through +//! `actionSet.read`). use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -34,6 +36,9 @@ const MAX_SET_ACTIONS: usize = 1000; /// The most steps [`read_set`] reads in one action. const MAX_ACTION_STEPS: usize = 10_000; +/// The most left-out steps that Load Actions… lists for an action set another app saved. +const MAX_SKIPPED: usize = 1000; + /// An action set file: a header, the set's name and its actions. #[derive(Serialize, Deserialize)] struct SetFile { @@ -201,12 +206,158 @@ pub(crate) fn can_load(app: &VectorcraftApp) -> bool { crate::picks::can(app, &crate::picks::PickRequest::Open(crate::FilePick::default())) } +/// The bytes start with `/version`, as the action sets another app saves as text (`.aia`) do. +fn is_aia(bytes: &[u8]) -> bool { + bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes).trim_ascii_start().starts_with(b"/version") +} + +/// `label` in any case, without a final `…` or `...`. +fn bare_label(label: &str) -> String { + label.trim().trim_end_matches("...").trim_end_matches('…').trim().to_lowercase() +} + +/// Whether a step of another app's action set may stand for command `id`. `command.batch` runs a +/// step without the app's handling of it, so a file or document command would close, revert or write +/// documents without asking, and a Paste would skip the view's center and the swatch conflict +/// question. Undo and Redo would act on the action's own undo step, and an add-on command with a +/// label a file also uses is not what the file recorded. +fn may_stand_for(id: &str) -> bool { + !(id.starts_with("file.") + || id.starts_with("document.") + || id.starts_with("addon.") + || id.starts_with("edit.paste") + || matches!(id, "edit.undo" | "edit.redo")) +} + +/// The name of a step of `actionSet.read`'s answer, and whether the step can stand for a command. A +/// step goes by its label. Only a step with a label and no recorded values can stand for a command; +/// a step recorded with values, a recorded menu item included, is left out. A step with no label is +/// listed by its name. +fn step_name(step: &Value) -> (String, bool) { + let label = step.get("label").and_then(Value::as_str); + let params = step.get("params").and_then(Value::as_u64).unwrap_or_default(); + match label { + Some(label) => (label.to_string(), params == 0), + None => (step.get("name").and_then(Value::as_str).unwrap_or_default().to_string(), false), + } +} + +/// The commands a step of another app's action set can stand for, by label, in English and in the +/// interface language ([`bare_label`]). A label stands for a command when no other menu command has +/// it, and the command is recordable, is run by a menu bar item with no params +/// ([`runs_with_no_values`](crate::shortcut_editor::runs_with_no_values)), has no `…` in its label, +/// sits directly in a top menu and is one [`may_stand_for`] allows. The +/// label of a submenu's item does not say which command it is: Select › Same › Opacity is "Opacity". +fn step_commands() -> std::collections::HashMap> { + let mut by_label = std::collections::HashMap::new(); + for c in vectorcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty()) { + let can_stand = + may_stand_for(c.id) && c.journal && c.menu.len() == 1 && !c.label.contains('…') && crate::shortcut_editor::runs_with_no_values(c); + let mut labels = vec![bare_label(c.label), bare_label(crate::i18n::t_id(c.id, c.label))]; + labels.dedup(); + for label in labels.into_iter().filter(|l| !l.is_empty()) { + by_label + .entry(label) + .and_modify(|id: &mut Option<&'static str>| *id = id.filter(|id| can_stand && *id == c.id)) + .or_insert(can_stand.then_some(c.id)); + } + } + by_label +} + +/// The command a step of another app's action set stands for ([`step_commands`]). +#[cfg(test)] +pub(super) fn step_command(label: &str) -> Option<&'static str> { + step_commands().get(&bare_label(label)).copied().flatten() +} + +/// What Load Actions… made of an action set another app saved as text. +struct AiaLoad { + set: ActionSet, + /// The first [`MAX_SKIPPED`] steps left out: `[{action, step}]`. + skipped: Vec, + /// How many steps were left out. + left_out: usize, +} + +/// The set of `read`, `actionSet.read`'s answer, named `name` when the file gives none. A step comes +/// in as the command it stands for ([`step_name`], [`step_commands`]), with no params, and excluded +/// from playback when it is off in the file. An action with no name is called `Action N`. +fn aia_set(read: &Value, name: &str) -> AiaLoad { + let (mut skipped, mut left_out) = (vec![], 0usize); + let commands = step_commands(); + let text = |v: &Value, key: &str| v.get(key).and_then(Value::as_str).unwrap_or_default().to_string(); + let actions = read + .get("actions") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .enumerate() + .map(|(i, a)| { + let action = Some(text(a, "name")).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Action {}", i + 1)); + let (mut steps, mut excluded_steps) = (vec![], std::collections::BTreeSet::new()); + for s in a.get("steps").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() { + let (name, can_stand) = step_name(s); + let Some(id) = commands.get(&bare_label(&name)).copied().flatten().filter(|_| can_stand) else { + left_out += 1; + if skipped.len() < MAX_SKIPPED { + skipped.push(json!({ "action": action, "step": name })); + } + continue; + }; + if s.get("on").and_then(Value::as_bool) == Some(false) { + excluded_steps.insert(steps.len()); + } + steps.push((id.to_string(), json!({}))); + } + Action { excluded_steps, ..Action::new(action, steps) } + }) + .collect(); + let set = text(read, "set"); + AiaLoad { set: ActionSet::new(if set.trim().is_empty() { name.to_string() } else { set }, actions), skipped, left_out } +} + +/// [`load_set`] of an action set another app saved as text, which `actionSet.read` reads with +/// `params`. +fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result { + // Text that starts `/version` and no number is not an action set either. + let read = app.run("actionSet.read", params).map_err(|_| "not an action set file")?; + // A set read only in part would play actions that differ from the file's. + if read.get("truncated").and_then(Value::as_bool).unwrap_or(false) { + return Err("the action set file can't be read in full".into()); + } + let AiaLoad { mut set, skipped, left_out } = aia_set(&read, "Actions"); + set.name = free_set_name(&app.ui.action_sets, &set.name); + let (name, actions) = (set.name.clone(), set.actions.len()); + app.ui.action_sets.push(set); + if left_out > 0 { + app.status(crate::i18n::msg_n( + left_out as u64, + "Loaded “{name}”: {n} step was left out", + "Loaded “{name}”: {n} steps were left out", + &[("name", &name)], + )); + } + Ok(json!({ + "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions, + "skipped": skipped, "leftOut": left_out, + })) +} + /// `ui.actionSet.load {path? | data?}`: reads the action set file at `path` (else a picked one), or /// the file's text `data`, and adds its set after the others, with its actions. A set with the /// same name as one in the panel gets the next free number (`Print 2`). Loaded actions keep their /// function keys. The current set and the other sets stay as they are → /// `{set: index, name, actions: n}`. Where the host can read part of a file (the desktop), a file /// larger than 8 MiB is refused after its first 8 MiB and one byte are read. +/// +/// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its +/// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]), +/// with no params; a step that is off in the file is excluded from playback. A set with no name is +/// called after the file, else `Actions`. The other steps are left out and listed, and the status +/// bar says how many → `{set, name, actions, skipped: [{action, step}] (at most 1,000), leftOut: n}`. +/// Such a file that `actionSet.read` marks `truncated` is refused, and no set is added. pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { let (text, stem) = match p.get("data") { Some(Value::String(data)) => { @@ -214,6 +365,9 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { if data.len() > MAX_SET_FILE { return Err("the action set file is larger than 8 MiB".into()); } + if is_aia(data.as_bytes()) { + return load_aia(app, json!({ "data": data })); + } (data.clone(), String::new()) } Some(_) => return Err("not an action set file".into()), @@ -221,7 +375,8 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { let path = match p.get("path").and_then(Value::as_str) { Some(path) => path.to_string(), None => { - let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Action sets", &[ACTIONS_EXT])], ..Default::default() }; + let exts = [&[ACTIONS_EXT], vectorcraft_engine::cmd::actionset::EXTS].concat(); + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Action sets", &exts)], ..Default::default() }; crate::picks::open(app, &pick).ok_or("cancelled")? } }; @@ -234,6 +389,9 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { if bytes.len() > MAX_SET_FILE { return Err("the action set file is larger than 8 MiB".into()); } + if is_aia(&bytes) { + return load_aia(app, json!({ "name": path, "dataBase64": vectorcraft_format::base64_encode(&bytes) })); + } let stem = std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); (String::from_utf8(bytes).map_err(|_| "not an action set file")?, stem) } diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 01deb3be8..d7be30f66 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -539,6 +539,17 @@ fn needs_a_value(key: &str) -> bool { .contains(key) } +/// Does a menu bar item run the command with no params, so that it does what its label says and opens +/// no dialog? A command no menu bar item runs (the Crop Image item runs `ui.cropImage`), a menu item +/// that gives it params, a [`NEEDS_A_PARAM`] command and a field a call must give each rule it out. +pub(crate) fn runs_with_no_values(c: &vectorcraft_engine::CommandSpec) -> bool { + menu_bar_items().contains_key(c.id) + && menu_target(c.id).is_none() + && !NEEDS_A_PARAM.contains(&c.id) + && !crate::dialogs::variables::opens(c.id) + && !names_a_required_field(c.params) +} + /// Does a params doc name a field a call must give? Its fields are the top-level parts of its /// first `{…}`. A field is optional when it is empty, starts with `…`, gives a default (`rows=10`) /// or each of its names ends with `?` (`id?|ids?: [id…]`); `{index | indices}` names a required diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 5139d4881..e6d81a00a 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); } +/// 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] +fn action_sets_are_read_only_inside_the_read_root() { + let (mut app, tx, base) = confined("aia"); + let set = "/version 3\n/name [ 4 4d696e65 ]\n/action-1 { /name [ 1 41 ] }\n"; + std::fs::write(base.join("in/a.aia"), set).unwrap(); + std::fs::write(base.join("outside/b.aia"), set).unwrap(); + let execute = |command: &str, path: PathBuf| json!({"command": command, "params": {"path": s(path)}}); + let r = request(&mut app, &tx, "engine.execute", execute("actionSet.read", base.join("in/a.aia"))); + assert_eq!(r["result"]["set"], "Mine", "{r}"); + let r = request(&mut app, &tx, "engine.execute", execute(crate::panels::actions::LOAD_SET, base.join("in/a.aia"))); + assert_eq!(r["result"]["name"], "Mine", "{r}"); + let sets = app.ui.action_sets.len(); + for command in ["actionSet.read", crate::panels::actions::LOAD_SET] { + let r = request(&mut app, &tx, "engine.execute", execute(command, base.join("outside/b.aia"))); + assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the read root")), "{command}: {r}"); + } + assert_eq!(app.ui.action_sets.len(), sets, "no set added"); + let _ = std::fs::remove_dir_all(base); +} + /// `ui.render` (MCP's `render_preview` and `screenshot` in the app) draws the artboard pixel for /// pixel as `app.export` writes a PNG on white: two rectangles that meet at x 40.3 and a diagonal /// edge. diff --git a/docs/mcp.md b/docs/mcp.md index 1a6189800..75572914c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -106,7 +106,7 @@ the suite) keep the agent to the folders you give it: - Every file a command reads must lie inside a **read root**: `open_file`, `file.place`, relinking and updating links, the images an SVG links to, swatch library, graphic style library, symbol library, brush library and colour-profile loads, plug-ins, - `file.newFromTemplate`, preset imports, shortcut sets (`shortcutSet.read`), the folders `text.findFontFiles` and `links.findFiles` search, the + `file.newFromTemplate`, preset imports, shortcut sets (`shortcutSet.read`), action sets (`actionSet.read`), the folders `text.findFontFiles` and `links.findFiles` search, the scripts `script.check` reads and the files they include. - Every file a command writes must lie inside a **write root**: `save_file`, `export`, the save and export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`, @@ -1978,7 +1978,8 @@ already walks Image Trace. Short agent entry points: instead of playing the excluded steps or failing at a stop; other files use version 1. A file with another `format` or a `version` other than 1, 2 or 3, a stop whose message is not text or whose `allowContinue` is not true or false, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are refused, and no set is added. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is - disabled where there is no open dialog (the web). + disabled where there is no open dialog (the web). `ui.actionSet.load` also reads the action sets other apps save as + text (`.aia`); see "Action sets from other apps". - Actions: `ui.action.rename {set, action, name}` renames the Actions panel's action at `action` in set `set` (0 is the top set) to `name` with spaces trimmed from both ends, keeps its steps, function key and color, and returns `{name}`. An index past the last set or action, or an empty name, is an error, and then nothing is renamed. @@ -6870,6 +6871,40 @@ the entry names and decodes the codes that VectorCraft's default shortcuts agree {"name":"run_command","arguments":{"command":"importMatches.set","params":{"matches":[{"kind":"name","name":"Group","target":"object.group"},{"kind":"key","name":"901","target":"G"}]}}} ``` +## Action sets from other apps + +`actionSet.read {path? | data? | dataBase64?, name?}` reads an action set another app saved as text (an `.aia` file +that starts with `/version`) without changing anything. Give exactly one of `path`, `data` and `dataBase64`. It returns +the set's name (`set`, else the file's name, else empty), its `version`, and its `actions`, each with its `name` and +`steps`. A step lists its `name` (the file's `/internalName`), its `label` (the `/localizedName` it is shown with, or +null when it has none or it is blank), `on` (false when the file sets `/isOn 0`), `params` (how many recorded values it +has) and its `line`. `unread` lists the tokens the reader skipped, with their lines. The reader stops at 8 MiB, 16 open +blocks, 1,000 actions, 10,000 steps in an action and 100,000 steps in the set, and cuts names at 1,024 bytes. It sets +`truncated` when it reaches one of these limits, when the text ends inside a block or a value or after a field's name, +when `/actionCount`, `/eventCount` or a step's `/parameterCount` differs from the actions, steps or recorded values read +(a negative count, one too large for a number and one that is not a number always differ), or when a hexadecimal name, +label or recorded `/value` is not as many bytes as its count says. Text that does not start with `/version` and a +number is an error. + +`ui.actionSet.load` (the Actions panel menu's Load Actions…) reads such a file too, from `path`, a picked file or +`data`, and adds its set after the other sets with all its actions. The set's name is the file's `/name`, else the +file's name, else `Actions`. A step with a label and no recorded values comes in as the VectorCraft command with that +label, in English or in the interface language, in any case and without a final `…`, when exactly one menu command has +that label and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with +no values and opens no dialog (Group, Ungroup). A submenu's items, file and document commands, Paste, Undo, Redo and +add-on commands are never matched. The step runs with no params, and a step that is off in the file is excluded from +playback. Other steps are left out, including every step with no label and every step recorded with values (recorded +menu items too). The reply lists the first 1,000 in `skipped` (`{action, step}`, the step by its label, else its name), +`leftOut` counts them, and the status bar says how many were left out. An action with no name is called `Action N`, by +its place in the file. Function keys and colors are not read. A file marked `truncated` is refused, and no set is added. +Text that starts `/version` and is not an action set gives "not an action set file". Tokens in `unread` do not stop a +load. + +```json +{"name":"run_command","arguments":{"command":"actionSet.read","params":{"path":"/Users/me/Prep.aia"}}} +{"name":"run_command","arguments":{"command":"ui.actionSet.load","params":{"path":"/Users/me/Prep.aia"}}} +``` + ## Artwork Cleanup add-on Artwork Cleanup is a built-in add-on: its commands are `addon.cleanup.*`, and in the desktop app it lives in Object › diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 72ef93f84..c92cf09e1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -635,6 +635,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`. - 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. - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is kept with the action for Button Mode, which VectorCraft does not have yet. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fca9b2354..8c8f14e86 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -366,7 +366,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Live trace result bounds and selection highlights exclude the retained bitmap; source-image views retain it | done | All TraceView modes and rotated result bounds regression-covered; ordinary hidden group behavior retained | | Image Trace panel: the Preview checkbox before Trace chooses whether a setting change traces the selected Image Trace object again at once | done | Per the public documentation, Preview shows the result of the current settings and Trace traces with them (). Preview is on by default and kept with the UI preferences; Reset to Default leaves it. 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, and come back when it is selected again. `imageTrace.preview {on?}`, `ui.set {imageTracePreview}`; `panels/image_trace.rs` headless tests (`with_preview_off_a_slider_change_waits_for_trace`, `turning_preview_on_traces_the_changes_that_waited`, `preview_turned_on_with_nothing_selected_traces_when_the_object_is_selected_again`, `settings_that_wait_stay_with_their_object`, `settings_that_wait_come_back_when_their_object_is_selected_again`, `settings_that_wait_are_dropped_when_their_object_changed_since`, `preview_is_kept_with_the_preferences_and_reset_to_default_leaves_it`) | | Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls | -| Actions panel: ≡ › Save Actions… saves the current set with all its actions to a file and ≡ › Load Actions… adds a saved set ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html)) | partial | `ui.actionSet.save`, `ui.actionSet.load`; `panels/actions.rs` tests. The file is VectorCraft's own `.vcactions` JSON; the reference app's `.aia` files and Reset, Clear and Replace Actions remain | +| Actions panel: ≡ › Save Actions… saves the current set with all its actions to a file and ≡ › Load Actions… adds a saved set ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html)) | partial | `ui.actionSet.save`, `ui.actionSet.load`; `panels/actions.rs` tests. The file is VectorCraft's own `.vcactions` JSON; Reset, Clear and Replace Actions remain | +| Actions panel: ≡ › 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)) | partial | `actionSet.read`, `ui.actionSet.load`; `tests/actionset.rs`, `panels/actions.rs` tests, `import_fuzz`. Only steps with a label and no recorded values come in, as menu commands that run with no values and open no dialog, matched by label; the other steps, recorded menu items included, are left out and counted | | Actions panel: ≡ › Action Options… renames the selected action and sets its function key and color ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.action.options`; `panels/actions.rs` tests (part of #1629). The color shows nowhere until Button Mode exists | | Actions panel: ≡ › Playback Options… plays actions Accelerated, Step by Step or with a pause between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html)) | done | `ui.action.playbackOptions`; `panels/actions.rs` tests. The pause is a whole number of seconds from 1 to 60 (VectorCraft's own range); Batch… plays at full speed | | Actions panel: a clicked action stays selected for Play Current Selection and Delete Selection | partial | #1853 (part of #1629); editing actions and action shortcuts remain | From b568977af45b1d8993ae17ed68437f776860460f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:29:05 +0700 Subject: [PATCH 14/93] Asset Export: 3D objects export as USDA, USDZ, glTF or OBJ meshes Squashed from #6168 onto main. --- crates/doc/src/assets.rs | 6 +- crates/doc/src/canvas_range.rs | 4 +- crates/effects/src/lib.rs | 5 +- crates/effects/src/revolve.rs | 39 ++ crates/effects/src/solids.rs | 204 ++++++++-- crates/effects/src/solids/tests_mesh.rs | 140 +++++++ crates/engine/src/cmd/assets.rs | 207 +++++++++- crates/engine/src/cmd/fileio.rs | 4 + crates/engine/src/cmd/fileio/mesh3d.rs | 260 ++++++++++++ crates/engine/src/cmd/fileio/mesh3d/tests.rs | 130 ++++++ crates/engine/src/cmd/fileio/screens.rs | 3 + crates/engine/src/cmd/fileio/zip.rs | 22 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/assets_3d.rs | 220 +++++++++++ crates/three-d/src/lib.rs | 41 ++ crates/three-d/src/mesh.rs | 371 ++++++++++++++++++ crates/three-d/src/mesh/tests.rs | 283 +++++++++++++ crates/three-d/src/solid.rs | 2 + crates/three-d/src/solid/build.rs | 42 +- crates/three-d/src/solid/inflate.rs | 20 +- .../ui-egui/src/dialogs/export_for_screens.rs | 34 +- .../src/dialogs/tests_screen_assets.rs | 22 ++ crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fi.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hi.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ka.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/tr.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/io/export.rs | 6 +- crates/ui-egui/src/menus/invoke.rs | 10 +- crates/ui-egui/src/palette.rs | 2 +- crates/ui-egui/src/panels/asset_export.rs | 61 ++- .../ui-egui/src/panels/tests_asset_export.rs | 75 ++++ docs/effects-parity.md | 4 +- docs/file-format-parity.md | 4 +- docs/gaps.md | 2 +- docs/mcp.md | 32 ++ docs/target-app-parity.md | 6 +- 59 files changed, 2376 insertions(+), 77 deletions(-) create mode 100644 crates/effects/src/solids/tests_mesh.rs create mode 100644 crates/engine/src/cmd/fileio/mesh3d.rs create mode 100644 crates/engine/src/cmd/fileio/mesh3d/tests.rs create mode 100644 crates/engine/src/tests/assets_3d.rs create mode 100644 crates/three-d/src/mesh.rs create mode 100644 crates/three-d/src/mesh/tests.rs diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 3c4451d54..824f3ebea 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -11,12 +11,16 @@ use serde::{Deserialize, Serialize}; use crate::{Document, NodeId}; /// One asset: what it is called (its files' names) and the objects it exports, cropped to their -/// visual bounds. +/// visual bounds, or for a 3D asset its object's solids as a mesh file. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportAsset { pub id: u64, pub name: String, pub nodes: Vec, + /// A 3D asset's mesh format (`usda`, `usdz`, `gltf` or `obj`): its object exports as a 3D + /// file. `None` for art exported as pictures. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format_3d: Option, } /// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`… diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index cf85507c7..114235696 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -313,8 +313,8 @@ mod tests { d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); d.assets = vec![ - crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] }, - crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] }, + crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None }, + crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)], format_3d: None }, ]; d.variables.bindings.insert(NodeId(11), ["v".to_string()].into()); d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index b251b15a6..ccdb902d7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -84,11 +84,12 @@ pub use reshape::{ }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ - EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, - three_d_params_as, validate_solid, validate_three_d, view_options, + EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, + three_d_meshes, three_d_params_as, validate_solid, validate_three_d, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use trap::TRAP; +pub use vectorcraft_three_d::Mesh; pub use warp::{WarpStyle, warp_point}; mod catalog; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index b2528818b..ba4b0e657 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -226,6 +226,45 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option { art(n, true) } +/// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on +/// the object, one in its top visible fill (else its stroke); with it on fills and strokes, one for +/// each of them. +pub(crate) fn revolve_meshes(n: &Node) -> Result, String> { + let path = profile(n).ok_or("Revolve currently requires a path or compound path")?; + let ctx = GeomContext::of(n); + let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result { + let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; + let (paint, opacity, _) = paint_of(item); + let mesh = vectorcraft_three_d::revolve_mesh(path, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) }) + }; + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + let before = n.appearance.effects.get(..index).unwrap_or_default(); + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let path = geometry(&path, before, &ctx); + let item = n + .appearance + .items + .iter() + .rev() + .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) + .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i))) + .ok_or("the revolved object has no fill or stroke")?; + return Ok(vec![mesh(&path, fx, item)?]); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { + if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) + && let Some(fx) = item.effects().get(index) + { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + out.push(mesh(&p, fx, item)?); + } + } + Ok(out) +} + fn art(n: &Node, expand: bool) -> Option { if !has_revolve(n) { return None; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3319d79af..09a2fc8ec 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -6,7 +6,7 @@ use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; -use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View}; +use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -221,6 +221,10 @@ fn union_bounds(shapes: &[Profile]) -> Option { /// The most members a 3D effect on a group builds solids from. const MAX_MEMBERS: usize = 4096; +/// The most triangles the meshes of one 3D effect hold together (about 290 MB of corners and +/// normals): past it the object is too complex to export. +const MAX_MESH_TRIANGLES: usize = 2_000_000; + /// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path, /// compound path and type in it, at any depth of its groups, each through its own geometry /// effects and painted with its own fills and strokes. Like the effect on a group in the @@ -338,6 +342,38 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, } } +/// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes, +/// a group's members from the back up, each with its shape's opacity and the region it paints. +/// The solids share the bounds of all their parts (`fallback` when none has any): one center to +/// turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines. +fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32, PathData, FillRule)>, Rect) { + let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes + .iter() + .flat_map(|s| { + let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); + s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| { + let (area, rule) = region(&s.path, s.rule, i); + (i, s.opacity, area, rule) + }) + }) + .collect(); + let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback); + (parts, bounds) +} + +/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` +/// its 3D effect, about the bounds of all of them. +fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { + if before.iter().any(|e| e.visible && is_geometry(&e.id)) + && let Some(b) = union_bounds(shapes) + { + let ctx = GeomContext::of(n); + for s in shapes { + s.path = apply_geometry_with(before, &s.path, b, &ctx); + } + } +} + /// The 3D art of one effect `fx` on `path` (already through the effects before it). /// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a /// solid from the path for the top visible fill and one from the outline of each visible stroke, @@ -363,20 +399,7 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> } return Some(out); } - // Each shape's top fill and its strokes, a group's members from the back up. - let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes - .iter() - .flat_map(|s| { - let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); - s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| { - let (area, rule) = region(&s.path, s.rule, i); - (i, s.opacity, area, rule) - }) - }) - .collect(); - // The solids share the bounds of all their shapes: one center to turn, twist and taper about, - // and Inflate's grid reaches over the strokes' outlines. - let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b); + let (parts, bounds) = solid_parts(shapes, b); let built = if fx.id == INFLATE { // One eye for every puff, as far away as the highest needs. let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect(); @@ -493,14 +516,7 @@ pub fn solid_art(n: &Node) -> Option { fn group_solid_art(n: &Node) -> Option { let (index, fx) = solid(&n.appearance.effects)?; let mut shapes = members(n, "").ok()?; - let before = n.appearance.effects.get(..index)?; - if before.iter().any(|e| e.visible && is_geometry(&e.id)) { - let b = union_bounds(&shapes)?; - let ctx = GeomContext::of(n); - for s in &mut shapes { - s.path = apply_geometry_with(before, &s.path, b, &ctx); - } - } + reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let children = art_for(n, fx, &shapes)?; let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); @@ -510,5 +526,147 @@ fn group_solid_art(n: &Node) -> Option { Some(art) } +/// One solid of a 3D object as a mesh, with the color it is painted. +#[derive(Clone, Debug, PartialEq)] +pub struct SolidMesh { + pub mesh: Mesh, + /// The paint's display color (sRGB, 0..1) and its opacity: a gradient's stops averaged, gray + /// for a pattern. + pub color: [f32; 4], +} + +/// The color a mesh of `paint` at `opacity` takes ([`SolidMesh::color`]). +pub(crate) fn mesh_color(paint: &Paint, opacity: f32) -> [f32; 4] { + let (rgb, alpha) = match paint { + Paint::Solid { color, .. } => (color.to_rgb(), 1.0), + Paint::Gradient(g) if !g.gradient.stops.is_empty() => { + let n = g.gradient.stops.len() as f32; + let mut sum = [0.0f32; 4]; + for s in &g.gradient.stops { + let [r, gr, b] = s.color.to_rgb(); + sum = [sum[0] + r, sum[1] + gr, sum[2] + b, sum[3] + s.opacity]; + } + ([sum[0] / n, sum[1] / n, sum[2] / n], sum[3] / n) + } + _ => ([0.5; 3], 1.0), + }; + let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + [unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), unit(alpha * opacity)] +} + +/// Add `mesh` to `out`, counting its triangles into `total`, or an error when that passes `limit`. +pub(crate) fn keep_mesh(out: &mut Vec, total: &mut usize, mesh: SolidMesh, limit: usize) -> Result<(), String> { + *total = total.saturating_add(mesh.mesh.triangles.len()); + if *total > limit { + return Err("The 3D object is too complex to export".into()); + } + out.push(mesh); + Ok(()) +} + +/// The solids effect `fx` builds from `shapes`, as [`art_for`] draws them, as meshes: Rotate turns +/// every fill and stroke; Extrude & Bevel and Inflate build one from each shape's top fill and one +/// from the outline of each stroke. A stroke whose solid is past the limits is left out. +fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result, String> { + let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…')); + let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; + let p = crate::merged_params(&fx.id, &fx.params); + let view = view_options(&p); + let mut out = vec![]; + let mut keep = |out: &mut Vec, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, MAX_MESH_TRIANGLES); + if fx.id == ROTATE { + for s in shapes { + for item in &s.items { + let (paint, opacity, _) = paint_of(item, s.opacity); + let (area, rule) = region(&s.path, s.rule, item); + keep(&mut out, vectorcraft_three_d::plane_mesh(&area, rule, b, view)?, mesh_color(&paint, opacity))?; + } + } + return Ok(out); + } + let (parts, bounds) = solid_parts(shapes, b); + // Inflate lays out every puff at once (they share one eye); each mesh, and each Extrude & + // Bevel solid, is built as it is reached. + let mut inflated = (fx.id == INFLATE).then(|| { + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) + }); + let extrude = extrude_options(&p); + for (item, member, area, rule) in &parts { + let item: &AppearanceItem = item; + let mesh = match inflated.as_mut() { + Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), + None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), + }; + match mesh { + Ok(mesh) => { + let (paint, opacity, _) = paint_of(item, *member); + keep(&mut out, mesh, mesh_color(&paint, opacity))?; + } + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// The solids `n`'s Extrude & Bevel, Inflate, Rotate or Revolve effect builds, as meshes with the +/// colors they are painted ([`SolidMesh`]), faces turned away from the viewer included; meshes +/// with no triangles are left out. On a group or layer, the effect's solids of its members +/// ([`members`]). The object's opacity multiplies every color's. An error when `n` carries no +/// such effect, its solids can't be built, or none has a triangle. +pub fn three_d_meshes(n: &Node) -> Result, String> { + // Triangles of every solid together, against [`MAX_MESH_TRIANGLES`]. + let mut total = 0usize; + let meshes = if crate::has_revolve(n) { + let mut out = vec![]; + for m in crate::revolve::revolve_meshes(n)? { + keep_mesh(&mut out, &mut total, m, MAX_MESH_TRIANGLES)?; + } + out + } else if !has_solid(n) { + return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into()); + } else if is_container(n) { + let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?; + let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…')); + let mut shapes = members(n, label)?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + meshes_for(fx, &shapes, &mut total)? + } else { + let (path, rule) = profile(n).ok_or("3D export needs a path, compound path, type or group")?; + let ctx = GeomContext::of(n); + let paints = solid_paints(n); + if let Some((index, fx)) = solid(&n.appearance.effects) { + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], &mut total)? + } else { + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in &paints { + if let Some((index, fx)) = solid(item.effects()) { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], &mut total)?); + } + } + out + } + }; + let meshes: Vec = meshes + .into_iter() + .filter(|m| !m.mesh.is_empty()) + .map(|mut m| { + m.color[3] *= n.opacity.clamp(0.0, 1.0); + m + }) + .collect(); + if meshes.is_empty() { + return Err("its 3D effect builds no solid".into()); + } + Ok(meshes) +} + #[cfg(test)] mod tests; + +#[cfg(test)] +mod tests_mesh; diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs new file mode 100644 index 000000000..9aca2ab8c --- /dev/null +++ b/crates/effects/src/solids/tests_mesh.rs @@ -0,0 +1,140 @@ +use serde_json::json; +use vectorcraft_doc::NodeId; +use vectorcraft_doc::color::{Color, Gradient, GradientKind, GradientPaint, GradientStop}; +use vectorcraft_geom::BezPath; + +use super::*; + +const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 }; + +fn square_node(id: u64, x: f64) -> Node { + let mut b = BezPath::new(); + b.move_to((x, 100.0)); + b.line_to((x + 100.0, 100.0)); + b.line_to((x + 100.0, 200.0)); + b.line_to((x, 200.0)); + b.close_path(); + Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 4.0)) +} + +fn fx(id: &str, params: Value) -> Effect { + crate::new_effect(id, ¶ms).unwrap() +} + +fn close(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5) +} + +#[test] +fn every_3d_effect_gives_a_mesh_for_its_fill_and_its_stroke() { + for id in SOLIDS { + let mut n = square_node(1, 100.0); + n.appearance.effects.push(fx(id, json!({}))); + let meshes = three_d_meshes(&n).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(meshes.len(), 2, "{id}: the fill's solid and the stroke's"); + assert!(close(meshes[0].color, [0.8, 0.2, 0.1, 1.0]), "{id}: {:?}", meshes[0].color); + assert!(close(meshes[1].color, [0.0, 0.0, 0.0, 1.0]), "{id}: {:?}", meshes[1].color); + assert!(meshes.iter().all(|m| !m.mesh.is_empty()), "{id}"); + } +} + +#[test] +fn the_meshes_of_one_object_share_one_triangle_budget() { + let mut n = square_node(1, 100.0); + n.appearance.effects.push(fx(EXTRUDE, json!({}))); + let meshes = three_d_meshes(&n).unwrap(); + let first = meshes[0].mesh.triangles.len(); + assert!(first > 0); + // Every mesh counts against what the ones before it used. + let (mut out, mut total) = (vec![], 0); + keep_mesh(&mut out, &mut total, meshes[0].clone(), first).unwrap(); + assert!(keep_mesh(&mut out, &mut total, meshes[1].clone(), first).is_err()); + assert_eq!(out.len(), 1); + // Building past the budget stops with an error instead of holding every mesh. + let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }]; + let Some(effect) = n.appearance.effects.first() else { panic!("no effect") }; + let mut total = 0; + assert!(meshes_for(effect, &shapes, &mut total).is_ok()); + let mut total = MAX_MESH_TRIANGLES - first + 1; + assert!(meshes_for(effect, &shapes, &mut total).is_err_and(|e| e.contains("too complex"))); +} + +#[test] +fn an_extruded_mesh_is_as_deep_as_the_effect() { + let mut n = square_node(1, 100.0); + n.appearance.items.truncate(1); + n.appearance.effects.push(fx(EXTRUDE, json!({ "depth": 30.0, "rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0 }))); + let meshes = three_d_meshes(&n).unwrap(); + let (lo, hi) = meshes[0].mesh.bounds().unwrap(); + assert!((hi[2] - lo[2] - 30.0).abs() < 1e-9 && (hi[0] - lo[0] - 100.0).abs() < 1e-9, "{lo:?} {hi:?}"); +} + +#[test] +fn revolve_gives_a_mesh_in_the_top_fill() { + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::None, 0.0)); + n.appearance.effects.push(fx(crate::REVOLVE, json!({}))); + let meshes = three_d_meshes(&n).unwrap(); + assert_eq!(meshes.len(), 1); + assert!(close(meshes[0].color, [0.8, 0.2, 0.1, 1.0])); + assert!(meshes[0].mesh.triangles.len() >= 64 * 3, "three profile edges swept in 64 steps"); +} + +#[test] +fn an_object_without_a_3d_effect_has_no_mesh() { + let mut n = square_node(1, 100.0); + assert!(three_d_meshes(&n).is_err()); + n.appearance.effects.push(fx(EXTRUDE, json!({}))); + n.appearance.effects[0].visible = false; + assert!(three_d_meshes(&n).is_err(), "a hidden effect builds nothing"); +} + +#[test] +fn the_object_and_the_paint_opacities_multiply() { + let mut n = square_node(1, 100.0); + n.appearance.items.truncate(1); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.opacity = 0.5; + } + n.opacity = 0.5; + n.appearance.effects.push(fx(EXTRUDE, json!({}))); + let meshes = three_d_meshes(&n).unwrap(); + assert!((meshes[0].color[3] - 0.25).abs() < 1e-6, "{:?}", meshes[0].color); +} + +#[test] +fn an_effect_on_one_fill_gives_that_fills_mesh_alone() { + let mut n = square_node(1, 100.0); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.get_mut(1) { + s.effects.push(fx(EXTRUDE, json!({}))); + } + let meshes = three_d_meshes(&n).unwrap(); + assert_eq!(meshes.len(), 1); + assert!(close(meshes[0].color, [0.0, 0.0, 0.0, 1.0])); +} + +#[test] +fn a_group_gives_a_mesh_for_each_member() { + let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(square_node(2, 300.0))]); + g.appearance.effects.push(fx(EXTRUDE, json!({ "rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0 }))); + let meshes = three_d_meshes(&g).unwrap(); + assert_eq!(meshes.len(), 4, "each member's fill and stroke"); + // The members turn about their shared centre: the first is left of it, the second right. + let (lo, _) = meshes[0].mesh.bounds().unwrap(); + let (_, hi) = meshes[2].mesh.bounds().unwrap(); + assert!(lo[0] < -150.0 && hi[0] > 150.0, "{lo:?} {hi:?}"); +} + +#[test] +fn a_gradient_takes_the_average_of_its_stops() { + let stops = + vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)) }]; + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + assert!(close(mesh_color(&paint, 1.0), [0.5, 0.0, 0.5, 0.75])); + let pattern = Paint::Pattern { pattern: "x".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + assert!(close(mesh_color(&pattern, 0.5), [0.5, 0.5, 0.5, 0.5])); +} diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 133fb1fce..fd273e067 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -3,12 +3,17 @@ //! settings Export for Screens shares (`document.exportSettings`). File → Export Selection collects //! the selection this way and opens Export for Screens on its Assets tab (a UI command). +use std::collections::HashSet; + use serde_json::{Map, Value, json}; use vectorcraft_doc::{ExportAsset, NodeId}; use super::edit::{roots_of, selected_roots}; -use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings, with_screen_export_preset}; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param}; +use super::fileio::{ + DEFAULT_FORMAT_3D, FORMATS_3D, SCREEN_SETTINGS_KEYS, check_screen_settings, create_dir, export_crop, export_screens, mesh_file_names, mesh_files, + safe_file_name, store_screen_settings, with_screen_export_preset, write_file, +}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param, switch_or, text_param}; /// The longest asset name kept (characters). const MAX_NAME: usize = 255; @@ -39,7 +44,7 @@ pub fn specs() -> Vec { "Assets", [], None, - "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order", + "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order", has_doc, list ), @@ -48,10 +53,37 @@ pub fn specs() -> Vec { "Export Assets", [], None, - "{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", has_doc, export ), + cmd!( + "assets.add3D", + "Export 3D Object", + [], + None, + "{ids?: [id…] (default: the selected objects), format?: usda|usdz|gltf|obj (default usda)} collect each object with an Extrude & Bevel, Revolve, Inflate or Rotate effect as a 3D asset of Window › Asset Export, named after the object (else Asset 1, Asset 2…): assets.export3D writes it as a mesh file. An object already collected as a 3D asset keeps it; an object without such an effect is refused → {assets: [asset id…] (in order), added: n}", + has_doc, + add_3d + ), + cmd!( + "assets.set3DFormat", + "3D Export Format", + [], + None, + "{assets: [asset id…], format: usda|usdz|gltf|obj} the mesh format 3D assets export to (Asset Export's Format for a 3D asset) → {changed: n}", + has_doc, + set_3d_format + ), + cmd!( + "assets.export3D", + "Export 3D Assets", + [], + None, + "{assets?: [asset id…] (default every 3D asset), folder?, zip?} each 3D asset's object as a mesh file in its format (assets.set3DFormat), named after the asset: USDA or USDZ, glTF 2.0 (one .gltf, its buffer embedded) or OBJ with its .mtl (whitespace and # in an OBJ's name become _). Every solid of the object (the fill's, each stroke's, a group's members') is a mesh with a material in the color it is painted; faces turned away from the viewer are included, at the object's 3D rotation, y up, about the point it turns about, in points (glTF: meters). Files that would share a name get -2, -3…; an object with a hole that crosses its face's outline or another hole, or with more than 2,000,000 triangles, is refused → {files: [path…]} (no folder: {files: [{name, dataBase64}]}, or with zip one zip as {name, dataBase64, files})", + has_doc, + export_3d + ), cmd!( "assets.settings.set", "Asset Export Settings", @@ -115,7 +147,9 @@ fn add(s: &mut Session, p: &Value) -> Result { if name.is_some() && sets.len() > 1 { return Err(bad(C, "a name names one asset: give multiple: false, or one object")); } - let existing = |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes).map(|a| a.id); + // A 3D asset of the same art is another asset. + let existing = + |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes && a.format_3d.is_none()).map(|a| a.id); let d = &s.doc()?.doc; // Nothing new: no undo step. if sets.iter().all(|set| existing(d, set).is_some()) { @@ -134,7 +168,7 @@ fn add(s: &mut Session, p: &Value) -> Result { }; let name = name.clone().or(own).unwrap_or_else(|| d.next_asset_name()); let id = d.alloc_id().0; - d.assets.push(ExportAsset { id, name, nodes }); + d.assets.push(ExportAsset { id, name, nodes, format_3d: None }); out.push(id); added += 1; } @@ -142,6 +176,162 @@ fn add(s: &mut Session, p: &Value) -> Result { }) } +/// The format of param `format` of command `cmd` ([`FORMATS_3D`]), `default` when it is not given. +fn format_3d_param(p: &Value, cmd: &str, default: Option<&str>) -> Result { + match (p.get("format"), default) { + (None, Some(d)) => Ok(d.to_string()), + (Some(Value::String(f)), _) if FORMATS_3D.contains(&f.as_str()) => Ok(f.clone()), + _ => { + let key = "format"; + Err(bad(cmd, format!("`{key}` must be one of {}", FORMATS_3D.join(", ")))) + } + } +} + +/// The asset ids of param `key` of command `cmd`, each a 3D asset of `d`. +fn three_d_asset_ids(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result> { + let ids = asset_ids(p, key, cmd)?; + for id in &ids { + match d.asset(*id) { + None => return Err(bad(cmd, "no such assets (see assets.list)")), + Some(a) if a.format_3d.is_none() => return Err(bad(cmd, format!("asset {id} isn't a 3D asset"))), + _ => {} + } + } + Ok(ids) +} + +fn add_3d(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.add3D"; + let format = format_3d_param(p, C, Some(DEFAULT_FORMAT_3D))?; + let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? }; + if ids.is_empty() { + return Err(bad(C, "select an object with a 3D effect (or give its ids)")); + } + let d = &s.doc()?.doc; + let mut ids = d.paint_order(ids); + ids.dedup(); + for id in &ids { + let n = d.node(*id).ok_or_else(|| bad(C, format!("no such object {0}", id.0)))?; + if let Err(e) = vectorcraft_render::effects::three_d_meshes(n) { + let what = n.name.clone().unwrap_or_else(|| format!("#{}", id.0)); + return Err(bad(C, format!("{what} can't be exported in 3D: {e}"))); + } + } + let existing = |d: &vectorcraft_doc::Document, id: NodeId| d.assets.iter().find(|a| a.nodes == [id] && a.format_3d.is_some()).map(|a| a.id); + // Nothing new: no undo step. + if ids.iter().all(|id| existing(d, *id).is_some()) { + return Ok(json!({ "assets": ids.iter().filter_map(|id| existing(d, *id)).collect::>(), "added": 0 })); + } + s.edit("Export 3D Object", |d, _| { + let (mut out, mut added) = (vec![], 0); + for node in ids { + if let Some(id) = existing(d, node) { + out.push(id); + continue; + } + let name = d.node(node).and_then(|n| n.name.as_deref()).and_then(clean_name).unwrap_or_else(|| d.next_asset_name()); + let id = d.alloc_id().0; + d.assets.push(ExportAsset { id, name, nodes: vec![node], format_3d: Some(format.clone()) }); + out.push(id); + added += 1; + } + Ok(json!({ "assets": out, "added": added })) + }) +} + +fn set_3d_format(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.set3DFormat"; + let d = &s.doc()?.doc; + let ids = three_d_asset_ids(d, p, "assets", C)?; + let format = format_3d_param(p, C, None)?; + let changed = d.assets.iter().filter(|a| ids.contains(&a.id) && a.format_3d.as_deref() != Some(format.as_str())).count(); + // Nothing changes: no undo step. + if changed == 0 { + return Ok(json!({ "changed": 0 })); + } + s.edit("3D Export Format", |d, _| { + for a in d.assets.iter_mut().filter(|a| ids.contains(&a.id)) { + a.format_3d = Some(format.clone()); + } + Ok(json!({ "changed": changed })) + }) +} + +fn export_3d(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.export3D"; + let d = &s.doc()?.doc; + let ids = if p.get("assets").is_some_and(|v| !v.is_null()) { + three_d_asset_ids(d, p, "assets", C)? + } else { + d.assets.iter().filter(|a| a.format_3d.is_some()).map(|a| a.id).collect() + }; + if ids.is_empty() { + return Err(bad(C, "no 3D assets yet: collect an object with a 3D effect first (assets.add3D)")); + } + let folder = text_param(p, "folder", C)?.filter(|f| !f.is_empty()); + let zip = switch_or(p, "zip", false, C)?; + // Every file's name, compared without case: a later file that would take one gets -2, -3… + let mut taken: HashSet = HashSet::new(); + let mut files: Vec<(String, Vec)> = vec![]; + for id in ids { + let Some(a) = d.asset(id) else { continue }; + let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D); + let mut parts = vec![]; + for node in &a.nodes { + let n = d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))?; + let what = &a.name; + parts.extend(vectorcraft_render::effects::three_d_meshes(n).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?); + } + let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into()); + let mut stem = base.clone(); + let mut k = 1; + loop { + let names = mesh_file_names(&stem, format).map_err(|e| bad(C, e))?; + if names.iter().all(|n| !taken.contains(&n.to_lowercase())) { + break; + } + k += 1; + stem = format!("{base}-{k}"); + } + let made = mesh_files(&stem, format, &parts).map_err(|e| bad(C, e))?; + taken.extend(made.iter().map(|(n, _)| n.to_lowercase())); + files.extend(made); + } + let names: Vec<&str> = files.iter().map(|(n, _)| n.as_str()).collect(); + if zip { + let archive = super::fileio::zip::store(&files).map_err(|e| bad(C, e))?; + // "-3D" keeps it apart from the picture assets' ZIP, which is named after the document. + let name = Some(safe_file_name(&d.title)).filter(|t| !t.is_empty()).map_or_else(|| "3D Assets.zip".into(), |t| format!("{t}-3D.zip")); + return match folder { + Some(dir) => { + create_dir(dir)?; + let path = format!("{dir}/{name}"); + write_file(&path, &archive)?; + Ok(json!({ "path": path, "bytes": archive.len(), "files": names })) + } + None => Ok(json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&archive), "bytes": archive.len(), "files": names })), + }; + } + match folder { + Some(dir) => { + create_dir(dir)?; + let mut written = vec![]; + for (name, bytes) in &files { + let path = format!("{dir}/{name}"); + write_file(&path, bytes)?; + written.push(path); + } + Ok(json!({ "files": written })) + } + None => { + let files: Vec = + files.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect(); + Ok(json!({ "files": files })) + } + } +} + /// The asset ids of param `key`. fn asset_ids(p: &Value, key: &str, cmd: &str) -> Result> { let ids = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be an array of asset ids (see assets.list)")))?; @@ -186,6 +376,7 @@ fn list(s: &mut Session, _: &Value) -> Result { "name": a.name, "nodes": a.nodes.iter().map(|n| n.0).collect::>(), "bounds": bounds.map(|b| [b.x0, b.y0, b.width(), b.height()]), + "format3D": a.format_3d, }) }) .collect(); @@ -227,8 +418,10 @@ fn export(s: &mut Session, p: &Value) -> Result { if q.get("subfolders").and_then(Value::as_bool) == Some(false) && p.get("folders").is_none() { q.insert("folders".into(), json!("none")); } + let d = &s.doc()?.doc; + // 3D assets are written as mesh files, by assets.export3D: the export refuses one it is given. if !q.contains_key("assets") { - let all: Vec = s.doc()?.doc.assets.iter().map(|a| a.id).collect(); + let all: Vec = d.assets.iter().filter(|a| a.format_3d.is_none()).map(|a| a.id).collect(); if all.is_empty() { return Err(bad(C, "no assets yet: collect art for export first (assets.add)")); } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 3f881b8e4..c453fecd7 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -17,6 +17,7 @@ //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `metafile`: EMF and WMF export, open and place. +//! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`). //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -32,6 +33,7 @@ mod export; pub(crate) mod imagemap; mod legacy_ai; mod load; +mod mesh3d; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; mod heifread; mod jp2read; @@ -74,6 +76,8 @@ pub use load::{ read_for_open, texture_image, }; pub use load::{losses_summary, overwrite_losses}; +pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label}; +pub(crate) use mesh3d::{mesh_file_names, mesh_files}; pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name}; pub(crate) use native::preview_png; pub use native::with_compression_pref; diff --git a/crates/engine/src/cmd/fileio/mesh3d.rs b/crates/engine/src/cmd/fileio/mesh3d.rs new file mode 100644 index 000000000..50a919d83 --- /dev/null +++ b/crates/engine/src/cmd/fileio/mesh3d.rs @@ -0,0 +1,260 @@ +//! 3D objects as mesh files, for a 3D asset of Window › Asset Export (`assets.export3D`): OBJ with +//! its MTL materials, glTF 2.0 (one `.gltf` file, its buffer embedded as a data URI), USDA and +//! USDZ (the USDA layer in an uncompressed ZIP, its data on a 64-byte boundary). +//! +//! Each solid of the object ([`vectorcraft_render::effects::three_d_meshes`]) is its own mesh, with a +//! material in the color it is painted (solids of one color share it). Positions are y up and +//! about the point the object turns about: in points in OBJ and USD (whose layer says how many +//! meters a unit is), in meters in glTF, as its specification asks. glTF and USD colors are linear, +//! as their specifications ask; MTL colors are the display colors. Materials are two-sided, since +//! an open solid or a turned plane is seen from both sides. +//! +//! Formats: glTF 2.0 , USD +//! (USDZ: ). + +use std::fmt::Write as _; + +use serde_json::{Value, json}; +use vectorcraft_render::effects::SolidMesh; + +/// The formats a 3D asset exports to, as `assets.set3DFormat` names them: USDA first, the default. +pub const FORMATS_3D: [&str; 4] = ["usda", "usdz", "gltf", "obj"]; +/// The format a new 3D asset exports to. +pub const DEFAULT_FORMAT_3D: &str = "usda"; +/// The meters in a point (1/72 inch). +const METERS_PER_POINT: f64 = 0.0254 / 72.0; + +/// The label of `format` for menus: `USDA`, `USDZ`, `glTF`, `OBJ`. +pub fn format_3d_label(format: &str) -> &'static str { + match format { + "usdz" => "USDZ", + "gltf" => "glTF", + "obj" => "OBJ", + _ => "USDA", + } +} + +/// `stem` as an OBJ file name: whitespace and `#` become `_`, so the `mtllib` line names the MTL +/// file as one word. +fn obj_stem(stem: &str) -> String { + stem.chars().map(|c| if c.is_whitespace() || c == '#' { '_' } else { c }).collect() +} + +/// The names of the files [`mesh_files`] writes for `stem` in `format`. +pub(crate) fn mesh_file_names(stem: &str, format: &str) -> Result, String> { + match format { + "obj" => { + let stem = obj_stem(stem); + Ok(vec![format!("{stem}.obj"), format!("{stem}.mtl")]) + } + "gltf" | "usda" | "usdz" => Ok(vec![format!("{stem}.{format}")]), + _ => { + let key = "format"; + Err(format!("`{key}` must be one of {}", FORMATS_3D.join(", "))) + } + } +} + +/// The files of a 3D object named `stem` in `format` ([`FORMATS_3D`]): `(file name, bytes)`, the +/// OBJ with its MTL after it. +pub(crate) fn mesh_files(stem: &str, format: &str, parts: &[SolidMesh]) -> Result)>, String> { + let names = mesh_file_names(stem, format)?; + let bytes = match format { + "obj" => { + let (obj, mtl) = obj(&obj_stem(stem), parts); + vec![obj.into_bytes(), mtl.into_bytes()] + } + "gltf" => vec![gltf(stem, parts).into_bytes()], + "usda" => vec![usda(stem, parts).into_bytes()], + _ => vec![super::zip::store_aligned(&[(format!("{stem}.usda"), usda(stem, parts).into_bytes())], 64)?], + }; + Ok(names.into_iter().zip(bytes).collect()) +} + +/// The distinct colors of `parts` in order, and each part's index among them. +fn materials(parts: &[SolidMesh]) -> (Vec<[f32; 4]>, Vec) { + let mut colors: Vec<[f32; 4]> = vec![]; + let index = parts + .iter() + .map(|p| { + colors.iter().position(|c| *c == p.color).unwrap_or_else(|| { + colors.push(p.color); + colors.len() - 1 + }) + }) + .collect(); + (colors, index) +} + +/// An sRGB channel (0..1) as linear light. +fn linear(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +/// A number as text: the shortest decimal that reads back as the same value. +fn num(v: f64) -> String { + format!("{v}") +} + +fn obj(stem: &str, parts: &[SolidMesh]) -> (String, String) { + let (colors, material) = materials(parts); + let mut o = String::new(); + let _ = writeln!(o, "mtllib {stem}.mtl"); + let _ = writeln!(o, "o {stem}"); + for p in parts { + for v in &p.mesh.positions { + let _ = writeln!(o, "v {} {} {}", num(v[0]), num(v[1]), num(v[2])); + } + } + for p in parts { + for n in &p.mesh.normals { + let _ = writeln!(o, "vn {} {} {}", num(n[0]), num(n[1]), num(n[2])); + } + } + let mut base = 1usize; + for (k, p) in parts.iter().enumerate() { + let _ = writeln!(o, "g Solid_{}", k + 1); + let _ = writeln!(o, "usemtl Material_{}", material.get(k).copied().unwrap_or(0) + 1); + for t in &p.mesh.triangles { + let [a, b, c] = t.map(|i| i as usize + base); + let _ = writeln!(o, "f {a}//{a} {b}//{b} {c}//{c}"); + } + base += p.mesh.positions.len(); + } + let mut m = String::new(); + for (k, c) in colors.iter().enumerate() { + let _ = writeln!(m, "newmtl Material_{}", k + 1); + let _ = writeln!(m, "Kd {} {} {}", num(f64::from(c[0])), num(f64::from(c[1])), num(f64::from(c[2]))); + let _ = writeln!(m, "d {}", num(f64::from(c[3]))); + let _ = writeln!(m, "illum 1"); + } + (o, m) +} + +fn gltf(stem: &str, parts: &[SolidMesh]) -> String { + let (colors, material) = materials(parts); + let mut buffer: Vec = vec![]; + let (mut views, mut accessors, mut primitives) = (vec![], vec![], vec![]); + let mut view = |buffer: &mut Vec, bytes: Vec, target: u32| -> usize { + views.push(json!({ "buffer": 0, "byteOffset": buffer.len(), "byteLength": bytes.len(), "target": target })); + buffer.extend(bytes); + views.len() - 1 + }; + for (k, p) in parts.iter().enumerate() { + let floats = |v: &[[f64; 3]], scale: f64| v.iter().flatten().flat_map(|x| ((x * scale) as f32).to_le_bytes()).collect::>(); + let (lo, hi) = p.mesh.bounds().unwrap_or_default(); + let positions = view(&mut buffer, floats(&p.mesh.positions, METERS_PER_POINT), 34962); + let normals = view(&mut buffer, floats(&p.mesh.normals, 1.0), 34962); + let indices = view(&mut buffer, p.mesh.triangles.iter().flatten().flat_map(|i| i.to_le_bytes()).collect(), 34963); + let count = p.mesh.positions.len(); + let at = accessors.len(); + accessors.push(json!({ + "bufferView": positions, "componentType": 5126, "count": count, "type": "VEC3", + "min": lo.map(|v| (v * METERS_PER_POINT) as f32), "max": hi.map(|v| (v * METERS_PER_POINT) as f32), + })); + accessors.push(json!({ "bufferView": normals, "componentType": 5126, "count": count, "type": "VEC3" })); + accessors.push(json!({ "bufferView": indices, "componentType": 5125, "count": p.mesh.triangles.len() * 3, "type": "SCALAR" })); + primitives.push(json!({ + "attributes": { "POSITION": at, "NORMAL": at + 1 }, + "indices": at + 2, + "material": material.get(k).copied().unwrap_or(0), + })); + } + let materials: Vec = colors + .iter() + .enumerate() + .map(|(k, c)| { + let mut m = json!({ + "name": format!("Material_{}", k + 1), + "pbrMetallicRoughness": { + "baseColorFactor": [linear(c[0]), linear(c[1]), linear(c[2]), c[3]], + "metallicFactor": 0.0, + "roughnessFactor": 1.0, + }, + "doubleSided": true, + }); + if c[3] < 1.0 { + m["alphaMode"] = json!("BLEND"); + } + m + }) + .collect(); + let doc = json!({ + "asset": { "version": "2.0", "generator": "VectorCraft" }, + "scene": 0, + "scenes": [{ "nodes": [0] }], + "nodes": [{ "name": stem, "mesh": 0 }], + "meshes": [{ "name": stem, "primitives": primitives }], + "materials": materials, + "buffers": [{ + "byteLength": buffer.len(), + "uri": format!("data:application/octet-stream;base64,{}", vectorcraft_format::base64_encode(&buffer)), + }], + "bufferViews": views, + "accessors": accessors, + }); + serde_json::to_string_pretty(&doc).unwrap_or_default() +} + +/// `name` as a USD prim name: letters, digits and `_`, not starting with a digit. +fn prim_name(name: &str) -> String { + let mut out: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect(); + if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +fn usda(stem: &str, parts: &[SolidMesh]) -> String { + let (colors, material) = materials(parts); + let root = prim_name(stem); + let triple = |v: [f64; 3]| format!("({}, {}, {})", num(v[0]), num(v[1]), num(v[2])); + let list = |items: Vec| items.join(", "); + let mut u = String::new(); + let _ = writeln!(u, "#usda 1.0"); + let _ = writeln!(u, "(\n defaultPrim = \"{root}\"\n metersPerUnit = {}\n upAxis = \"Y\"\n)\n", METERS_PER_POINT); + let _ = writeln!(u, "def Xform \"{root}\"\n{{"); + for (k, p) in parts.iter().enumerate() { + let (lo, hi) = p.mesh.bounds().unwrap_or_default(); + let m = material.get(k).copied().unwrap_or(0) + 1; + let _ = writeln!(u, " def Mesh \"Solid_{}\" (\n prepend apiSchemas = [\"MaterialBindingAPI\"]\n )\n {{", k + 1); + let _ = writeln!(u, " uniform bool doubleSided = 1"); + let _ = writeln!(u, " float3[] extent = [{}, {}]", triple(lo), triple(hi)); + let _ = writeln!(u, " int[] faceVertexCounts = [{}]", list(vec!["3".to_string(); p.mesh.triangles.len()])); + let _ = writeln!(u, " int[] faceVertexIndices = [{}]", list(p.mesh.triangles.iter().flatten().map(u32::to_string).collect())); + let _ = writeln!(u, " rel material:binding = "); + let _ = writeln!( + u, + " normal3f[] normals = [{}] (\n interpolation = \"vertex\"\n )", + list(p.mesh.normals.iter().map(|n| triple(*n)).collect()) + ); + let _ = writeln!(u, " point3f[] points = [{}]", list(p.mesh.positions.iter().map(|v| triple(*v)).collect())); + let c = p.color; + let _ = writeln!( + u, + " color3f[] primvars:displayColor = [({}, {}, {})] (\n interpolation = \"constant\"\n )", + linear(c[0]), + linear(c[1]), + linear(c[2]) + ); + let _ = writeln!(u, " float[] primvars:displayOpacity = [{}] (\n interpolation = \"constant\"\n )", c[3]); + let _ = writeln!(u, " uniform token subdivisionScheme = \"none\"\n }}\n"); + } + let _ = writeln!(u, " def Scope \"Materials\"\n {{"); + for (k, c) in colors.iter().enumerate() { + let m = k + 1; + let _ = writeln!(u, " def Material \"Material_{m}\"\n {{"); + let _ = writeln!(u, " token outputs:surface.connect = \n"); + let _ = writeln!(u, " def Shader \"Surface\"\n {{"); + let _ = writeln!(u, " uniform token info:id = \"UsdPreviewSurface\""); + let _ = writeln!(u, " color3f inputs:diffuseColor = ({}, {}, {})", linear(c[0]), linear(c[1]), linear(c[2])); + let _ = writeln!(u, " float inputs:opacity = {}", c[3]); + let _ = writeln!(u, " float inputs:roughness = 1"); + let _ = writeln!(u, " token outputs:surface\n }}\n }}"); + } + let _ = writeln!(u, " }}\n}}"); + u +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/fileio/mesh3d/tests.rs b/crates/engine/src/cmd/fileio/mesh3d/tests.rs new file mode 100644 index 000000000..7c467fafa --- /dev/null +++ b/crates/engine/src/cmd/fileio/mesh3d/tests.rs @@ -0,0 +1,130 @@ +use serde_json::Value; +use vectorcraft_render::effects::{Mesh, SolidMesh}; + +use super::*; + +/// A 10 × 10 square facing the viewer: two triangles, each with corners of its own. +fn square(color: [f32; 4]) -> SolidMesh { + let [a, b, c, d] = [[-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]]; + let mesh = Mesh { positions: vec![a, b, c, a, c, d], normals: vec![[0.0, 0.0, 1.0]; 6], triangles: vec![[0, 1, 2], [3, 4, 5]] }; + SolidMesh { mesh, color } +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 0.5]; + +fn text(files: &[(String, Vec)], name: &str) -> String { + let (_, bytes) = files.iter().find(|(n, _)| n == name).unwrap_or_else(|| panic!("no {name}")); + String::from_utf8(bytes.clone()).unwrap() +} + +#[test] +fn obj_numbers_corners_across_solids_and_names_its_materials() { + let files = mesh_files("Box", "obj", &[square(RED), square(BLUE), square(RED)]).unwrap(); + assert_eq!(files.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box.obj", "Box.mtl"]); + let obj = text(&files, "Box.obj"); + assert!(obj.starts_with("mtllib Box.mtl\no Box\n")); + assert_eq!(obj.lines().filter(|l| l.starts_with("v ")).count(), 18); + assert_eq!(obj.lines().filter(|l| l.starts_with("vn ")).count(), 18); + // The third solid's last triangle uses corners 13 to 18; red solids share Material_1. + assert_eq!(obj.lines().rfind(|l| l.starts_with("f ")), Some("f 16//16 17//17 18//18")); + assert_eq!(obj.lines().filter(|l| l.starts_with("usemtl ")).collect::>(), ["usemtl Material_1", "usemtl Material_2", "usemtl Material_1"]); + let mtl = text(&files, "Box.mtl"); + assert_eq!(mtl.matches("newmtl").count(), 2); + assert!(mtl.contains("newmtl Material_2\nKd 0 0 1\nd 0.5\n"), "{mtl}"); +} + +#[test] +fn gltf_holds_its_buffer_and_every_accessor_fits_it() { + let files = mesh_files("Box", "gltf", &[square(RED), square(BLUE)]).unwrap(); + let g: Value = serde_json::from_str(&text(&files, "Box.gltf")).unwrap(); + assert_eq!(g["asset"]["version"], "2.0"); + let uri = g["buffers"][0]["uri"].as_str().unwrap(); + let data = vectorcraft_format::base64_decode(uri.strip_prefix("data:application/octet-stream;base64,").unwrap()).unwrap(); + assert_eq!(g["buffers"][0]["byteLength"].as_u64(), Some(data.len() as u64)); + for v in g["bufferViews"].as_array().unwrap() { + assert!(v["byteOffset"].as_u64().unwrap() + v["byteLength"].as_u64().unwrap() <= data.len() as u64); + assert_eq!(v["byteOffset"].as_u64().unwrap() % 4, 0); + } + let prims = g["meshes"][0]["primitives"].as_array().unwrap(); + assert_eq!(prims.len(), 2); + assert_eq!((prims[0]["material"].as_u64(), prims[1]["material"].as_u64()), (Some(0), Some(1))); + // Positions are meters: the 10 pt square is 10/72 inch across. + let pos = &g["accessors"][prims[0]["attributes"]["POSITION"].as_u64().unwrap() as usize]; + let width = pos["max"][0].as_f64().unwrap() - pos["min"][0].as_f64().unwrap(); + assert!((width - 10.0 * 0.0254 / 72.0).abs() < 1e-7, "{width}"); + let index = &g["accessors"][prims[0]["indices"].as_u64().unwrap() as usize]; + assert_eq!((index["componentType"].as_u64(), index["count"].as_u64()), (Some(5125), Some(6))); + // Translucent paint blends; colors are linear. + assert_eq!(g["materials"][1]["alphaMode"], "BLEND"); + assert!(g["materials"][0].get("alphaMode").is_none()); + assert_eq!(g["materials"][0]["doubleSided"], true); +} + +#[test] +fn usda_is_y_up_in_points_with_a_material_for_each_color() { + let files = mesh_files("My Box", "usda", &[square(RED), square([0.5, 0.5, 0.5, 1.0])]).unwrap(); + let u = text(&files, "My Box.usda"); + assert!(u.starts_with("#usda 1.0\n")); + assert!(u.contains("defaultPrim = \"My_Box\"") && u.contains("upAxis = \"Y\"") && u.contains("def Xform \"My_Box\""), "{u}"); + assert!(u.contains(&format!("metersPerUnit = {}", 0.0254 / 72.0))); + assert_eq!(u.matches("def Mesh").count(), 2); + assert_eq!(u.matches("def Material").count(), 2); + assert!(u.contains("int[] faceVertexCounts = [3, 3]")); + assert!(u.contains("rel material:binding = ")); + // sRGB 0.5 is about 0.214 linear. + assert!(u.contains("color3f inputs:diffuseColor = (0.21404"), "{u}"); + assert_eq!(u.matches('{').count(), u.matches('}').count()); +} + +#[test] +fn usdz_stores_the_layer_uncompressed_on_a_64_byte_boundary() { + let files = mesh_files("Box", "usdz", &[square(RED)]).unwrap(); + let (name, zip) = &files[0]; + assert_eq!(name, "Box.usdz"); + // The local header: 30 bytes, the name, then the extra field. + let u16_at = |at: usize| u16::from_le_bytes([zip[at], zip[at + 1]]) as usize; + assert_eq!(&zip[..4], &[0x50, 0x4b, 0x03, 0x04]); + assert_eq!(u16_at(8), 0, "stored, not deflated"); + let start = 30 + u16_at(26) + u16_at(28); + assert_eq!(start % 64, 0); + let layer = mesh_files("Box", "usda", &[square(RED)]).unwrap().remove(0).1; + assert_eq!(&zip[start..start + layer.len()], layer.as_slice()); + let archive = super::super::zip::Archive::open(zip, 8).unwrap(); + assert_eq!(archive.read("Box.usda", usize::MAX).unwrap(), Some(layer)); +} + +#[test] +fn an_obj_names_its_mtl_file_without_spaces_or_comment_marks() { + let files = mesh_files("Box #1", "obj", &[square(RED)]).unwrap(); + assert_eq!(files.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box__1.obj", "Box__1.mtl"]); + assert!(text(&files, "Box__1.obj").starts_with("mtllib Box__1.mtl\n")); + assert_eq!(mesh_file_names("Box #1", "obj").unwrap(), ["Box__1.obj", "Box__1.mtl"]); + assert_eq!(mesh_file_names("Box #1", "usda").unwrap(), ["Box #1.usda"]); +} + +#[test] +fn obj_and_usda_keep_coordinates_a_32_bit_float_would_merge() { + let mut part = square(RED); + part.mesh.positions[0] = [20_000_000.0, 0.0, 0.0]; + part.mesh.positions[1] = [20_000_001.0, 0.0, 0.0]; + let obj = text(&mesh_files("Box", "obj", std::slice::from_ref(&part)).unwrap(), "Box.obj"); + assert!(obj.contains("v 20000000 0 0\nv 20000001 0 0\n"), "{obj}"); + let usda = text(&mesh_files("Box", "usda", &[part]).unwrap(), "Box.usda"); + assert!(usda.contains("(20000001, 0, 0)")); +} + +#[test] +fn an_unknown_format_is_refused() { + assert!(mesh_files("Box", "fbx", &[square(RED)]).is_err()); + assert!(mesh_file_names("Box", "").is_err()); + assert_eq!(mesh_file_names("Box", "obj").unwrap(), ["Box.obj", "Box.mtl"]); +} + +#[test] +fn prim_names_are_usd_identifiers() { + assert_eq!(prim_name("Logo 2"), "Logo_2"); + assert_eq!(prim_name("3D"), "_3D"); + assert_eq!(prim_name(""), "_"); + assert_eq!(prim_name("é/x"), "__x"); +} diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 11e878bc1..b69cfd177 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -1210,6 +1210,9 @@ fn items( let mut pieces = vec![]; for id in ids { let a = doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))?; + if a.format_3d.is_some() { + return Err(bad(C, format!("asset {id} is a 3D asset: assets.export3D writes it"))); + } let (d, r) = alone(&a.nodes, &a.name).ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?; pieces.push((a.name.clone(), d, Some(0), r)); } diff --git a/crates/engine/src/cmd/fileio/zip.rs b/crates/engine/src/cmd/fileio/zip.rs index 8279b368f..047fcb56e 100644 --- a/crates/engine/src/cmd/fileio/zip.rs +++ b/crates/engine/src/cmd/fileio/zip.rs @@ -23,6 +23,13 @@ pub const DEFLATE_CHUNK: usize = 1 << 20; /// The archive of `files` (`(name, bytes)`; `/` separates folders in a name), stored without /// compression. Fails past the limits of a ZIP without its 64-bit extension (65534 files, 4 GB; 65535 is the ZIP64 marker). pub fn store, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, String> { + store_aligned(files, 1) +} + +/// [`store`], with each file's data starting at a multiple of `align` bytes from the start of the +/// archive (USDZ asks for 64): a padding block in the local header's extra field (header id +/// 0x1986) moves it there. +pub fn store_aligned, B: AsRef<[u8]>>(files: &[(N, B)], align: usize) -> Result, String> { let too_big = || "too much to zip: at most 65534 files and 4 GB".to_string(); let count = u16::try_from(files.len()).ok().filter(|&c| c != u16::MAX).ok_or_else(too_big)?; let mut out = Vec::with_capacity(files.iter().map(|(n, b)| 76 + 2 * n.as_ref().len() + b.as_ref().len()).sum::() + 22); @@ -47,8 +54,21 @@ pub fn store, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); out.extend_from_slice(&VERSION.to_le_bytes()); out.extend_from_slice(&shared); - out.extend_from_slice(&0u16.to_le_bytes()); // extra field length + // The data would start after the header (30 bytes) and the name; a padding block of at + // least its own 4-byte header moves it to the boundary. + let start = out.len() + 2 + name.len(); + let mut pad = if align > 1 { (align - start % align) % align } else { 0 }; + if pad > 0 && pad < 4 { + pad += align; + } + let pad_len = u16::try_from(pad).map_err(|_| format!("cannot align zip entries to {align} bytes"))?; + out.extend_from_slice(&pad_len.to_le_bytes()); // extra field length out.extend_from_slice(name); + if pad > 0 { + out.extend_from_slice(&0x1986u16.to_le_bytes()); + out.extend_from_slice(&(pad_len - 4).to_le_bytes()); + out.resize(out.len() + pad - 4, 0); + } out.extend_from_slice(bytes); // Central directory record. central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2eb70a449..4676474b7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10,6 +10,7 @@ mod ai_open_path; mod appearance; mod areafit; mod assets; +mod assets_3d; mod attributes; mod bboxrotate; mod blendfidelity; diff --git a/crates/engine/src/tests/assets_3d.rs b/crates/engine/src/tests/assets_3d.rs new file mode 100644 index 000000000..cdef5b1df --- /dev/null +++ b/crates/engine/src/tests/assets_3d.rs @@ -0,0 +1,220 @@ +//! 3D assets: Export 3D Object collects objects with a 3D effect as 3D assets, their Format picks +//! the mesh file (USDA by default, USDZ, glTF or OBJ), and `assets.export3D` writes them, apart from +//! the picture assets `assets.export` writes. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + // Unstroked art: each object is one solid. + s.paint.stroke = Paint::None; + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// A rectangle with Extrude & Bevel, named `name`. +fn extruded(s: &mut Session, x: f64, name: &str) -> u64 { + let id = run(s, "shape.rectangle", json!({"x": x, "y": 10.0, "width": 40.0, "height": 30.0}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [id]})); + run(s, "effect.apply", json!({"effect": "threeD.extrude", "params": {"depth": 20.0}})); + run(s, "layer.setProps", json!({"id": id, "name": name})); + id +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// `(name, bytes)` of each file an export returned. +fn files(r: &Value) -> Vec<(String, Vec)> { + r["files"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["name"].as_str().unwrap().to_string(), vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap())) + .collect() +} + +#[test] +fn export_3d_object_collects_each_3d_object_as_a_usda_asset() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let b = extruded(&mut s, 100.0, "Crate"); + run(&mut s, "select.set", json!({"ids": [a, b]})); + let before = undo_len(&s); + let r = run(&mut s, "assets.add3D", json!({})); + assert_eq!(r["added"], 2); + assert_eq!(undo_len(&s), before + 1, "one undo step for every object"); + let list = run(&mut s, "assets.list", json!({}))["assets"].as_array().unwrap().clone(); + assert_eq!( + list.iter().map(|a| (a["name"].clone(), a["format3D"].clone())).collect::>(), + [(json!("Box"), json!("usda")), (json!("Crate"), json!("usda"))] + ); + // Collected again: the same assets, and no undo step. + let again = run(&mut s, "assets.add3D", json!({"ids": [a]})); + assert_eq!((again["added"].clone(), again["assets"][0].clone()), (json!(0), r["assets"][0].clone())); + assert_eq!(undo_len(&s), before + 1); + // Collect for Export of the same object makes a picture asset beside it. + assert_eq!(run(&mut s, "assets.add", json!({"ids": [a]}))["added"], 1); + // Undo takes the 3D assets back out in one step. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().doc.assets.is_empty()); +} + +#[test] +fn export_3d_object_refuses_objects_without_a_3d_effect_and_bad_formats() { + let mut s = session(); + let plain = run(&mut s, "shape.rectangle", json!({"x": 10.0, "y": 10.0, "width": 40.0, "height": 30.0}))["id"].as_u64().unwrap(); + let box3d = extruded(&mut s, 100.0, "Box"); + let before = undo_len(&s); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d, plain]})).is_err(), "one object without a 3D effect refuses them all"); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d], "format": "fbx"})).is_err()); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d], "format": 3})).is_err()); + assert!(s.execute("assets.add3D", &json!({"ids": [999_999]})).is_err()); + run(&mut s, "select.set", json!({"ids": []})); + assert!(s.execute("assets.add3D", &json!({})).is_err(), "nothing selected"); + assert!(s.doc().unwrap().doc.assets.is_empty()); + assert_eq!(undo_len(&s), before); + run(&mut s, "select.set", json!({"ids": [box3d]})); + let r = run(&mut s, "assets.add3D", json!({"format": "gltf"})); + assert_eq!(run(&mut s, "assets.list", json!({}))["assets"][0]["format3D"], "gltf"); + assert_eq!(r["added"], 1); +} + +#[test] +fn the_format_is_set_per_asset_and_only_a_change_is_an_undo_step() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let picture = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let before = undo_len(&s); + assert_eq!(run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": "usda"}))["changed"], 0); + assert_eq!(undo_len(&s), before, "no change, no undo step"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [asset], "format": "stl"})).is_err()); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [asset]})).is_err(), "the format is required"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [picture], "format": "obj"})).is_err(), "a picture asset has no 3D format"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [12345], "format": "obj"})).is_err()); + assert_eq!(run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": "obj"}))["changed"], 1); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().doc.asset(asset).unwrap().format_3d.as_deref(), Some("obj")); +} + +#[test] +fn export_3d_writes_each_format() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let cases = [("usda", vec!["Box.usda"]), ("usdz", vec!["Box.usdz"]), ("gltf", vec!["Box.gltf"]), ("obj", vec!["Box.obj", "Box.mtl"])]; + for (format, names) in cases { + run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": format})); + let out = files(&run(&mut s, "assets.export3D", json!({}))); + assert_eq!(out.iter().map(|(n, _)| n.as_str()).collect::>(), names, "{format}"); + assert!(out.iter().all(|(_, b)| !b.is_empty()), "{format}"); + } + // The OBJ holds the box: a rectangle's front and back and four walls, two triangles each. + let out = files(&run(&mut s, "assets.export3D", json!({}))); + let obj = String::from_utf8(out[0].1.clone()).unwrap(); + assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), 12); +} + +#[test] +fn export_3d_numbers_files_that_would_share_a_name_and_cleans_path_characters() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let b = extruded(&mut s, 100.0, "box"); + let c = extruded(&mut s, 200.0, "../a:b"); + run(&mut s, "assets.add3D", json!({"ids": [a, b, c]})); + let out = files(&run(&mut s, "assets.export3D", json!({}))); + assert_eq!(out.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box.usda", "box-2.usda", "..-a-b.usda"]); +} + +#[test] +fn picture_and_3d_assets_export_apart() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let three_d = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + // assets.export leaves 3D assets to assets.export3D. + let e = s.execute("assets.export", &json!({"formats": [{"format": "png", "scale": "1x"}]})).unwrap_err().to_string(); + assert!(e.contains("no assets yet"), "{e}"); + let e = s.execute("assets.export", &json!({"assets": [three_d]})).unwrap_err().to_string(); + assert!(e.contains("assets.export3D"), "{e}"); + let e = s.execute("document.exportForScreens", &json!({"assets": [three_d]})).unwrap_err().to_string(); + assert!(e.contains("assets.export3D"), "Export for Screens refuses it too: {e}"); + let picture = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let pngs = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}]}))); + assert_eq!(pngs.len(), 1); + assert!(s.execute("assets.export3D", &json!({"assets": [picture]})).is_err()); +} + +#[test] +fn export_3d_needs_3d_assets() { + let mut s = session(); + assert!(s.execute("assets.export3D", &json!({})).is_err()); + assert!(s.execute("assets.export3D", &json!({"assets": [77]})).is_err()); + assert!(s.execute("assets.export3D", &json!({"assets": "all"})).is_err()); +} + +#[test] +fn bad_3d_params_are_refused() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + assert!(s.execute("assets.add3D", &json!({"ids": [a], "format": null})).is_err(), "a given format must be a format"); + run(&mut s, "assets.add3D", json!({"ids": [a]})); + assert!(s.execute("assets.export3D", &json!({"folder": 123})).is_err()); + assert!(s.execute("assets.export3D", &json!({"zip": "true"})).is_err()); +} + +#[test] +fn an_asset_whose_object_lost_its_effect_is_refused_at_export() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + run(&mut s, "assets.add3D", json!({"ids": [a]})); + run(&mut s, "select.set", json!({"ids": [a]})); + run(&mut s, "effect.remove", json!({"index": 0})); + let e = s.execute("assets.export3D", &json!({})).unwrap_err().to_string(); + assert!(e.contains("Box"), "{e}"); +} + +#[test] +fn export_3d_writes_into_a_folder_or_one_zip() { + let dir = std::env::temp_dir().join(format!("vc-assets-3d-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().replace('\\', "/"); + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a], "format": "obj"}))["assets"][0].as_u64().unwrap(); + let r = run(&mut s, "assets.export3D", json!({"folder": folder})); + assert_eq!(r["files"], json!([format!("{folder}/Box.obj"), format!("{folder}/Box.mtl")])); + assert!(dir.join("Box.obj").is_file() && dir.join("Box.mtl").is_file()); + let zip = run(&mut s, "assets.export3D", json!({"assets": [asset], "zip": true})); + assert_eq!(zip["files"], json!(["Box.obj", "Box.mtl"])); + assert!(zip["name"].as_str().unwrap().ends_with("-3D.zip"), "{}", zip["name"]); + // A picture ZIP and a 3D ZIP in one folder are two files. + run(&mut s, "assets.add", json!({"ids": [a]})); + let pictures = run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}], "folder": folder, "zip": true})); + let meshes = run(&mut s, "assets.export3D", json!({"folder": folder, "zip": true})); + let (p, m) = (pictures["path"].as_str().unwrap(), meshes["path"].as_str().unwrap()); + assert_ne!(p, m); + assert!(std::path::Path::new(p).is_file() && std::path::Path::new(m).is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn three_d_assets_round_trip_through_the_native_format() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + run(&mut s, "assets.add3D", json!({"ids": [a], "format": "usdz"})); + let saved = s.doc().unwrap().doc.assets.clone(); + let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "assets3d.vectorcraft", "dataBase64": b64})); + assert_eq!(s.doc().unwrap().doc.assets, saved); + assert_eq!(saved[0].format_3d.as_deref(), Some("usdz")); +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 43ec2d72a..ff21f37ba 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -15,6 +15,8 @@ pub use shading::{LightGradient, RgbGradient, ShadeGradient}; mod lights; pub use lights::{Light, Lights, MAX_LIGHTS}; mod caps; +mod mesh; +pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv}; @@ -447,7 +449,17 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi /// Evaluate a profile in document space. All subpaths share one axis from `bounds`. /// Invalid or excessively complex inputs return an error, never a partial surface. pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { + revolve_faces(path, bounds, options, None) +} + +/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn. +fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Option<&mut Vec>) -> Result, String> { let o = options.sanitized(); + // A face kept for a mesh: its normal follows from its corners. + let keep = |mesh: &mut Vec, face: Vec, normal: V3| { + mesh.push((vec![face], normal)); + if mesh.len() > MAX_FACES { Err("Revolve surface is too complex; reduce Segments or simplify the profile".to_string()) } else { Ok(()) } + }; if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve requires finite profile coordinates within 100 million points".into()); } @@ -543,6 +555,16 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result = profile.iter().map(|p| (p.x - axis, p.y)).collect(); + if let (false, Some(first), Some(last)) = (sp.closed, profile.first(), profile.last()) { + ring.extend([(0.0, last.y), (0.0, first.y)]); + } + let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); + if area < 0.0 { -1.0 } else { 1.0 } + }; for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() { let [a, b] = pair else { continue }; if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { @@ -550,6 +572,14 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, edges: &[(usize, &caps::RingArc, bool)]| { @@ -581,6 +611,17 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result = (0..=steps).map(|j| vertex(*end, j)).collect(); + if o.angle >= 360.0 { + cap.pop(); + } else { + cap.insert(0, o.rotate(V3(0.0, end.y - center_y, 0.0))); + } + keep(mesh, cap, o.rotate(V3(0.0, outward, 0.0)))?; + continue; + } // Keep the original tessellation work budget, even though each cap // now produces only one curved vector face. emit(ring.face(bounds, o, light, outward)?, steps)?; diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs new file mode 100644 index 000000000..04b5db626 --- /dev/null +++ b/crates/three-d/src/mesh.rs @@ -0,0 +1,371 @@ +//! The solids of the 3D effects as triangle meshes, for files that other 3D apps open (OBJ, glTF, +//! USD). A mesh holds the faces the effect builds before they are projected onto the page: every +//! face, those turned away from the viewer included, at the turn the effect gives the art. +//! +//! Coordinates are points with y up and z toward the viewer, about the point the solid turns about +//! (the centre of the art's bounds; for Revolve, the axis at the profile's middle). Each face keeps +//! its own corners and normal, so the mesh is shaded flat. Planar faces with holes (caps and the +//! turned plane) are cut into triangles: each hole is joined to its outline by a bridge, then the +//! outline is cut by ear clipping. + +use vectorcraft_geom::{FillRule, PathData, Rect}; + +use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; +use crate::{Extrude, Inflate, Revolve, V3, View}; + +/// Most steps the triangulation of one solid may take: a face's ears and bridges each test the +/// face's other corners. +const MAX_WORK: usize = 400_000_000; + +/// A triangle mesh. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Mesh { + /// Corner positions in points: x right, y up, z toward the viewer. + pub positions: Vec<[f64; 3]>, + /// One unit normal for each position: the normal of the face the corner belongs to. + pub normals: Vec<[f64; 3]>, + /// Corner indices, counterclockwise seen from the side the normals point to. + pub triangles: Vec<[u32; 3]>, +} + +impl Mesh { + pub fn is_empty(&self) -> bool { + self.triangles.is_empty() + } + + /// The smallest and largest coordinate on each axis; `None` for an empty mesh. + pub fn bounds(&self) -> Option<([f64; 3], [f64; 3])> { + let first = *self.positions.first()?; + Some(self.positions.iter().fold((first, first), |(lo, hi), p| { + ([lo[0].min(p[0]), lo[1].min(p[1]), lo[2].min(p[2])], [hi[0].max(p[0]), hi[1].max(p[1]), hi[2].max(p[2])]) + })) + } + + /// The meshes of `parts` as one, in order. + pub fn join(parts: impl IntoIterator) -> Mesh { + let mut out = Mesh::default(); + for m in parts { + let base = out.positions.len() as u32; + out.positions.extend(m.positions); + out.normals.extend(m.normals); + out.triangles.extend(m.triangles.into_iter().map(|t| t.map(|i| i + base))); + } + out + } +} + +/// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh. +pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result { + extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces)| mesh(&faces)) +} + +/// Rotate's plane ([`crate::plane`]) as a mesh. +pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result { + plane_faces(path, rule, bounds, view, true).and_then(|(_, faces)| mesh(&faces)) +} + +/// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one +/// result for each shape, in order. Each mesh is cut as the iterator reaches it. +pub fn inflate_meshes( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, +) -> impl Iterator> + use<> { + inflate_faces(shapes, bounds, options, view, true).into_iter().map(|r| r.and_then(|(_, faces)| mesh(&faces))) +} + +/// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than +/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). +pub fn revolve_mesh(path: &PathData, bounds: Rect, options: Revolve) -> Result { + let mut faces = vec![]; + crate::revolve_faces(path, bounds, options, Some(&mut faces))?; + mesh(&faces) +} + +/// The area-weighted normal of `contours` (Newell's method). +fn newell(contours: &[Vec]) -> V3 { + let mut n = V3::default(); + for c in contours { + for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) { + let x = a.cross(*b); + n = V3(n.0 + x.0, n.1 + x.1, n.2 + x.2); + } + } + n +} + +/// From the effects' view space (y down) to the mesh's (y up). +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). +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() }; + if n.dot(n) < 0.5 { + continue; + } + let [nx, ny, nz] = up(n); + for [a, b, c] in triangulate(contours, geometric, &mut work)? { + let (pa, pb, pc) = (up(a), up(b), up(c)); + let cross = V3(pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]).cross(V3(pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2])); + if cross.dot(cross) < 1e-24 { + continue; + } + let (pb, pc) = if cross.dot(V3(nx, ny, nz)) < 0.0 { (pc, pb) } else { (pb, pc) }; + let base = u32::try_from(out.positions.len()).map_err(|_| "The 3D object is too complex to export".to_string())?; + out.positions.extend([pa, pb, pc]); + out.normals.extend([[nx, ny, nz]; 3]); + out.triangles.push([base, base + 1, base + 2]); + } + } + Ok(out) +} + +/// A corner of a face: its position on the plane the face is cut in, and in 3D. +#[derive(Clone, Copy)] +struct Corner { + x: f64, + y: f64, + v: V3, +} + +fn area(ring: &[Corner]) -> f64 { + ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::() / 2.0 +} + +fn inside(ring: &[Corner], x: f64, y: f64) -> bool { + let mut odd = false; + for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()) { + if (a.y > y) != (b.y > y) && x < a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x) { + odd = !odd; + } + } + odd +} + +fn too_complex() -> String { + "The 3D object is too complex to export".to_string() +} + +fn crossing_hole() -> String { + "The 3D object has a hole that crosses its outline or another hole".to_string() +} + +/// Count `steps` of work against [`MAX_WORK`]. +fn spend(work: &mut usize, steps: usize) -> Result<(), String> { + *work = work.saturating_add(steps); + if *work > MAX_WORK { Err(too_complex()) } else { Ok(()) } +} + +/// The triangles covering one face: `contours` (outlines one way round, holes the other) on the +/// plane across `normal` (its Newell normal). +fn triangulate(contours: &[Vec], normal: V3, work: &mut usize) -> Result, String> { + // Cut the face on the coordinate plane it is least slanted to, turned so outlines run + // counterclockwise. + let (ax, ay, az) = (normal.0.abs(), normal.1.abs(), normal.2.abs()); + let flat = |v: V3| -> (f64, f64) { + if az >= ax && az >= ay { + if normal.2 >= 0.0 { (v.0, v.1) } else { (v.1, v.0) } + } else if ax >= ay { + if normal.0 >= 0.0 { (v.1, v.2) } else { (v.2, v.1) } + } else if normal.1 >= 0.0 { + (v.2, v.0) + } else { + (v.0, v.2) + } + }; + let mut outlines: Vec<(Vec, f64)> = vec![]; + let mut holes: Vec> = vec![]; + for c in contours { + let mut ring: Vec = c + .iter() + .map(|v| { + let (x, y) = flat(*v); + Corner { x, y, v: *v } + }) + .collect(); + ring.dedup_by(|a, b| (a.x - b.x).hypot(a.y - b.y) < 1e-9); + while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| (a.x - b.x).hypot(a.y - b.y) < 1e-9) { + ring.pop(); + } + if ring.len() < 3 { + continue; + } + let a = area(&ring); + if a > 1e-12 { + outlines.push((ring, a)); + } else if a < -1e-12 { + holes.push(ring); + } + } + // Each hole goes to the smallest outline around it. + let mut held: Vec>> = vec![vec![]; outlines.len()]; + for hole in holes { + let corners: usize = outlines.iter().map(|(o, _)| o.len()).sum(); + spend(work, corners.saturating_mul(hole.len() + 1))?; + // A hole that crosses an outline can't be cut out of it; leaving it would fill it. + let edges = |r: &[Corner]| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(p, q)| ((p.x, p.y), (q.x, q.y))).collect::>(); + let sides = edges(&hole); + if outlines.iter().any(|(o, _)| edges(o).iter().any(|(c, d)| sides.iter().any(|(a, b)| crosses(*a, *b, *c, *d)))) { + return Err(crossing_hole()); + } + let Some(first) = hole.first() else { continue }; + let owner = + outlines.iter().enumerate().filter(|(_, (o, _))| inside(o, first.x, first.y)).min_by(|a, b| a.1.1.total_cmp(&b.1.1)).map(|(i, _)| i); + if let Some(i) = owner + && let Some(h) = held.get_mut(i) + { + h.push(hole); + } + } + let mut out = vec![]; + for ((outline, _), mut holes) in outlines.into_iter().zip(held) { + // The rightmost hole first, as each bridge then reaches the outline past the others. + holes.sort_by(|a, b| { + let right = |h: &[Corner]| h.iter().map(|c| c.x).fold(f64::NEG_INFINITY, f64::max); + right(b).total_cmp(&right(a)) + }); + let mut ring = outline; + for k in 0..holes.len() { + let (hole, rest) = holes[k..].split_first().ok_or_else(too_complex)?; + ring = bridge(ring, hole, rest, work)?; + } + clip(&ring, &mut out, work)?; + } + Ok(out) +} + +/// Do segments `a`–`b` and `c`–`d` cross (touching at a shared end does not count)? +fn crosses(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> bool { + let same = |p: (f64, f64), q: (f64, f64)| (p.0 - q.0).hypot(p.1 - q.1) < 1e-9; + if same(a, c) || same(a, d) || same(b, c) || same(b, d) { + return false; + } + let side = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0); + let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d)); + ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) +} + +/// Does point `p` lie on segment `a`–`b`, short of its ends? +fn touches(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> bool { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = dx.hypot(dy); + if len < 1e-9 { + return false; + } + let along = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len; + let off = ((p.1 - a.1) * dx - (p.0 - a.0) * dy).abs() / len; + off < 1e-9 && along > 1e-9 && along < len - 1e-9 +} + +/// `ring` with `hole` joined to it: from the hole's rightmost corner to the nearest corner of the +/// ring that a straight bridge reaches inside the face: it crosses no edge of the ring, the hole or +/// the `others`, passes through none of their other corners, and its middle is inside the ring and +/// outside the holes. The bridge runs there and back, so the result is one ring. +fn bridge(ring: Vec, hole: &[Corner], others: &[Vec], work: &mut usize) -> Result, String> { + let Some(m) = (0..hole.len()).max_by(|a, b| hole[*a].x.total_cmp(&hole[*b].x)) else { return Ok(ring) }; + let at = (hole[m].x, hole[m].y); + let mut order: Vec = (0..ring.len()).collect(); + order.sort_by(|a, b| { + let d = |i: usize| (ring[i].x - at.0).hypot(ring[i].y - at.1); + d(*a).total_cmp(&d(*b)) + }); + let edges = |r: &[Corner]| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(p, q)| ((p.x, p.y), (q.x, q.y))).collect::>(); + let mut walls = edges(&ring); + walls.extend(edges(hole)); + for o in others { + walls.extend(edges(o)); + } + let to = order.into_iter().find(|&i| { + let p = (ring[i].x, ring[i].y); + *work = work.saturating_add(2 * walls.len()); + if *work > MAX_WORK || walls.iter().any(|(c, d)| crosses(at, p, *c, *d) || touches(*c, at, p)) { + return false; + } + let mid = ((at.0 + p.0) / 2.0, (at.1 + p.1) / 2.0); + inside(&ring, mid.0, mid.1) && !inside(hole, mid.0, mid.1) && !others.iter().any(|o| inside(o, mid.0, mid.1)) + }); + if *work > MAX_WORK { + return Err(too_complex()); + } + // A hole no bridge reaches (one that crosses another hole) can't be cut out; leaving it would + // fill it. + let Some(i) = to else { return Err(crossing_hole()) }; + let mut out = Vec::with_capacity(ring.len() + hole.len() + 2); + out.extend_from_slice(&ring[..=i]); + out.extend(hole[m..].iter().chain(&hole[..=m]).copied()); + out.extend_from_slice(&ring[i..]); + Ok(out) +} + +/// Cut `ring` (counterclockwise, possibly joined to its holes by bridges) into triangles by ear +/// clipping. A corner in line with its neighbors goes without a triangle. If no ear is left (a +/// ring that crosses itself), the rest is cut as a fan. +fn clip(ring: &[Corner], out: &mut Vec<[V3; 3]>, work: &mut usize) -> Result<(), String> { + let n = ring.len(); + if n < 3 { + return Ok(()); + } + let mut next: Vec = (1..=n).map(|i| i % n).collect(); + let mut prev: Vec = (0..n).map(|i| (i + n - 1) % n).collect(); + let (mut left, mut i, mut tried) = (n, 0usize, 0usize); + let cross = |a: &Corner, b: &Corner, c: &Corner| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + let same = |a: &Corner, b: &Corner| (a.x - b.x).hypot(a.y - b.y) < 1e-12; + while left > 3 { + let (a, c) = (prev[i], next[i]); + let (pa, pb, pc) = (&ring[a], &ring[i], &ring[c]); + let turn = cross(pa, pb, pc); + let scale = (pb.x - pa.x).hypot(pb.y - pa.y) * (pc.x - pb.x).hypot(pc.y - pb.y); + let mut ear = false; + let drop = turn.abs() <= 1e-12 * scale.max(1e-300); + if !drop && turn > 0.0 { + spend(work, left)?; + ear = true; + let mut k = next[c]; + while k != a { + let p = &ring[k]; + if !same(p, pa) && !same(p, pb) && !same(p, pc) && cross(pa, pb, p) > 0.0 && cross(pb, pc, p) > 0.0 && cross(pc, pa, p) > 0.0 { + ear = false; + break; + } + k = next[k]; + } + } + if !ear && !drop && tried > left { + // No ear anywhere: cut what is left as a fan. + let start = i; + let mut k = next[start]; + while next[k] != start { + out.push([ring[start].v, ring[k].v, ring[next[k]].v]); + k = next[k]; + } + return Ok(()); + } + if ear || drop { + if ear { + out.push([pa.v, pb.v, pc.v]); + } + next[a] = c; + prev[c] = a; + left -= 1; + i = a; + tried = 0; + } else { + i = c; + tried += 1; + } + } + let (a, c) = (prev[i], next[i]); + out.push([ring[a].v, ring[i].v, ring[c].v]); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs new file mode 100644 index 000000000..4ee38fe1d --- /dev/null +++ b/crates/three-d/src/mesh/tests.rs @@ -0,0 +1,283 @@ +use std::collections::HashMap; + +use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; + +use super::*; +use crate::{Bevel, Edge}; + +fn square(b: &mut BezPath, x: f64, y: f64, s: f64, clockwise: bool) { + b.move_to((x, y)); + if clockwise { + b.line_to((x, y + s)); + b.line_to((x + s, y + s)); + b.line_to((x + s, y)); + } else { + b.line_to((x + s, y)); + b.line_to((x + s, y + s)); + b.line_to((x, y + s)); + } + b.close_path(); +} + +fn shape(b: &BezPath) -> (PathData, Rect) { + let p = PathData::from_bezpath(b); + let r = p.bounds().unwrap(); + (p, r) +} + +/// Straight on, without perspective. +fn front() -> View { + View { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, perspective: 0.0, ..View::default() } +} + +fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn corners(m: &Mesh, t: [u32; 3]) -> [[f64; 3]; 3] { + t.map(|i| m.positions[i as usize]) +} + +/// The area of every triangle together. +fn area(m: &Mesh) -> f64 { + m.triangles + .iter() + .map(|t| { + let [a, b, c] = corners(m, *t); + dot(cross(sub(b, a), sub(c, a)), cross(sub(b, a), sub(c, a))).sqrt() / 2.0 + }) + .sum() +} + +/// Every edge of every triangle is met by an edge running the other way, as often: the surface is +/// closed and its triangles turn one way. +fn closed(m: &Mesh) -> bool { + let key = |p: [f64; 3]| p.map(|v| (v * 1e6).round() as i64); + let mut edges: HashMap<([i64; 3], [i64; 3]), i64> = HashMap::new(); + for t in &m.triangles { + let c = corners(m, *t).map(key); + for (a, b) in [(c[0], c[1]), (c[1], c[2]), (c[2], c[0])] { + *edges.entry((a, b)).or_default() += 1; + *edges.entry((b, a)).or_default() -= 1; + } + } + edges.values().all(|n| *n == 0) +} + +/// Every triangle of a solid convex about the origin turns its front away from the origin, and +/// agrees with its corners' normals. +fn outward(m: &Mesh) -> bool { + m.triangles.iter().all(|t| { + let [a, b, c] = corners(m, *t); + let n = cross(sub(b, a), sub(c, a)); + let centre = [(a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0, (a[2] + b[2] + c[2]) / 3.0]; + dot(n, centre) > 0.0 && dot(n, m.normals[t[0] as usize]) > 0.0 + }) +} + +#[test] +fn an_extruded_square_is_a_closed_box_about_its_centre() { + let mut b = BezPath::new(); + square(&mut b, 200.0, 300.0, 100.0, false); + let (p, r) = shape(&b); + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 50.0, ..Extrude::default() }, front()).unwrap(); + assert_eq!(m.triangles.len(), 12, "two triangles for each side of the box"); + assert_eq!(m.positions.len(), m.normals.len()); + assert_eq!(m.bounds(), Some(([-50.0, -50.0, -25.0], [50.0, 50.0, 25.0]))); + assert!((area(&m) - (2.0 * 100.0 * 100.0 + 4.0 * 100.0 * 50.0)).abs() < 1e-6, "{}", area(&m)); + assert!(closed(&m)); + assert!(outward(&m)); + assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9)); +} + +#[test] +fn caps_with_holes_are_cut_around_them() { + // Two holes in one outline: each cap keeps the outline less both holes. + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 10.0, 10.0, 30.0, true); + square(&mut b, 60.0, 50.0, 30.0, true); + let (p, r) = shape(&b); + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 20.0, ..Extrude::default() }, front()).unwrap(); + let caps = 2.0 * (100.0 * 100.0 - 2.0 * 30.0 * 30.0); + let walls = 4.0 * 100.0 * 20.0 + 2.0 * 4.0 * 30.0 * 20.0; + assert!((area(&m) - (caps + walls)).abs() < 1e-6, "{} != {}", area(&m), caps + walls); + assert!(closed(&m)); +} + +#[test] +fn a_bridge_from_a_hole_never_runs_through_the_hole() { + // The hole's rightmost corner (60, 50) is nearest the outline's spike tip (10, 50), and the + // straight line to it runs through the hole and out at its corner (20, 50). + let poly = |b: &mut BezPath, pts: &[(f64, f64)]| { + for (k, p) in pts.iter().enumerate() { + if k == 0 { b.move_to(*p) } else { b.line_to(*p) } + } + b.close_path(); + }; + let mut b = BezPath::new(); + poly(&mut b, &[(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0), (0.0, 55.0), (10.0, 50.0), (0.0, 45.0)]); + let hole = [(60.0, 50.0), (30.0, 40.0), (20.0, 50.0), (30.0, 60.0)]; + poly(&mut b, &hole); + let (p, r) = shape(&b); + let m = plane_mesh(&p, FillRule::EvenOdd, r, front()).unwrap(); + let face = 200.0 * 100.0 - 50.0 - 400.0; + assert!((area(&m) - face).abs() < 1e-6, "{} != {face}", area(&m)); + // No triangle's centre is in the hole (page coordinates: the mesh is y up about (100, 50)). + let in_hole = |x: f64, y: f64| { + let mut odd = false; + for (k, a) in hole.iter().enumerate() { + let c = hole[(k + 1) % hole.len()]; + if (a.1 > y) != (c.1 > y) && x < a.0 + (y - a.1) / (c.1 - a.1) * (c.0 - a.0) { + odd = !odd; + } + } + odd + }; + for t in &m.triangles { + let [a, b, c] = corners(&m, *t); + let (x, y) = ((a[0] + b[0] + c[0]) / 3.0 + 100.0, 50.0 - (a[1] + b[1] + c[1]) / 3.0); + assert!(!in_hole(x, y), "a triangle at ({x}, {y})"); + } +} + +#[test] +fn a_hole_that_crosses_the_outline_is_refused_rather_than_filled() { + // The hole starts inside the square, but its rightmost corner (15, 5) lies outside it, so no + // bridge from there reaches the square through the face. + let ring = |pts: &[(f64, f64)]| pts.iter().map(|(x, y)| V3(*x, *y, 0.0)).collect::>(); + let outline = ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]); + let hole = ring(&[(5.0, 4.0), (5.0, 6.0), (15.0, 5.0)]); + let mut work = 0; + assert!(super::triangulate(std::slice::from_ref(&outline), V3(0.0, 0.0, 1.0), &mut work).is_ok()); + let r = super::triangulate(&[outline.clone(), hole], V3(0.0, 0.0, 1.0), &mut work); + assert!(r.as_ref().is_err_and(|e| e.contains("hole")), "{:?}", r.map(|t| t.len())); + // The same hole starting outside the square belongs to no outline, and is refused too. + let hole = ring(&[(15.0, 5.0), (5.0, 4.0), (5.0, 6.0)]); + let r = super::triangulate(&[outline, hole], V3(0.0, 0.0, 1.0), &mut work); + assert!(r.as_ref().is_err_and(|e| e.contains("hole")), "{:?}", r.map(|t| t.len())); +} + +#[test] +fn a_bridge_through_another_corner_touches_it() { + assert!(super::touches((5.0, 0.0), (0.0, 0.0), (10.0, 0.0))); + assert!(!super::touches((0.0, 0.0), (0.0, 0.0), (10.0, 0.0)), "an end is not a touch"); + assert!(!super::touches((5.0, 1.0), (0.0, 0.0), (10.0, 0.0))); +} + +#[test] +fn a_beveled_extrusion_stays_closed() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + let (p, r) = shape(&b); + let bevel = Bevel { width: 6.0, height: 6.0, both_sides: true, ..Bevel::default() }; + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 40.0, bevel: Some(bevel), ..Extrude::default() }, front()).unwrap(); + assert!(closed(&m)); + assert!(outward(&m)); +} + +#[test] +fn the_mesh_keeps_the_turn_of_the_view() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + let (p, r) = shape(&b); + // Turned 90° about the vertical axis, the extrusion's depth lies along x. + let view = View { rotation_y: 90.0, ..front() }; + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 20.0, ..Extrude::default() }, view).unwrap(); + let (lo, hi) = m.bounds().unwrap(); + assert!((hi[0] - lo[0] - 20.0).abs() < 1e-9 && (hi[2] - lo[2] - 100.0).abs() < 1e-9, "{lo:?} {hi:?}"); +} + +#[test] +fn a_turned_plane_keeps_its_area_less_its_hole() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 25.0, 25.0, 50.0, true); + let (p, r) = shape(&b); + let view = View { rotation_x: 30.0, rotation_y: -40.0, ..front() }; + let m = plane_mesh(&p, FillRule::NonZero, r, view).unwrap(); + assert!((area(&m) - (100.0 * 100.0 - 50.0 * 50.0)).abs() < 1e-6, "{}", area(&m)); +} + +#[test] +fn y_points_up() { + // The art's top edge (y 0 on the page) is the mesh's highest. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((100.0, 0.0)); + b.line_to((50.0, 100.0)); + b.close_path(); + let (p, r) = shape(&b); + let m = plane_mesh(&p, FillRule::NonZero, r, front()).unwrap(); + let top = m.positions.iter().filter(|q| (q[1] - 50.0).abs() < 1e-9).count(); + assert!(top >= 2, "{:?}", m.positions); + assert!(m.normals.iter().all(|n| n[2] > 0.99), "a plane facing the viewer has its normal toward +z"); +} + +#[test] +fn a_revolved_profile_is_a_closed_cylinder() { + // A vertical line 10 pt from the axis, swept round with its ends capped: a closed cylinder. + let mut b = BezPath::new(); + b.move_to((20.0, 0.0)); + b.line_to((20.0, 100.0)); + 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!(closed(&m)); + let exact = 2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0; + assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); + assert!(outward(&m)); +} + +#[test] +fn a_partial_revolve_caps_its_ends_with_sectors() { + let mut b = BezPath::new(); + b.move_to((20.0, 0.0)); + b.line_to((20.0, 100.0)); + let p = PathData::from_bezpath(&b); + let r = p.bounds().unwrap(); + let o = Revolve { angle: 90.0, offset: 10.0, segments: 64, ..Revolve::default() }; + let m = revolve_mesh(&p, r, o).unwrap(); + let exact = 0.25 * (2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0); + assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); +} + +#[test] +fn inflate_puffs_the_front_toward_the_viewer() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + let (p, r) = shape(&b); + let m = inflate_meshes(&[(&p, FillRule::NonZero)], r, Inflate::default(), front()).last().unwrap().unwrap(); + assert!(!m.is_empty()); + let (lo, hi) = m.bounds().unwrap(); + assert!(hi[2] > 0.0 && lo[2] < hi[2], "the front puffs toward the viewer: {lo:?} {hi:?}"); +} + +#[test] +fn an_empty_shape_makes_an_empty_mesh() { + let p = PathData::new(vec![]); + let m = plane_mesh(&p, FillRule::NonZero, Rect::new(0.0, 0.0, 10.0, 10.0), front()).unwrap(); + assert!(m.is_empty()); + assert_eq!(m.bounds(), None); +} + +#[test] +fn join_numbers_each_parts_corners_after_the_last() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 10.0, false); + let (p, r) = shape(&b); + let one = plane_mesh(&p, FillRule::NonZero, r, front()).unwrap(); + let both = Mesh::join([one.clone(), one.clone()]); + assert_eq!(both.triangles.len(), 2 * one.triangles.len()); + assert!(both.triangles.iter().all(|t| t.iter().all(|i| (*i as usize) < both.positions.len()))); + assert_eq!(both.triangles[one.triangles.len()][0], one.triangles[0][0] + one.positions.len() as u32); +} diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 043e34766..20beda4ec 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -20,7 +20,9 @@ mod rings; #[cfg(test)] use build::{Camera, cuts, edge_distance, newell, section}; +pub(crate) use build::{Face3, extrude_faces, plane_faces}; pub use build::{extrude, plane}; +pub(crate) use inflate::inflate_faces; pub use inflate::{inflate, inflate_together}; #[cfg(test)] use membrane::{Edges, Membrane, cell_triangles}; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index c78f2a38b..7fbeb3e8c 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -41,6 +41,13 @@ impl Camera { } } +/// A face of a solid before projection: its contours in view space (holes running the other way) +/// and its outward normal. +pub(crate) type Face3 = (Vec>, V3); + +/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection. +pub(crate) type Built = (Vec, Vec); + /// Facets being collected for one solid. pub(super) struct Builder<'a> { pub(super) camera: &'a Camera, @@ -48,6 +55,8 @@ pub(super) struct Builder<'a> { /// insides. pub(super) closed: bool, pub(super) facets: Vec, + /// With `Some`, every face goes here as it is, none hidden or projected, for a mesh. + pub(super) mesh: Option>, } /// Where a face goes in the order the facets are drawn, back to front. @@ -73,6 +82,13 @@ impl Builder<'_> { if count < 3 { return Ok(()); } + if let Some(mesh) = &mut self.mesh { + mesh.push((contours, n.unit())); + if mesh.len() > MAX_FACES { + return Err("The 3D surface is too complex; simplify the shape".into()); + } + return Ok(()); + } let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64); let n = n.unit(); @@ -182,9 +198,15 @@ impl Builder<'_> { Ok(()) } - pub(super) fn finish(mut self) -> Vec { + /// How many faces the builder holds: facets, or faces kept for a mesh. + pub(super) fn len(&self) -> usize { + self.mesh.as_ref().map_or(self.facets.len(), Vec::len) + } + + /// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]). + pub(super) fn finish(mut self) -> Built { self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - self.facets + (self.facets, self.mesh.unwrap_or_default()) } } @@ -370,15 +392,20 @@ fn steps(o: &Extrude) -> Vec { /// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels /// and caps. pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { + extrude_faces(path, rule, bounds, options, view, false).map(|(facets, _)| facets) +} + +/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets. +pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, mesh: bool) -> Result { check_bounds(bounds)?; let o = options.sanitized(); // No depth: the flat shape, seen from either side. if o.depth <= 0.0 { - return plane(path, rule, bounds, view); + return plane_faces(path, rule, bounds, view, mesh); } let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; let camera = Camera::new(view, bounds, o.depth); - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) }; let steps = steps(&o); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = @@ -428,10 +455,15 @@ pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, /// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the /// shape's own, so that every part of one object turns about the same point). pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { + plane_faces(path, rule, bounds, view, false).map(|(facets, _)| facets) +} + +/// [`plane`], or with `mesh` its face before projection instead of its facet. +pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { check_bounds(bounds)?; let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; let camera = Camera::new(view, bounds, 0.0); - let mut b = Builder { camera: &camera, closed: false, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index d80fef185..9f1f5c3f7 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,4 +1,4 @@ -use super::build::{Builder, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; +use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; use super::rings::{MAX_SOLID_POINTS, rings, signed_area}; use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View}; @@ -51,6 +51,11 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, /// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest /// puff needs with Perspective. One result for each shape, in order. pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { + inflate_faces(shapes, bounds, options, view, false).into_iter().map(|r| r.map(|(facets, _)| facets)).collect() +} + +/// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. +pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, mesh: bool) -> Vec> { let o = options.sanitized(); let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); @@ -58,16 +63,17 @@ pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: puffs .into_iter() .map(|p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach), - Ok(None) => Ok(vec![]), + Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), + Ok(None) => Ok((vec![], vec![])), Err(e) => Err(e), }) .collect() } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the -/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. -fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result, String> { +/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. With `mesh`, +/// its faces before projection instead. +fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: bool) -> Result { let Puff { rings, edges, cell, nx, ny, m, height } = p; let outline: Vec<(Rect, vectorcraft_geom::SubPath)> = rings .iter() @@ -106,7 +112,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result let smooth = view.shade && view.has_colored_lights(); let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); // Without its back cap the object is a shell open behind, its inside showing. - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) }; let half = o.depth / 2.0; // The back: turned and scaled by the twist and taper, its puff too. let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); @@ -300,7 +306,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect() }; let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); - fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.facets.len())); + fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); for (ring, pieces) in rings.iter().zip(&pieces) { for k in 0..slices { b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 24f2d7367..90c1ddd39 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -108,12 +108,13 @@ pub(crate) fn saved_rows(saved: &Map) -> (Vec, Option<&str /// Open the dialog on its Assets tab with `checked` assets checked (every asset when `None`). pub(crate) fn open_assets(app: &mut VectorcraftApp, checked: Option<&[u64]>) { open(app); + let shown: Vec = app.session.active().map(|st| picture_assets(st).iter().map(|a| a.id).collect()).unwrap_or_default(); if let Some(d) = app.ui.dialog.as_mut() { d.fields.insert("tab".into(), json!("assets")); if let Some(c) = checked { d.fields.insert("assets".into(), json!(c)); - // The list scrolls to the first of them once: new assets land at its end. - if let Some(first) = c.first() { + // The list scrolls to the first of them it shows, once: new assets land at its end. + if let Some(first) = c.iter().find(|id| shown.contains(id)) { d.fields.insert("__scrollTo".into(), json!(first)); } } @@ -128,7 +129,7 @@ pub fn open(app: &mut VectorcraftApp) { let (n, saved, assets) = app .session .active() - .map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone(), st.doc.assets.iter().map(|a| a.id).collect::>())) + .map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone(), picture_assets(st).iter().map(|a| a.id).collect::>())) .unwrap_or_default(); let get = |k: &str| saved.get(k).filter(|v| !v.is_null()); let flag = |k: &str, default: bool| get(k).and_then(Value::as_bool).unwrap_or(default); @@ -575,16 +576,23 @@ fn tiles( toggled } -/// The checked assets that are still in the document, in panel order. -fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec { - let on: Vec = d.fields.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); - app.session.active().map(|st| st.doc.assets.iter().map(|a| a.id).filter(|id| on.contains(id)).collect()).unwrap_or_default() +/// The document's assets exported as pictures, in panel order: 3D assets export as mesh files from +/// the Asset Export panel (`assets.export3D`). +fn picture_assets(st: &vectorcraft_engine::DocState) -> Vec<&vectorcraft_doc::ExportAsset> { + st.doc.assets.iter().filter(|a| a.format_3d.is_none()).collect() } -/// The Assets tab: each asset's thumbnail with its checkbox and name, in a grid or a list. +/// The checked picture assets that are still in the document, in panel order. +fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec { + let on: Vec = d.fields.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + app.session.active().map(|st| picture_assets(st).iter().map(|a| a.id).filter(|id| on.contains(id)).collect()).unwrap_or_default() +} + +/// The Assets tab: each picture asset's thumbnail with its checkbox and name, in a grid or a list. fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { let Some(st) = app.session.active() else { return }; - if st.doc.assets.is_empty() { + let shown = picture_assets(st); + if shown.is_empty() { ui.add_space(24.0); widgets::dim_label(ui, tl!("No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel.")); return; @@ -592,10 +600,10 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { let mut on = checked_assets(app, d); let before = on.clone(); // Asked for until its row is in view: the dialog's first frame only measures it. - let scroll_to = d.fields.get("__scrollTo").and_then(Value::as_u64).filter(|id| st.doc.assets.iter().any(|a| a.id == *id)); + let scroll_to = d.fields.get("__scrollTo").and_then(Value::as_u64).filter(|id| shown.iter().any(|a| a.id == *id)); let mut in_view = scroll_to.is_none(); - let clicked = tiles(ui, "efs-assets", st.doc.assets.len(), list, |ui, i, r| { - let Some(a) = st.doc.assets.get(i) else { return (String::new(), String::new(), false) }; + let clicked = tiles(ui, "efs-assets", shown.len(), list, |ui, i, r| { + let Some(a) = shown.get(i) else { return (String::new(), String::new(), false) }; crate::panels::asset_export::paint_thumb(ui, st, a, r); if scroll_to == Some(a.id) { if ui.clip_rect().contains_rect(r) { @@ -607,7 +615,7 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { (a.name.clone(), a.name.clone(), on.contains(&a.id)) }); for i in clicked { - if let Some(a) = st.doc.assets.get(i) { + if let Some(a) = shown.get(i) { if on.contains(&a.id) { on.retain(|id| *id != a.id); } else { diff --git a/crates/ui-egui/src/dialogs/tests_screen_assets.rs b/crates/ui-egui/src/dialogs/tests_screen_assets.rs index 32f585e81..df218bd0a 100644 --- a/crates/ui-egui/src/dialogs/tests_screen_assets.rs +++ b/crates/ui-egui/src/dialogs/tests_screen_assets.rs @@ -122,6 +122,28 @@ fn the_assets_tab_exports_the_checked_assets_into_the_folder() { let _ = std::fs::remove_dir_all(dir); } +/// 3D assets export as mesh files from the Asset Export panel: the Assets tab lists and checks the +/// picture assets alone. +#[test] +fn the_assets_tab_leaves_3d_assets_out() { + let (mut app, _, [a, b]) = app(false); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "params": {}})).unwrap(); + let three_d = app.run("assets.add3D", json!({"ids": [a]})).unwrap()["assets"][0].as_u64().unwrap(); + let picture = app.run("assets.add", json!({"ids": [b], "name": "Flat"})).unwrap()["assets"][0].as_u64().unwrap(); + app.run("assets.rename", json!({"asset": three_d, "name": "Solid"})).unwrap(); + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!(field(&app, "assets"), json!([picture])); + set(&mut app, "tab", json!("assets")); + let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + assert!(text.contains("Flat") && !text.contains("Solid"), "{text}"); + // Opened from the panel with both checked, the tab still shows the picture asset alone. + open_export_for_screens_assets(&mut app, Some(&[three_d, picture])); + assert_eq!(field(&app, "__scrollTo"), json!(picture), "the list scrolls to the first asset it shows"); + let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + assert!(text.contains("Flat") && !text.contains("Solid"), "{text}"); +} + #[test] fn an_empty_assets_tab_says_how_to_collect_art() { let (mut app, _, _) = app(false); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 638a5f102..6ca909b49 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1057,6 +1057,9 @@ font-technology Variable Променливи Export As SVG… Експортиране като SVG… Export Asset Експортиране на актив Export Assets Експортиране на активи + Export 3D Object Експортиране на 3D обект + 3D Export Format Формат на 3D експортиране + Export 3D Assets Експортиране на 3D активи Export As… Експортиране като… Export CSS Експортиране на CSS Export CSS… Експортиране на CSS… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 70055e848..e159a9343 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3152,6 +3152,9 @@ theme Appearance Izgled Asset Export Settings Postavke izvoza resursa Assets Resursi Export Assets Izvezi resurse + Export 3D Object Izvezi 3D objekat + 3D Export Format Format 3D izvoza + Export 3D Assets Izvezi 3D resurse Remove Asset Ukloni resurs Rename Asset Preimenuj resurs Fill Rule Pravilo ispune diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index aca952436..391ad658e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1465,6 +1465,14 @@ Export As SVG… Exporta com a SVG… Export Asset Exporta el recurs Export Assets Exporta els recursos + Export 3D Object Exporta l’objecte 3D + 3D Export Format Format d’exportació 3D + Export 3D Assets Exporta els recursos 3D +@msg {what} can't be exported in 3D: {e} no es pot exportar {what} en 3D: {e} +@msg asset {id} isn't a 3D asset el recurs {id} no és un recurs 3D +@msg asset {id} is a 3D asset: assets.export3D writes it el recurs {id} és un recurs 3D: l’escriu assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) encara no hi ha cap recurs 3D: recolliu primer un objecte amb un efecte 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) seleccioneu un objecte amb un efecte 3D (o indiqueu-ne els id) Export As… Exporta com a… Export CSS Exporta CSS Export CSS… Exporta CSS… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8a4aa807a..8f2dcb816 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3191,6 +3191,14 @@ theme Appearance Motiv Asset Export Settings Nastavení exportu prvků Assets Prvky Export Assets Exportovat prvky + Export 3D Object Exportovat 3D objekt + 3D Export Format Formát 3D exportu + Export 3D Assets Exportovat 3D prvky +@msg {what} can't be exported in 3D: {e} {what} nelze exportovat ve 3D: {e} +@msg asset {id} isn't a 3D asset prvek {id} není 3D prvek +@msg asset {id} is a 3D asset: assets.export3D writes it prvek {id} je 3D prvek: zapisuje jej assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) zatím žádné 3D prvky: nejprve shromážděte objekt s 3D efektem (assets.add3D) +@msg select an object with a 3D effect (or give its ids) vyberte objekt s 3D efektem (nebo zadejte jeho ID) Remove Asset Odebrat prvek Rename Asset Přejmenovat prvek Fill Rule Pravidlo výplně diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 29aa686dd..aabdbeb90 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2935,6 +2935,9 @@ theme Appearance Udseende Asset Export Settings Indstillinger for eksport af aktiver Assets Aktiver Export Assets Eksportér aktiver + Export 3D Object Eksportér 3D-objekt + 3D Export Format 3D-eksportformat + Export 3D Assets Eksportér 3D-aktiver Remove Asset Fjern aktiv Rename Asset Omdøb aktiv Fill Rule Udfyldningsregel diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b6c9220c1..9a8669767 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1057,6 +1057,14 @@ font-technology Variable Variabel Export As SVG… Als SVG exportieren… Export Asset Asset exportieren Export Assets Assets exportieren + Export 3D Object 3D-Objekt exportieren + 3D Export Format 3D-Exportformat + Export 3D Assets 3D-Assets exportieren +@msg {what} can't be exported in 3D: {e} {what} kann nicht als 3D exportiert werden: {e} +@msg asset {id} isn't a 3D asset Asset {id} ist kein 3D-Asset +@msg asset {id} is a 3D asset: assets.export3D writes it Asset {id} ist ein 3D-Asset: assets.export3D schreibt es +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) noch keine 3D-Assets: Sammeln Sie zuerst ein Objekt mit 3D-Effekt (assets.add3D) +@msg select an object with a 3D effect (or give its ids) wählen Sie ein Objekt mit 3D-Effekt aus (oder geben Sie seine IDs an) Export As… Exportieren als… Export CSS CSS exportieren Export CSS… CSS exportieren… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 23d376654..2bab85fec 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1030,6 +1030,14 @@ font-technology Variable Μεταβλητές Export As SVG… Εξαγωγή ως SVG… Export Asset Εξαγωγή στοιχείου Export Assets Εξαγωγή στοιχείων + Export 3D Object Εξαγωγή αντικειμένου 3D + 3D Export Format Μορφή εξαγωγής 3D + Export 3D Assets Εξαγωγή στοιχείων 3D +@msg {what} can't be exported in 3D: {e} το {what} δεν μπορεί να εξαχθεί σε 3D: {e} +@msg asset {id} isn't a 3D asset το στοιχείο {id} δεν είναι στοιχείο 3D +@msg asset {id} is a 3D asset: assets.export3D writes it το στοιχείο {id} είναι στοιχείο 3D: το γράφει η assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) δεν υπάρχουν ακόμη στοιχεία 3D: συγκεντρώστε πρώτα ένα αντικείμενο με εφέ 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) επιλέξτε ένα αντικείμενο με εφέ 3D (ή δώστε τα αναγνωριστικά του) Export As… Εξαγωγή ως… Export CSS Εξαγωγή CSS Export CSS… Εξαγωγή CSS… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5741993ca..7a6ec6794 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1086,6 +1086,14 @@ font-technology Variable Variables Export As SVG… Exportar como SVG… Export Asset Exportar recurso Export Assets Exportar recursos + Export 3D Object Exportar objeto 3D + 3D Export Format Formato de exportación 3D + Export 3D Assets Exportar recursos 3D +@msg {what} can't be exported in 3D: {e} {what} no se puede exportar en 3D: {e} +@msg asset {id} isn't a 3D asset el recurso {id} no es un recurso 3D +@msg asset {id} is a 3D asset: assets.export3D writes it el recurso {id} es un recurso 3D: lo escribe assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) todavía no hay recursos 3D: reúna primero un objeto con un efecto 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) seleccione un objeto con un efecto 3D (o indique sus id) Export As… Exportar como… Export CSS Exportar CSS Export CSS… Exportar CSS… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index ec8f0c23f..eca3d4a46 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3130,6 +3130,14 @@ theme Appearance Ulkoasu Asset Export Settings Resurssien vientiasetukset Assets Resurssit Export Assets Vie resurssit + Export 3D Object Vie 3D-objekti + 3D Export Format 3D-vientimuoto + Export 3D Assets Vie 3D-resurssit +@msg {what} can't be exported in 3D: {e} kohdetta {what} ei voi viedä 3D-muodossa: {e} +@msg asset {id} isn't a 3D asset resurssi {id} ei ole 3D-resurssi +@msg asset {id} is a 3D asset: assets.export3D writes it resurssi {id} on 3D-resurssi: assets.export3D kirjoittaa sen +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) ei vielä 3D-resursseja: kerää ensin objekti, jossa on 3D-tehoste (assets.add3D) +@msg select an object with a 3D effect (or give its ids) valitse objekti, jossa on 3D-tehoste (tai anna sen tunnukset) Remove Asset Poista resurssi Rename Asset Nimeä resurssi uudelleen Fill Rule Täyttösääntö diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a6aada7f9..acecace4d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1081,6 +1081,14 @@ font-technology Variable Variables Export As SVG… Exporter au format SVG… Export Asset Exporter la ressource Export Assets Exporter les ressources + Export 3D Object Exporter l’objet 3D + 3D Export Format Format d’exportation 3D + Export 3D Assets Exporter les ressources 3D +@msg {what} can't be exported in 3D: {e} impossible d’exporter {what} en 3D : {e} +@msg asset {id} isn't a 3D asset la ressource {id} n’est pas une ressource 3D +@msg asset {id} is a 3D asset: assets.export3D writes it la ressource {id} est une ressource 3D : assets.export3D l’écrit +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) aucune ressource 3D pour l’instant : rassemblez d’abord un objet doté d’un effet 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) sélectionnez un objet doté d’un effet 3D (ou indiquez ses identifiants) Export As… Exporter sous… Export CSS Exporter le CSS Export CSS… Exporter le CSS… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b458a309f..81481eb01 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1629,6 +1629,14 @@ Export As… इस रूप में एक्सपोर्ट करें… Export Asset संपत्ति निर्यात करें Export Assets निर्यात परिसंपत्तियां + Export 3D Object 3D ऑब्जेक्ट निर्यात करें + 3D Export Format 3D निर्यात फ़ॉर्मेट + Export 3D Assets 3D परिसंपत्तियां निर्यात करें +@msg {what} can't be exported in 3D: {e} {what} को 3D में निर्यात नहीं किया जा सकता: {e} +@msg asset {id} isn't a 3D asset परिसंपत्ति {id} 3D परिसंपत्ति नहीं है +@msg asset {id} is a 3D asset: assets.export3D writes it परिसंपत्ति {id} एक 3D परिसंपत्ति है: इसे assets.export3D लिखता है +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) अभी तक कोई 3D परिसंपत्ति नहीं: पहले 3D प्रभाव वाला ऑब्जेक्ट इकट्ठा करें (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D प्रभाव वाला ऑब्जेक्ट चुनें (या उसकी आईडी दें) Export CSS CSS निर्यात करें Export CSS… CSS निर्यात करें… Export Data Sets… डेटा सेट निर्यात करें… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 5c1b1b253..a362a3b68 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1368,6 +1368,14 @@ font-technology Variable Változó Export As SVG… Exportálás SVG-ként… Export Asset Elem exportálása Export Assets Elemek exportálása + Export 3D Object 3D objektum exportálása + 3D Export Format 3D exportálási formátum + Export 3D Assets 3D elemek exportálása +@msg {what} can't be exported in 3D: {e} {what} nem exportálható 3D-ben: {e} +@msg asset {id} isn't a 3D asset a(z) {id} elem nem 3D elem +@msg asset {id} is a 3D asset: assets.export3D writes it a(z) {id} elem 3D elem: az assets.export3D írja ki +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) még nincs 3D elem: előbb gyűjtsön össze egy 3D effektussal rendelkező objektumot (assets.add3D) +@msg select an object with a 3D effect (or give its ids) jelöljön ki egy 3D effektussal rendelkező objektumot (vagy adja meg az azonosítóit) Export As… Exportálás másként… Export CSS CSS exportálása Export CSS… CSS exportálása… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bce0c1acb..3c0dffa2e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2935,6 +2935,9 @@ theme Appearance Tampilan Asset Export Settings Pengaturan ekspor aset Assets Aset Export Assets Ekspor aset + Export 3D Object Ekspor objek 3D + 3D Export Format Format ekspor 3D + Export 3D Assets Ekspor aset 3D Remove Asset Hapus aset Rename Asset Ganti nama aset Fill Rule Aturan isian diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 490d0c646..1628344a1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1087,6 +1087,14 @@ font-technology Variable Variabili Export As SVG… Esporta come SVG… Export Asset Esporta risorsa Export Assets Esporta risorse + Export 3D Object Esporta oggetto 3D + 3D Export Format Formato di esportazione 3D + Export 3D Assets Esporta risorse 3D +@msg {what} can't be exported in 3D: {e} impossibile esportare {what} in 3D: {e} +@msg asset {id} isn't a 3D asset la risorsa {id} non è una risorsa 3D +@msg asset {id} is a 3D asset: assets.export3D writes it la risorsa {id} è una risorsa 3D: la scrive assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) ancora nessuna risorsa 3D: raccogli prima un oggetto con un effetto 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) seleziona un oggetto con un effetto 3D (o indicane gli ID) Export As… Esporta come… Export CSS Esporta CSS Export CSS… Esporta CSS… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f2f438876..19e8a2803 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1589,6 +1589,14 @@ digits Malayalam マラヤーラム数字 PDF Print 印刷用 PDF Export As 形式を指定して書き出し Export Assets アセットを書き出し + Export 3D Object 3D オブジェクトを書き出し + 3D Export Format 3D 書き出し形式 + Export 3D Assets 3D アセットを書き出し +@msg {what} can't be exported in 3D: {e} {what} は 3D で書き出せません: {e} +@msg asset {id} isn't a 3D asset アセット {id} は 3D アセットではありません +@msg asset {id} is a 3D asset: assets.export3D writes it アセット {id} は 3D アセットです: assets.export3D で書き出します +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D アセットがまだありません: まず 3D 効果のあるオブジェクトを追加してください (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D 効果のあるオブジェクトを選択してください (または ID を指定) Export CSS CSS を書き出し Export CSS… CSS を書き出し… Export DXF DXF を書き出し diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 7c893c418..bf7efc0e7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2752,6 +2752,14 @@ theme Appearance იერსახე Asset Export Settings რესურსის ექსპორტის პარამეტრები Assets რესურსები Export Assets რესურსების ექსპორტირება + Export 3D Object 3D ობიექტის ექსპორტირება + 3D Export Format 3D ექსპორტის ფორმატი + Export 3D Assets 3D რესურსების ექსპორტირება +@msg {what} can't be exported in 3D: {e} {what} 3D-ში ვერ ექსპორტირდება: {e} +@msg asset {id} isn't a 3D asset რესურსი {id} არ არის 3D რესურსი +@msg asset {id} is a 3D asset: assets.export3D writes it რესურსი {id} 3D რესურსია: მას assets.export3D წერს +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D რესურსები ჯერ არ არის: ჯერ შეაგროვეთ 3D ეფექტის მქონე ობიექტი (assets.add3D) +@msg select an object with a 3D effect (or give its ids) აირჩიეთ 3D ეფექტის მქონე ობიექტი (ან მიუთითეთ მისი ID) Remove Asset რესურსის წაშლა Rename Asset რესურსის სახელის შეცვლა Fill Rule შევსების წესი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 838bb7e58..21d443316 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1579,6 +1579,14 @@ digits Malayalam 말라얄람어 PDF Print 인쇄용 PDF Export As 다른 형식으로 내보내기 Export Assets 에셋 내보내기 + Export 3D Object 3D 개체 내보내기 + 3D Export Format 3D 내보내기 형식 + Export 3D Assets 3D 에셋 내보내기 +@msg {what} can't be exported in 3D: {e} {what}은(는) 3D로 내보낼 수 없습니다: {e} +@msg asset {id} isn't a 3D asset 에셋 {id}은(는) 3D 에셋이 아닙니다 +@msg asset {id} is a 3D asset: assets.export3D writes it 에셋 {id}은(는) 3D 에셋입니다: assets.export3D로 내보냅니다 +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 아직 3D 에셋이 없습니다: 먼저 3D 효과가 있는 개체를 수집하세요 (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D 효과가 있는 개체를 선택하세요 (또는 ID를 지정하세요) Export CSS CSS 내보내기 Export CSS… CSS 내보내기… Export DXF DXF 내보내기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f846de4d6..08d36a234 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2949,6 +2949,9 @@ theme Appearance Vormgeving Asset Export Settings Exportinstellingen voor assets Assets Assets Export Assets Assets exporteren + Export 3D Object 3D-object exporteren + 3D Export Format 3D-exportindeling + Export 3D Assets 3D-assets exporteren Remove Asset Asset verwijderen Rename Asset Asset hernoemen Fill Rule Vulregel diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f8fa28e93..089cd17ec 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3069,6 +3069,14 @@ theme Appearance Wygląd Asset Export Settings Ustawienia eksportu zasobów Assets Zasoby Export Assets Eksportuj zasoby + Export 3D Object Eksportuj obiekt 3D + 3D Export Format Format eksportu 3D + Export 3D Assets Eksportuj zasoby 3D +@msg {what} can't be exported in 3D: {e} nie można wyeksportować {what} w 3D: {e} +@msg asset {id} isn't a 3D asset zasób {id} nie jest zasobem 3D +@msg asset {id} is a 3D asset: assets.export3D writes it zasób {id} jest zasobem 3D: zapisuje go assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) brak zasobów 3D: najpierw zbierz obiekt z efektem 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) zaznacz obiekt z efektem 3D (lub podaj jego identyfikatory) Remove Asset Usuń zasób Rename Asset Zmień nazwę zasobu Fill Rule Reguła wypełniania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index df73efa99..17428e1cf 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -974,6 +974,14 @@ Export As PNG… Exportar Como PNG… Export As SVG… Exportar Como SVG… Export Assets Exportar Ativos + Export 3D Object Exportar Objeto 3D + 3D Export Format Formato de Exportação 3D + Export 3D Assets Exportar Ativos 3D +@msg {what} can't be exported in 3D: {e} não é possível exportar {what} em 3D: {e} +@msg asset {id} isn't a 3D asset o ativo {id} não é um ativo 3D +@msg asset {id} is a 3D asset: assets.export3D writes it o ativo {id} é um ativo 3D: assets.export3D o grava +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) ainda não há ativos 3D: colete primeiro um objeto com um efeito 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) selecione um objeto com um efeito 3D (ou informe seus ids) Export As… Exportar Como… Export CSS Exportar CSS Export CSS… Exportar CSS… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c90226537..102848cbf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1084,6 +1084,14 @@ font-technology Variable Вариативные Export As SVG… Экспортировать как SVG… Export Asset Экспортировать ресурс Export Assets Экспортировать ресурсы + Export 3D Object Экспортировать 3D-объект + 3D Export Format Формат 3D-экспорта + Export 3D Assets Экспортировать 3D-ресурсы +@msg {what} can't be exported in 3D: {e} {what} невозможно экспортировать в 3D: {e} +@msg asset {id} isn't a 3D asset ресурс {id} не является 3D-ресурсом +@msg asset {id} is a 3D asset: assets.export3D writes it ресурс {id} является 3D-ресурсом: его записывает assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D-ресурсов пока нет: сначала соберите объект с 3D-эффектом (assets.add3D) +@msg select an object with a 3D effect (or give its ids) выберите объект с 3D-эффектом (или укажите его идентификаторы) Export As… Экспортировать как… Export CSS Экспортировать CSS Export CSS… Экспортировать CSS… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 75ee16447..d1dd3ca1f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1065,6 +1065,14 @@ font-technology Variable Variabla Export As SVG… Exportera som SVG… Export Asset Exportera resurs Export Assets Exportera resurser + Export 3D Object Exportera 3D-objekt + 3D Export Format 3D-exportformat + Export 3D Assets Exportera 3D-resurser +@msg {what} can't be exported in 3D: {e} {what} kan inte exporteras i 3D: {e} +@msg asset {id} isn't a 3D asset resurs {id} är ingen 3D-resurs +@msg asset {id} is a 3D asset: assets.export3D writes it resurs {id} är en 3D-resurs: assets.export3D skriver den +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) inga 3D-resurser än: samla först ett objekt med en 3D-effekt (assets.add3D) +@msg select an object with a 3D effect (or give its ids) markera ett objekt med en 3D-effekt (eller ange dess id:n) Export As… Exportera som… Export CSS Exportera CSS Export CSS… Exportera CSS… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9d807d760..b70d49803 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1057,6 +1057,14 @@ font-technology Variable Değişken Export As SVG… SVG Olarak Dışa Aktar… Export Asset Varlığı Dışa Aktar Export Assets Varlıkları Dışa Aktar + Export 3D Object 3D Nesneyi Dışa Aktar + 3D Export Format 3D Dışa Aktarma Biçimi + Export 3D Assets 3D Varlıkları Dışa Aktar +@msg {what} can't be exported in 3D: {e} {what} 3D olarak dışa aktarılamıyor: {e} +@msg asset {id} isn't a 3D asset {id} varlığı bir 3D varlık değil +@msg asset {id} is a 3D asset: assets.export3D writes it {id} varlığı bir 3D varlık: onu assets.export3D yazar +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) henüz 3D varlık yok: önce 3D efekti olan bir nesne toplayın (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D efekti olan bir nesne seçin (veya kimliklerini verin) Export As… Farklı Dışa Aktar… Export CSS CSS'yi dışa aktar Export CSS… CSS'yi dışa aktar… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6d7e38cfb..9c1fbf9ce 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1081,6 +1081,14 @@ font-technology Variable Варіативні Export As SVG… Експортувати як SVG… Export Asset Експортувати ресурс Export Assets Експортувати ресурси + Export 3D Object Експортувати 3D-об’єкт + 3D Export Format Формат 3D-експорту + Export 3D Assets Експортувати 3D-ресурси +@msg {what} can't be exported in 3D: {e} {what} неможливо експортувати в 3D: {e} +@msg asset {id} isn't a 3D asset ресурс {id} не є 3D-ресурсом +@msg asset {id} is a 3D asset: assets.export3D writes it ресурс {id} є 3D-ресурсом: його записує assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D-ресурсів ще немає: спершу зберіть об’єкт із 3D-ефектом (assets.add3D) +@msg select an object with a 3D effect (or give its ids) виберіть об’єкт із 3D-ефектом (або вкажіть його ідентифікатори) Export As… Експортувати як… Export CSS Експортувати CSS Export CSS… Експортувати CSS… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5d9150df2..d9239a386 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1245,6 +1245,14 @@ font-technology Variable Biến thiên Export As SVG… Xuất thành SVG… Export Asset Xuất asset Export Assets Xuất asset + Export 3D Object Xuất đối tượng 3D + 3D Export Format Định dạng xuất 3D + Export 3D Assets Xuất asset 3D +@msg {what} can't be exported in 3D: {e} không thể xuất {what} dưới dạng 3D: {e} +@msg asset {id} isn't a 3D asset asset {id} không phải là asset 3D +@msg asset {id} is a 3D asset: assets.export3D writes it asset {id} là asset 3D: assets.export3D sẽ ghi nó +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) chưa có asset 3D nào: hãy thu thập trước một đối tượng có hiệu ứng 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) hãy chọn một đối tượng có hiệu ứng 3D (hoặc cung cấp id của nó) Export As… Xuất thành… Export CSS Xuất CSS Export CSS… Xuất CSS… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 849a626eb..cbfd6010d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1027,6 +1027,14 @@ Export As SVG… 导出为 SVG… Export Asset 导出资源 Export Assets 导出资源 + Export 3D Object 导出 3D 对象 + 3D Export Format 3D 导出格式 + Export 3D Assets 导出 3D 资源 +@msg {what} can't be exported in 3D: {e} 无法将 {what} 导出为 3D:{e} +@msg asset {id} isn't a 3D asset 资源 {id} 不是 3D 资源 +@msg asset {id} is a 3D asset: assets.export3D writes it 资源 {id} 是 3D 资源:由 assets.export3D 写入 +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 还没有 3D 资源:请先收集带有 3D 效果的对象 (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 请选择带有 3D 效果的对象 (或提供其 ID) Export As… 导出为… Export CSS 导出 CSS Export CSS… 导出 CSS… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7a5f8aa43..86cf8db1e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1051,6 +1051,14 @@ font-technology Variable 可變 Export As SVG… 匯出為 SVG… Export Asset 匯出資產 Export Assets 匯出資產 + Export 3D Object 匯出 3D 物件 + 3D Export Format 3D 匯出格式 + Export 3D Assets 匯出 3D 資產 +@msg {what} can't be exported in 3D: {e} 無法將 {what} 匯出為 3D:{e} +@msg asset {id} isn't a 3D asset 資產 {id} 不是 3D 資產 +@msg asset {id} is a 3D asset: assets.export3D writes it 資產 {id} 是 3D 資產:由 assets.export3D 寫入 +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 尚無 3D 資產:請先收集具有 3D 效果的物件 (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 請選取具有 3D 效果的物件 (或提供其 ID) Export As… 匯出為… Export CSS 匯出 CSS Export CSS… 匯出 CSS… diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 1b438adf9..52ce3677c 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -244,7 +244,8 @@ pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); // Without a folder the files come back in the reply, which the caller waits for. - let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty(); + let in_background = + app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty() && id != "assets.export3D"; if !in_background { // Made at once, after the exports and saves started before it. background::wait_all(app); diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 073b1130e..a29714b1a 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -347,10 +347,12 @@ fn open_object_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { } return true; } - // Export Assets, Remove, Rename and Asset Export Settings with no params, as the command - // palette runs them (#4864): the Asset Export panel, where each one is done. A panel that already - // shows stays open. - if matches!(id, "assets.export" | "assets.remove" | "assets.rename" | "assets.settings.set") && p.as_object().is_none_or(|o| o.is_empty()) { + // Export Assets, Remove, Rename, Asset Export Settings, Export 3D Assets and 3D Export Format + // with no params, as the command palette runs them (#4864): the Asset Export panel, where each + // one is done. A panel that already shows stays open. + if matches!(id, "assets.export" | "assets.remove" | "assets.rename" | "assets.settings.set" | "assets.export3D" | "assets.set3DFormat") + && p.as_object().is_none_or(|o| o.is_empty()) + { if let Err(e) = crate::panel_docking::reveal_panel(app, crate::panels::asset_export::ID) { app.status(e); } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index fda2e1f7f..4f893e686 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -222,7 +222,7 @@ mod tests { /// Asset Export panel instead of failing on the missing params or exporting nowhere. #[test] fn a_pick_of_an_asset_export_command_opens_its_panel() { - let ids = ["assets.export", "assets.remove", "assets.rename", "assets.settings.set"]; + let ids = ["assets.export", "assets.remove", "assets.rename", "assets.settings.set", "assets.export3D", "assets.set3DFormat"]; for id in ids { assert!(items().iter().any(|(_, item, _)| item == id), "{id} is in the palette"); let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 872bcc0d6..f5fc9b0b9 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -7,6 +7,10 @@ //! desktop with Background Export on, on the background thread); the web downloads them. The //! screen button opens Export for Screens on its Assets tab. //! +//! Export 3D Object (the panel menu) collects the selected objects with a 3D effect as 3D assets, +//! marked 3D on their tiles. While the panel's assets include 3D assets, a Format row picks their +//! mesh format (USDA, USDZ, glTF or OBJ), and Export writes them as mesh files beside the pictures. +//! //! Everything runs through the `assets.*` commands. use std::cell::RefCell; @@ -17,7 +21,7 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{ExportAsset, Node, NodeId}; use vectorcraft_engine::DocState; -use vectorcraft_engine::cmd::fileio::{SCREEN_SETTINGS_KEYS, screen_export_preset_list}; +use vectorcraft_engine::cmd::fileio::{FORMATS_3D, SCREEN_SETTINGS_KEYS, format_3d_label, screen_export_preset_list}; use super::{pstate, set_pstate}; use crate::dialogs::{ @@ -131,6 +135,11 @@ fn tiles(ui: &mut Ui, st: &DocState, sel: &[u64], ev: &mut Events) { let th = Rect::from_min_size(egui::pos2(r.center().x - THUMB / 2.0, r.top() + 4.0), vec2(THUMB, THUMB)); paint_thumb(ui, st, a, th); ui.painter().rect_stroke(th, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, if on { t.accent } else { t.border }), StrokeKind::Outside); + if a.format_3d.is_some() { + let badge = Rect::from_min_size(th.left_top() + vec2(2.0, 2.0), vec2(18.0, 12.0)); + ui.painter().rect_filled(badge, 2.0, t.accent); + ui.painter().text(badge.center(), egui::Align2::CENTER_CENTER, "3D", egui::FontId::proportional(9.0), Color32::WHITE); + } let name_rect = Rect::from_min_max(egui::pos2(r.left() + 2.0, th.bottom() + 3.0), egui::pos2(r.right() - 2.0, r.bottom() - 1.0)); match &renaming { Some((id, buf)) if *id == a.id => { @@ -214,6 +223,12 @@ fn export(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { } fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { + // 3D assets are written as mesh files (`assets.export3D`), the others as pictures. + let Some(st) = app.session.active() else { return }; + let format = |id: &u64| st.doc.asset(*id).and_then(|a| a.format_3d.clone()); + let (three_d, ids): (Vec, Vec) = ids.into_iter().partition(|id| format(id).is_some()); + // An OBJ is two files. + let mesh_files: usize = three_d.iter().map(|id| if format(id).as_deref() == Some("obj") { 2 } else { 1 }).sum(); let mut p = json!({ "assets": ids }); // Match the shared dialog's TIFF defaults, retaining saved choices and other formats. if let Some(st) = app.session.active() @@ -251,7 +266,23 @@ fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { p["folder"] = json!(folder); p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true)); } - if let Err(e) = io::export_files(app, "assets.export", p) { + let mut meshes = json!({ "assets": three_d }); + if io::is_web(app) { + meshes["zip"] = json!(mesh_files > 1); + } else { + meshes["folder"] = p["folder"].clone(); + meshes["openLocation"] = p["openLocation"].clone(); + } + // The meshes are written at once, before the pictures start on the background thread. When + // they fail, the pictures are not exported and the status bar shows the error. + if !three_d.is_empty() + && let Err(e) = io::export_files(app, "assets.export3D", meshes) + { + return app.status(e); + } + if !ids.is_empty() + && let Err(e) = io::export_files(app, "assets.export", p) + { app.status(e); } } @@ -264,6 +295,21 @@ enum Settings { Format(String), } +/// The Format of the 3D assets among `ids`: a row shown only when there are some, set to their +/// format (blank when they differ). Returns those assets and the format picked for them. +fn format_3d(ui: &mut Ui, st: &DocState, ids: &[u64]) -> Option<(Vec, &'static str)> { + let three_d: Vec<(u64, &str)> = ids.iter().filter_map(|id| Some((*id, st.doc.asset(*id)?.format_3d.as_deref()?))).collect(); + let first = three_d.first()?.1; + let current = if three_d.iter().all(|(_, f)| *f == first) { format_3d_label(first) } else { "" }; + let labels: Vec<&str> = FORMATS_3D.iter().map(|f| format_3d_label(f)).collect(); + let mut picked = None; + ui.horizontal(|ui| { + widgets::dim_label(ui, &format!("3D {}", tl!("Format:"))); + picked = widgets::dropdown_names(ui, "asset-export-3d-format", current, &labels, 90.0).and_then(|i| FORMATS_3D.get(i).copied()); + }); + picked.map(|f| (three_d.into_iter().map(|(id, _)| id).collect(), f)) +} + /// The document's shared export format rows. Returns the change asked for and how many files one asset makes. fn settings(ui: &mut Ui, st: &DocState) -> (Option, usize) { let (rows, preset) = screen_saved_rows(&st.doc.export_settings); @@ -316,6 +362,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(6.0); widgets::section_header(ui, tl!("Export Settings")); let (change, files_each) = settings(ui, st); + let all_ids: Vec = st.doc.assets.iter().map(|a| a.id).collect(); + let format_change = format_3d(ui, st, &sel_or_all(&sel, &all_ids)); let doc = st.uid; let mut save_name = pstate::>(&ctx, SAVING); let mut save_pick = None; @@ -392,6 +440,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(pick) = save_pick { preset(app, &ctx, doc, pick); } + if let Some((assets, format)) = format_change { + run(app, "assets.set3DFormat", json!({ "assets": assets, "format": format })); + } if add { collect(app, &ctx, None, alt); } @@ -525,6 +576,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Add Selected Artwork as Multiple Assets"), has_art, false) { collect(app, &ctx, None, false); } + if menu_item(ui, tl!("Export 3D Object"), has_art, false) + && let Some(r) = run(app, "assets.add3D", json!({})) + { + let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + set_pstate(&ctx, SELECTED, ids); + } ui.separator(); if menu_item(ui, tl!("Select All Assets"), !all.is_empty(), false) { set_pstate(&ctx, SELECTED, all.clone()); diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs index 3c686d335..ad8ec702c 100644 --- a/crates/ui-egui/src/panels/tests_asset_export.rs +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -743,3 +743,78 @@ fn tiff_saved_aliases_use_document_defaults_and_explicit_rgb() { } let _ = std::fs::remove_dir_all(&dir); } + +/// Export 3D Object in the panel menu collects the selected 3D object; its tile is marked 3D, the +/// Format row sets its mesh format, and Export writes it as a mesh file beside the pictures. +#[test] +fn a_3d_asset_takes_its_format_from_the_row_and_exports_as_a_mesh() { + let (mut app, log, [a, b]) = app(true); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "params": {"depth": 20}})).unwrap(); + app.run("layer.setProps", json!({"id": a, "name": "Box"})).unwrap(); + app.run("assets.add", json!({"ids": [b], "name": "Flat"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // Without a 3D asset there is no 3D Format row. + frame(&ctx, &mut app, vec![], false); + assert!(!has(&frame(&ctx, &mut app, vec![], false), "3D Format:")); + menu_frame(&ctx, &mut app, vec![]); + let items = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&items, " Export 3D Object"))); + let list = assets(&mut app); + assert_eq!((list[1]["name"].clone(), list[1]["format3D"].clone()), (json!("Box"), json!("usda"))); + let texts = frame(&ctx, &mut app, vec![], false); + let texts = if has(&texts, "3D Format:") { texts } else { frame(&ctx, &mut app, vec![], false) }; + assert!(has(&texts, "3D") && has(&texts, "3D Format:"), "{:?}", texts.iter().map(|t| &t.0).collect::>()); + frame(&ctx, &mut app, click(at(&texts, "USDA")), false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "OBJ")), false); + assert_eq!(assets(&mut app)[1]["format3D"], "obj"); + // The menu left the new 3D asset selected: Export writes its OBJ and MTL alone, as one zip. + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export 1 Selected")), false); + { + let log = log.borrow(); + let [(name, zip)] = log.as_slice() else { panic!("{:?} {}", log.iter().map(|f| &f.0).collect::>(), app.ui.status) }; + assert!(name.ends_with(".zip"), "{name}"); + let has_name = |n: &str| zip.windows(n.len()).any(|w| w == n.as_bytes()); + assert!(has_name("Box.obj") && has_name("Box.mtl")); + } + // Every asset: the 3D asset's files first, since they are written at once, then the picture + // asset as before. + log.borrow_mut().clear(); + menu_frame(&ctx, &mut app, vec![]); + let items = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&items, " Select All Assets"))); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + let log = log.borrow(); + assert_eq!(log.iter().map(|f| f.0.ends_with(".zip")).collect::>(), [true, false], "{:?}", log.iter().map(|f| &f.0).collect::>()); + assert!(log[1].0.starts_with("Flat"), "{}", log[1].0); +} + +/// When the 3D assets fail to export, Export leaves the picture assets unexported and the status +/// bar keeps the 3D error. +#[test] +fn a_failed_3d_export_stops_the_picture_export() { + let (mut app, log, [a, b]) = app(true); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "params": {"depth": 20}})).unwrap(); + app.run("layer.setProps", json!({"id": a, "name": "Box"})).unwrap(); + app.run("assets.add3D", json!({"ids": [a]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.remove", json!({"index": 0})).unwrap(); + app.run("assets.add", json!({"ids": [b], "name": "Flat"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + menu_frame(&ctx, &mut app, vec![]); + let items = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&items, " Select All Assets"))); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + assert!(log.borrow().is_empty(), "{:?}", log.borrow().iter().map(|f| &f.0).collect::>()); + assert!(app.ui.status.contains("Box"), "{}", app.ui.status); +} diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 6fe07e086..ac80c1f6f 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -183,11 +183,11 @@ VectorCraft choices; an original approximation, never compared with another appl | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | | Turntable (30.0 beta) | none | -| Export 3D objects (OBJ, USDA, glTF) | none | +| Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces; the effects crate turns them into art. What's left (materials and shadows, ray-traced rendering, -angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 h. +angular cut caps, exact visibility, 3D Classic, Turntable) is 25–45 h. ## Revision history diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9fc6094fd..7259b9775 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -21,7 +21,7 @@ from the public specifications, and users' own `.ai` files are used locally only | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | -| Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | +| Formats exported | ~16 | 18 | missing: DWG; extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | EPS editing-data follow-up (2026-10-16): both mesh-corner layouts (with and without opacity) @@ -282,7 +282,7 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | Word `.docx` | place | place (area type with the document's styles and its fonts, size, bold, italic, underline, strikethrough, caps, superscript and subscript, color, alignment, indents and paragraph spacing) | no | `tests/textimport.rs`, `place/docx.rs`; fuzzed (`text_import_fuzz.rs`); list numbers, pictures and text boxes are left out | | Word `.doc` | place | place (area type with the same formatting as `.docx`, from the style sheet's styles and direct formatting; Word 97 and later) | no | `tests/textimport.rs`, `place/doc.rs`; fuzzed (`text_import_fuzz.rs`); pictures, headers, footers, notes, comments and text boxes are left out with a warning; encrypted and fast-saved documents are refused | | CSS | export (CSS Properties) | — | yes | | -| OBJ / USDA / glTF | 3D export | — | no | needs 3D (gap G3) | +| OBJ / USDA / glTF | 3D export | — | yes, also USDZ (a 3D asset of Asset Export: `assets.add3D`, `assets.export3D`) | each solid of an object with Extrude & Bevel, Revolve, Inflate or Rotate is a triangle mesh of its faces before projection, at the object's 3D rotation, y up, with a two-sided material in its paint's color; glTF 2.0 is one `.gltf` with its buffer embedded, in meters; USDA and OBJ are in points (USDA says its `metersPerUnit`), OBJ with an `.mtl`; USDZ stores the USDA uncompressed on a 64-byte boundary; tests in `crates/three-d/src/mesh/tests.rs`, `crates/engine/src/cmd/fileio/mesh3d/tests.rs` and `crates/engine/src/tests/assets_3d.rs` | | Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) | | Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art; cells copied from a spreadsheet (tab-separated text) paste as a table of point type over gridlines, at most 1000 rows, 256 columns and 20,000 cells (#5296) | diff --git a/docs/gaps.md b/docs/gaps.md index 8369b2573..40626920f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -637,7 +637,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, 3D Classic, - Turntable, 3D export (OBJ, USDA, glTF). + Turntable, textures, map art and shading in 3D export. - **Impact:** specialist; Materials is the one menu stub left. - **Estimate:** 25–45 h; plan in `plan/` before coding. diff --git a/docs/mcp.md b/docs/mcp.md index 75572914c..891fd6e14 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5153,6 +5153,38 @@ renderer measures for what the art draws, its effects included. A layer id in `ids` (12 above) is the same as Collect for Export on that Layers row: every object under the layer becomes an asset (or one asset with `multiple: false`). +### 3D assets + +Asset Export's menu item Export 3D Object (`assets.add3D {ids?, format?}`) collects each selected object (or `ids`) +that carries Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, named after the object; an object already +collected as a 3D asset keeps it, and an object without such an effect is refused. A 3D asset's Format is `usda` (the +default), `usdz`, `gltf` or `obj`; the panel shows a Format row for the 3D assets it would export, and +`assets.set3DFormat {assets, format}` sets it (an undo step only when the format changes). `assets.list` gives each +asset's `format3D` (null for a picture asset). + +`assets.export3D {assets?, folder?, zip?}` writes each 3D asset (default: all of them) as a mesh file named after it: +`.usda`, `.usdz`, `.gltf` (glTF 2.0, its buffer embedded) or `.obj` with its `.mtl` (whitespace and `#` in an OBJ's +name become `_`). Every solid of the object (the +fill's, each stroke's, a group's members') is its own mesh, with a two-sided material in the color it is painted +(a gradient's stops averaged, a pattern gray). The mesh holds the solid's faces before they are projected onto the +page, those turned away from the viewer included, at the object's 3D rotation, y up, about the point the object turns +about: in points (USD's layer gives `metersPerUnit`), in meters in glTF. Files that would share a name get `-2`, `-3` and so on. +With `zip`, the files go into one ZIP named after the document with `-3D` added (`Untitled-1-3D.zip`), or +`3D Assets.zip` when the document's title gives no file name. +An object whose face has a hole that crosses the face's outline or another hole is refused, by `assets.add3D` and +`assets.export3D` alike, rather than exported with the hole filled. So is an object whose meshes would hold more than +2,000,000 triangles together, VectorCraft's own limit. +`assets.export` leaves 3D assets out and refuses one named in its `assets`. On the 3D export page +() +the 3D asset's Format is picked in Asset Export, with USDA first; the file layouts follow the glTF 2.0 specification +() and OpenUSD (). + +```json +{"name":"run_command","arguments":{"command":"assets.add3D","params":{"ids":[12],"format":"gltf"}}} +{"name":"run_command","arguments":{"command":"assets.set3DFormat","params":{"assets":[40],"format":"obj"}}} +{"name":"run_command","arguments":{"command":"assets.export3D","params":{"folder":"/tmp/meshes"}}} +``` + ## CSS Properties `css.selection {ids?, units?, position?, dimensions?, unnamed?, rasterize?}` returns the CSS web pages style the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c92cf09e1..43844d158 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -187,7 +187,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | @@ -195,7 +195,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, 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, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 52% | the Effect Gallery's filter thumbnails and in-dialog preview area ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | 1–2 | estimated (raised from 49%: the Effect Gallery shipped, #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated | @@ -204,6 +204,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path comes from VectorCraft's font metrics, and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | @@ -985,6 +986,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). + - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Pathfinder panel operation, direct `object.pathfinder.*` command or `command.batch` step (including Actions playback) on the current selection; the panel menu names it. `action.batch`, Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html). From 581ced3213f1d71e938e52cc5cb45f0bb1a8d14f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:29:28 +0700 Subject: [PATCH 15/93] Transform: Align to Pixel Grid keeps an object's horizontal and vertical edges on the pixel grid Squashed from #6169 onto main. --- crates/doc/src/node/extras.rs | 4 + crates/doc/src/node/tests.rs | 3 + crates/doc/src/node/wire.rs | 5 + crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/appearance.rs | 25 +- crates/engine/src/cmd/edit.rs | 20 +- crates/engine/src/cmd/globaledit.rs | 2 + crates/engine/src/cmd/layer.rs | 1 + crates/engine/src/cmd/menucmds.rs | 4 + crates/engine/src/cmd/object.rs | 2 + crates/engine/src/cmd/object/align.rs | 15 + crates/engine/src/cmd/object/commands.rs | 10 + crates/engine/src/cmd/object/compound.rs | 13 + crates/engine/src/cmd/object/pixelgrid.rs | 407 ++++++++++ crates/engine/src/cmd/object/shape.rs | 3 + crates/engine/src/cmd/object/transform.rs | 9 +- crates/engine/src/cmd/objects_on_path.rs | 16 +- crates/engine/src/cmd/paint.rs | 3 + crates/engine/src/cmd/panelcmds.rs | 5 +- crates/engine/src/cmd/path.rs | 15 + crates/engine/src/cmd/slices.rs | 2 + crates/engine/src/cmd/stroke.rs | 2 + crates/engine/src/cmd/style.rs | 1 + crates/engine/src/cmd/xform.rs | 1 + crates/engine/src/session/edit.rs | 10 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/pixelalign.rs | 768 +++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/transform.rs | 34 +- crates/ui-egui/src/panels/transform/tests.rs | 44 ++ docs/mcp.md | 50 ++ docs/target-app-parity.md | 1 + 58 files changed, 1565 insertions(+), 15 deletions(-) create mode 100644 crates/engine/src/cmd/object/pixelgrid.rs create mode 100644 crates/engine/src/tests/pixelalign.rs diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 90b2726d3..6865ff7e6 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -69,6 +69,9 @@ pub struct NodeExtras { /// As an envelope with margins in Edit Contents, the margins its strokes gave when Edit Contents /// began ([`crate::live::EnvelopeOptions::bounds_margin`]). pub envelope_edit_margin: Option<[f32; 4]>, + /// Transform panel › Align to Pixel Grid: the path's or compound path's horizontal and vertical + /// segments stay on the pixel grid when it is transformed. + pub pixel_aligned: bool, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -95,6 +98,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { shape_mode: crate::ShapeMode::Add, symbol_overrides: Vec::new(), envelope_edit_margin: None, + pixel_aligned: false, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index ee29f75eb..43a8ac97b 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -22,6 +22,7 @@ fn extras_are_written_flat_in_order() { e.compound_shape = true; e.shape_mode = crate::ShapeMode::Exclude; e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]); + e.pixel_aligned = true; } /// The top-level keys of a JSON object, in the order written. struct Keys(Vec); @@ -67,9 +68,11 @@ fn extras_are_written_flat_in_order() { "compound_shape", "shape_mode", "envelope_edit_margin", + "pixel_aligned", ]; assert_eq!(keys, order); assert_eq!(v["shape_mode"], "exclude"); + assert_eq!(v["pixel_aligned"], true); assert_eq!(serde_json::from_value::(v).unwrap(), n); let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 32191f58c..b54d3f4b8 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -54,6 +54,7 @@ impl Serialize for Node { is_default(&e.shape_mode), e.symbol_overrides.is_empty(), e.envelope_edit_margin.is_none(), + is_default(&e.pixel_aligned), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -96,6 +97,7 @@ impl Serialize for Node { field!("shape_mode", &e.shape_mode); field!("symbol_overrides", &e.symbol_overrides); field!("envelope_edit_margin", &e.envelope_edit_margin); + field!("pixel_aligned", &e.pixel_aligned); st.end() } } @@ -236,6 +238,8 @@ pub(super) struct NodeWire { symbol_overrides: Vec, #[serde(default)] envelope_edit_margin: Option<[f32; 4]>, + #[serde(default)] + pixel_aligned: bool, } impl From for Node { @@ -263,6 +267,7 @@ impl From for Node { shape_mode: w.shape_mode, symbol_overrides: w.symbol_overrides, envelope_edit_margin: w.envelope_edit_margin, + pixel_aligned: w.pixel_aligned, }; Node { id: w.id, diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a7194fb2e..ade2994a6 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -674,6 +674,7 @@ pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; +pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 4a1622c9d..7f7b34352 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -598,6 +598,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { n.appearance.items.push(item); added.push((*id, fill)); } + if !fill { + super::object::snap_if_aligned(n); + } } } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); @@ -620,6 +623,7 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { for id in &ids { if let Some(n) = d.node_mut(*id) { clear(n); + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); @@ -727,9 +731,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result { } to.appearance = ap; (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape); + super::object::snap_if_aligned(to); if !copy && let Some(n) = d.node_mut(source) { clear(n); (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); + super::object::snap_if_aligned(n); } let changed = if copy { vec![target] } else { vec![source, target] }; let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); @@ -788,6 +794,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { st.blend = BlendMode::Normal; n.appearance.items.push(AppearanceItem::Stroke(st)); } + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); @@ -847,6 +854,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("visible").and_then(Value::as_bool) { *vis = v; } + super::object::snap_if_aligned(n); let items = &n.appearance.items; if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx)) @@ -907,6 +915,7 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { for &i in idx.iter().rev() { n.appearance.remove_item(i); } + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); @@ -1011,7 +1020,19 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { let roots = require_targets(s, p, "appearance.showAllHidden")?; let before = s.doc()?.doc.clone(); let ids = s.edit("Show All Hidden Attributes", |d, _| { - let shown: Vec = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); + let shown: Vec = roots + .iter() + .copied() + .filter(|id| { + d.node_mut(*id).is_some_and(|n| { + let shown = n.appearance.show_all(); + if shown { + super::object::snap_if_aligned(n); + } + shown + }) + }) + .collect(); if shown.is_empty() { return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into())); } @@ -1408,6 +1429,7 @@ impl Picked { let placed = self.placed(n.geometric_bounds()); n.appearance.items.extend(placed.items.into_iter().filter(|it| if it.is_fill() { ap.fill.color } else { ap.stroke.color })); n.appearance.effects.extend(placed.effects); + super::object::snap_if_aligned(n); return; } if let NodeKind::Text(t) = &mut n.kind { @@ -1431,6 +1453,7 @@ impl Picked { } else { focal_attrs(&mut n.appearance, &placed, ap); } + super::object::snap_if_aligned(n); } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8c7de8bdc..f6491d757 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1299,10 +1299,11 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho for (k, n) in clip.nodes.iter().enumerate() { let mut c = d.reid(n); imported.apply(&mut c); + let sc = vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() }; if xf != Affine::IDENTITY { // A cross-document drag carries the graphic's appearance with it, // including the pattern phase, independently of transform-tool settings. - c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() }); + c.transform(xf, sc); } let layer = match clip.source_layers.get(k) { Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))), @@ -1338,6 +1339,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho if let NodeKind::Path { live, .. } = &mut child_node.kind { *live = None; } + if child_node.extras().pixel_aligned { + child_node.extras_mut().pixel_aligned = false; + } let insert_idx = if idx == usize::MAX { usize::MAX } else { idx.saturating_add(offset) }; new_ids.push(d.insert(par, insert_idx, child_node)?); } @@ -1350,6 +1354,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho if let NodeKind::Path { live, .. } = &mut c.kind { *live = None; } + // A path inside a compound path snaps with the compound path, whose property it takes. + if c.extras().pixel_aligned { + c.extras_mut().pixel_aligned = false; + } new_ids.push(d.insert(par, idx, c)?); } _ => { @@ -1358,14 +1366,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho let offset = *placed.entry(outer_par).or_default(); *placed.entry(outer_par).or_default() += 1; let insert_idx = if beside_idx == usize::MAX { usize::MAX } else { beside_idx.saturating_add(offset) }; + super::object::keep_pixel_aligned(&mut c, sc); new_ids.push(d.insert(outer_par, insert_idx, c)?); } } } else { + super::object::keep_pixel_aligned(&mut c, sc); new_ids.push(d.insert(par, idx, c)?); } } } + for id in &new_ids { + super::object::keep_compound_aligned(d, *id, vectorcraft_doc::Scaling::default()); + } sel.set(new_ids.iter().copied()); // Copied ruler guides (#3226): on the layer new art goes to, moved as the objects are (a // paste of guides alone keeps them where they were, but onto another artboard or every @@ -1437,7 +1450,10 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { /// Duplicate `roots` directly above each original, transform the copies, select them /// ([`copy_above`]). pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result> { - let out = copy_above(d, roots, |_, _, c| c.transform(xf, true))?; + let out = copy_above(d, roots, |_, _, c| { + c.transform(xf, true); + super::object::keep_pixel_aligned(c, true.into()); + })?; sel.set(out.iter().copied()); Ok(out) } diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index ec0778364..4a42f03d4 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -768,10 +768,12 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se let mut placed = model.clone(); placed.transform(*xf * back, Scaling::default()); give(was, &model, placed, &mut n, d); + super::object::keep_pixel_aligned(&mut n, Scaling::default()); if let Some(slot) = d.node_mut(*id) { *slot = n; edited.push(*id); } + super::object::keep_compound_aligned(d, *id, Scaling::default()); } edited } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index d5437d017..6fe0746ac 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -1125,6 +1125,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { for id in &e.art { if let Some(n) = d.node_mut(*id) { n.transform(xf, sc); + super::object::keep_pixel_aligned(n, sc); } } } else if !e.resized { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 6992f42ab..4a48319f3 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -620,6 +620,9 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result { if let Some(n) = d.node_mut(*id) { super::object::transform_node(n, m, sc, objects); } + if objects { + super::object::keep_compound_aligned(d, *id, sc); + } } Ok(targets) })?; @@ -1363,6 +1366,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { for (id, dl) in &art { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(*dl), sc); + super::object::keep_pixel_aligned(n, sc); } } let mut moved = vec![]; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index ade05bd95..c467f06e5 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -25,6 +25,7 @@ mod arrange; mod bounds; mod commands; mod compound; +mod pixelgrid; mod props; mod shape; mod spans; @@ -35,5 +36,6 @@ pub(crate) use arrange::group_nodes; pub(crate) use bounds::{bounds_rewrite, object_bounds_transform}; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; +pub(crate) use pixelgrid::{keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned}; pub(super) use shape::recut; pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects}; diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index 156b35824..82a61b02a 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -112,7 +112,9 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: & for (id, dv) in moves { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(*dv), sc); + super::pixelgrid::keep_pixel_aligned(n, sc); } + super::pixelgrid::keep_compound_aligned(d, *id, sc); } for &(i, pos) in &guides { if let Some(g) = d.guides.get_mut(i) { @@ -330,6 +332,7 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st a.translate(target(a.p) - a.p); } } + snap_anchor_path(d, *id); } Ok(()) })?; @@ -434,11 +437,23 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option Result { check_switches(p, &["patterns"], "object.distribute")?; check_numbers(p, &["artboard"], "object.distribute")?; diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 69f464edb..f248294f2 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -2,6 +2,7 @@ use super::align::{align, can_distribute, distribute, distribute_spacing}; use super::arrange::{Arrange, arrange, exit_isolation, group, hide, isolate, lock, send_to_current_layer, show_all, ungroup, unlock_all}; use super::bounds::set_bounds; use super::compound::{clip_edit, clip_make, clip_release, compound_make, compound_release}; +use super::pixelgrid::set_pixel_aligned; use super::props::set_props; use super::shape::{expand_shape, set_live_shape}; use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again}; @@ -300,5 +301,14 @@ pub fn specs() -> Vec { has_selection, set_live_shape ), + cmd!( + "object.setPixelAligned", + "Align to Pixel Grid", + [], + None, + "{ids?|id?, on?: bool (default true)} → {ids (the objects whose property changed)} (Transform panel › Align to Pixel Grid: on, each object's horizontal and vertical straight segments move to whole points, or half points when a visible, painted centered stroke has an odd weight, its stroke weights become whole points (at least 1), and every later move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor (path.moveAnchor), path.setAnchors, object.setLiveShape, move of a compound path's path on its own, artboard move or resize that carries the art (artboard.move, artboard.setProps) or copies it (artboard.move with copy, Duplicate Artboards), every copy that edit.duplicate or a paste moves and object.slice.move of an object slice snaps them again, as does every stroke.set, stroke.weightUp or stroke.weightDown edit (which also rounds the new weight) and every appearance.setItem, appearance.addStroke, appearance.removeItem, appearance.clear, appearance.reduceToBasic, appearance.showAllHidden, appearance.transfer, appearance.copyFrom, paint.setStroke or graphicStyle.apply edit, and every other command that changes the object; diagonal segments and curves keep their shape apart from the anchors they share with those segments, and coordinates that differ stay at least 1 point apart; a live rectangle, polygon, line or pie moves and scales as a whole to the snapped bounds (a full ellipse, all curves, stays as it is), and a path with Live Corners snaps the outline its corners are cut from; a compound path snaps its paths with its own stroke, Make Compound Path from an aligned path or compound path gives an aligned compound path whose paths drop the property, and Release Compound Path gives each path the compound path's property; off, the object keeps its geometry and stops snapping. Only paths and compound paths can be aligned: type, images, groups, guides, paths inside a compound path and other objects are an error and nothing changes. One undo step; nothing changes and no undo step is made when every object already has the value. Saved in the native file)", + has_selection, + set_pixel_aligned + ), ] } diff --git a/crates/engine/src/cmd/object/compound.rs b/crates/engine/src/cmd/object/compound.rs index 1dabc76db..54e4d6f88 100644 --- a/crates/engine/src/cmd/object/compound.rs +++ b/crates/engine/src/cmd/object/compound.rs @@ -13,11 +13,15 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { d.node(backmost).ok_or(EngineError::NoNode(backmost))?; let mut units: Vec>> = vec![]; let mut appearance = None; + // The compound path is pixel-aligned when any of its paths was, or any compound path whose + // paths it takes; its paths snap with its stroke, so they do not keep the property themselves. + let mut aligned = false; for id in &ids { let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) { return Err(EngineError::Other("compound paths can only contain paths".into())); } + n.walk(&mut |c| aligned |= c.extras().pixel_aligned); compound_members(n, &mut appearance, &mut units); } let cid = d.alloc_id(); @@ -40,6 +44,7 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { for mut p in unit { let pn = Arc::make_mut(&mut p); pn.appearance = Appearance::default().into(); + pn.extras_mut().pixel_aligned = false; if let NodeKind::Path { path, live, .. } = &mut pn.kind { *live = None; if flip { @@ -49,6 +54,10 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { ch.push(p); } } + if aligned { + c.extras_mut().pixel_aligned = true; + super::pixelgrid::snap_if_aligned(&mut c); + } d.wrap(&ids, |_| c)?; sel.set([cid]); Ok(cid) @@ -99,6 +108,10 @@ pub(super) fn compound_release(s: &mut Session, _: &Value) -> Result { for c in children { let mut c = (**c).clone(); c.appearance = n.appearance.clone(); + // Each path keeps the compound path's pixel-aligned property. + if n.extras().pixel_aligned { + c.extras_mut().pixel_aligned = true; + } out.push(c.id); parts.push(c); } diff --git a/crates/engine/src/cmd/object/pixelgrid.rs b/crates/engine/src/cmd/object/pixelgrid.rs new file mode 100644 index 000000000..6441fda0c --- /dev/null +++ b/crates/engine/src/cmd/object/pixelgrid.rs @@ -0,0 +1,407 @@ +//! Transform panel › Align to Pixel Grid: an object's pixel-aligned property, and the snapping that +//! keeps the horizontal and vertical segments of a pixel-aligned object on the pixel grid +//! (). + +use vectorcraft_doc::{AppearanceItem, LiveShape, StrokeAlign}; + +use super::{ + Affine, Arc, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, Scaling, Session, Value, Vec2, bad, json, + selected_roots, +}; +use crate::cmd::{given_objects, switch_or}; + +const C: &str = "object.setPixelAligned"; + +/// How far apart two coordinates may be and still count as one, in points: segments this close to +/// horizontal or vertical count as horizontal or vertical. +const EPS: f64 = 1e-6; + +/// Whether `n` can be aligned to the pixel grid: a path that is not a guide, or a compound path. +/// Type, images, groups and other objects cannot. +pub(crate) fn can_align(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) +} + +/// `object.setPixelAligned`: turn the pixel-aligned property of the objects given (or the selected +/// objects) on or off. Turning it on snaps them to the pixel grid at once. Every object must be +/// one [`can_align`] accepts, else nothing changes. When every object already has the value, the +/// document and its undo history stay as they are. +pub(super) fn set_pixel_aligned(s: &mut Session, p: &Value) -> Result { + let on = switch_or(p, "on", true, C)?; + let ids = match given_objects(s, p, C)? { + Some(ids) => ids, + None => selected_roots(s)?, + }; + let d = &s.doc()?.doc; + let mut changed: Vec = vec![]; + for id in &ids { + let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; + // A path inside a compound path snaps with the compound path, whose property it takes. + let in_compound = d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + if !can_align(n) || in_compound { + return Err(bad(C, "only paths and compound paths can be aligned to the pixel grid")); + } + if n.extras().pixel_aligned != on && !changed.contains(id) { + changed.push(*id); + } + } + if changed.is_empty() { + return Ok(json!({ "ids": [] })); + } + s.edit("Align to Pixel Grid", |d, _| { + for id in &changed { + let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; + n.extras_mut().pixel_aligned = on; + if on { + snap_object(n, Scaling::default()); + } + } + Ok(()) + })?; + Ok(json!({ "ids": changed.iter().map(|i| i.0).collect::>() })) +} + +/// After a transform of `n` with `sc`: snap `n` and every object in it that is pixel-aligned to the +/// pixel grid, the art of linked opacity masks included. Objects that are not pixel-aligned stay as +/// they are. +pub(crate) fn keep_pixel_aligned(n: &mut Node, sc: Scaling) { + let mut found = vec![]; + aligned_within(n, &mut vec![], &mut found); + for at in found { + if let Some(a) = node_at(n, &at) { + snap_object(a, sc); + } + } +} + +/// One step from an object down to an object it holds: its child at an index, or the art of its +/// linked opacity mask. +#[derive(Clone, Copy)] +enum Step { + Child(usize), + Mask, +} + +/// Add to `out` the steps from the object [`keep_pixel_aligned`] started at to `n` (`at`) and to +/// every pixel-aligned object in `n`, in the art of linked opacity masks included, `n` first. One +/// walk visits each object once, so nothing is copied that does not snap. +fn aligned_within(n: &Node, at: &mut Vec, out: &mut Vec>) { + if n.extras().pixel_aligned { + out.push(at.clone()); + } + // The paths of a pixel-aligned compound path snap with it, but their opacity masks still need a visit. + for (i, c) in n.children().map_or(&[][..], Vec::as_slice).iter().enumerate() { + at.push(Step::Child(i)); + aligned_within(c, at, out); + at.pop(); + } + if let Some(m) = n.extras().mask.as_ref() + && m.linked + { + at.push(Step::Mask); + aligned_within(&m.art, at, out); + at.pop(); + } +} + +/// The object that `steps` lead to from `n`, made unique for editing. +fn node_at<'a>(n: &'a mut Node, steps: &[Step]) -> Option<&'a mut Node> { + let mut cur = n; + for s in steps { + cur = match s { + Step::Child(i) => Arc::make_mut(cur.children_mut()?.get_mut(*i)?), + Step::Mask => Arc::make_mut(&mut cur.extras_if_any()?.mask.as_mut()?.art), + }; + } + Some(cur) +} + +/// After a transform of object `id` with `sc`: when `id` is a path of a pixel-aligned compound path +/// (moved on its own, as with Group Selection), snap that compound path to the pixel grid again. +/// The compound path holds the property, so [`keep_pixel_aligned`] on the path alone does nothing. +pub(crate) fn keep_compound_aligned(d: &mut Document, id: NodeId, sc: Scaling) { + let Some(parent) = d.parent_of(id) else { return }; + if let Some(n) = d.node_mut(parent) + && matches!(n.kind, NodeKind::Compound { .. }) + && n.extras().pixel_aligned + { + snap_object(n, sc); + } +} + +/// After any edit ([`Session::edit`]): snap each pixel-aligned object that the edit changed or added +/// (its node in `doc` is not the one in `before`), with the pixel-aligned art of its linked opacity +/// mask, to the pixel grid. Objects the edit did not touch are not visited. +pub(crate) fn refresh(before: &Document, doc: &mut Document) { + let mut ids = vec![]; + changed_aligned(&before.layers, &doc.layers, &mut ids); + for id in ids { + if let Some(n) = doc.node_mut(id) { + keep_pixel_aligned(n, Scaling::default()); + } + } +} + +/// After a pass that moved what objects `ids` hold (Objects on Path placing its members): snap the +/// pixel-aligned objects in them to the pixel grid again. +pub(crate) fn keep_aligned_in(doc: &mut Document, ids: &[NodeId]) { + for id in ids { + if doc.node(*id).is_some_and(holds_aligned) + && let Some(n) = doc.node_mut(*id) + { + keep_pixel_aligned(n, Scaling::default()); + } + } +} + +/// The objects of `after` that differ from their counterparts in `before` (or are new) and are +/// pixel-aligned or hold pixel-aligned art in a linked opacity mask. Inside such an object the +/// walk stops: [`keep_pixel_aligned`] visits what it holds. +fn changed_aligned(before: &[Arc], after: &[Arc], out: &mut Vec) { + let mut by_id: Option>> = None; + for (i, a) in after.iter().enumerate() { + let b = match before.get(i) { + Some(b) if b.id == a.id => Some(b), + _ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&a.id).copied(), + }; + if b.is_some_and(|b| Arc::ptr_eq(b, a)) { + continue; + } + if a.extras().pixel_aligned || a.extras().mask.as_ref().is_some_and(|m| m.linked && holds_aligned(&m.art)) { + out.push(a.id); + } else if let Some(children) = a.children() { + changed_aligned(b.and_then(|b| b.children()).map_or(&[], Vec::as_slice), children, out); + } + } +} + +/// After an edit that replaces object `id`'s geometry: snap it, or the pixel-aligned compound path +/// that holds it, to the pixel grid again when it is pixel-aligned. +pub(crate) fn resnap(d: &mut Document, id: NodeId) { + if let Some(n) = d.node_mut(id) { + snap_if_aligned(n); + } + keep_compound_aligned(d, id, Scaling::default()); +} + +/// After an edit of `n`'s strokes: when `n` is pixel-aligned, round its stroke weights and snap it to +/// the pixel grid again, since a new weight or alignment can move where the stroke's edges fall. +pub(crate) fn snap_if_aligned(n: &mut Node) { + if n.extras().pixel_aligned { + snap_object(n, Scaling::default()); + } +} + +/// Whether `n` or an object in it, or in the art of a linked opacity mask in it, is pixel-aligned. +/// It goes only where a transform of `n` goes: not into unlinked masks. +fn holds_aligned(n: &Node) -> bool { + n.extras().pixel_aligned + || n.extras().mask.as_ref().is_some_and(|m| m.linked && holds_aligned(&m.art)) + || n.children().is_some_and(|ch| ch.iter().any(|c| holds_aligned(c))) +} + +/// Snap pixel-aligned object `n` to the pixel grid: its stroke weights become whole points (at +/// least 1), and its horizontal and vertical straight segments move to whole points, or to half +/// points when a centered stroke has an odd weight, so that the stroke's edges fall on the grid. +/// A compound path snaps each of its paths. Placed gradients and linked opacity masks of an object +/// whose anchors moved follow it ([`follow_snap`]). +fn snap_object(n: &mut Node, sc: Scaling) { + let half = round_strokes(n); + let before = n.geometric_bounds(); + let mut moved = false; + if let NodeKind::Compound { children, .. } = &mut n.kind { + // A path is copied for editing only when the snap moves it. + for c in children.iter_mut() { + if path_snaps(c, half) { + let was = c.geometric_bounds(); + let anchors = snaps_anchors(c); + let snapped = Arc::make_mut(c); + snap_path_node(snapped, half, sc); + if anchors { + follow_snap(snapped, was, sc); + } + moved = true; + } + } + } else if snaps_anchors(n) { + moved = snap_path_node(n, half, sc); + } else { + // A live shape is moved with `Node::transform`, which carries its gradients and mask along. + snap_path_node(n, half, sc); + } + if moved { + follow_snap(n, before, sc); + } +} + +/// Whether a snap moves the anchors of path `n` itself (a plain path, or one with Live Corners) +/// rather than transforming it as a whole (another live shape). +fn snaps_anchors(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Path { live, .. } if live.as_deref().is_none_or(|l| matches!(l, LiveShape::Path { .. }))) +} + +/// After the anchors of `n` moved in a snap, from bounds `before`: its placed gradients are fitted +/// to its new bounds, its patterns move and scale with those bounds when `sc` transforms patterns, +/// and the art of its linked opacity mask moves and scales with them (and snaps when it is +/// pixel-aligned itself). +fn follow_snap(n: &mut Node, before: Option, sc: Scaling) { + let (Some(f), Some(t)) = (before, n.geometric_bounds()) else { return }; + if f == t { + return; + } + n.appearance.rebase_gradients(f, t); + if sc.patterns { + vectorcraft_doc::pattern::transform_pattern_paints(n, rect_to_rect(f, t)); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + let art = Arc::make_mut(&mut m.art); + art.transform(rect_to_rect(f, t), sc); + keep_pixel_aligned(art, sc); + } +} + +/// Round the stroke weights of `n` to whole points, at least 1 (strokes without weight stay +/// without). Returns whether a visible, painted centered stroke has an odd weight. +fn round_strokes(n: &mut Node) -> bool { + let rounds = |w: f64| if w > 0.0 { w.round().max(1.0) } else { w }; + let needs = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if rounds(st.width) != st.width)); + if needs { + for item in &mut n.appearance.items { + if let AppearanceItem::Stroke(st) = item { + st.width = rounds(st.width); + } + } + } + n.appearance.items.iter().any(|i| { + matches!(i, AppearanceItem::Stroke(st) if st.visible + && !matches!(st.paint, Paint::None) + && st.width > 0.0 + && st.width % 2.0 == 1.0 + && st.align == StrokeAlign::Center) + }) +} + +/// Snap path object `n`'s horizontal and vertical straight segments ([`snap_segments`]). A path +/// with Live Corners snaps the outline its corners are cut from. Another live shape is moved and +/// scaled as a whole instead, from its path's bounds to the bounds of the snapped path, so that it +/// stays a live shape. Returns whether it changed. +fn snap_path_node(n: &mut Node, half: bool, sc: Scaling) -> bool { + let NodeKind::Path { path, live, .. } = &mut n.kind else { return false }; + let Some(shape) = live.as_deref_mut() else { return snap_segments(path, half) }; + if let LiveShape::Path { base, .. } = shape { + if !snap_segments(base, half) { + return false; + } + *path = shape.to_path(); + return true; + } + let mut snapped = path.clone(); + if !snap_segments(&mut snapped, half) { + return false; + } + let (Some(from), Some(to)) = (path.bounds(), snapped.bounds()) else { return false }; + n.transform(rect_to_rect(from, to), Scaling { keep_corners: true, patterns: sc.patterns, ..Scaling::default() }); + true +} + +/// Whether [`snap_path_node`] would change path object `n`: whether an anchor of the path it snaps +/// moves. Another kind of object is not changed. +fn path_snaps(n: &Node, half: bool) -> bool { + let NodeKind::Path { path, live, .. } = &n.kind else { return false }; + match live.as_deref() { + Some(LiveShape::Path { base, .. }) => segment_moves(base, half).is_some(), + _ => segment_moves(path, half).is_some(), + } +} + +/// The move and scale that takes `from` onto `to` (a side without size is only moved). +fn rect_to_rect(from: Rect, to: Rect) -> Affine { + let sx = if from.width() > EPS { to.width() / from.width() } else { 1.0 }; + let sy = if from.height() > EPS { to.height() / from.height() } else { 1.0 }; + Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0)) +} + +/// Move the anchors of `path`'s horizontal straight segments to whole points vertically and the +/// anchors of its vertical ones to whole points horizontally (`half`: to half points), each anchor +/// with its handles, so curves and diagonal segments keep their shape apart from the anchors they +/// share with those segments. Coordinates that differ stay at least 1 point apart within a subpath, +/// so no side collapses. Returns whether an anchor moved. +pub(crate) fn snap_segments(path: &mut PathData, half: bool) -> bool { + let Some(moves) = segment_moves(path, half) else { return false }; + for (sp, m) in path.subpaths.iter_mut().zip(moves) { + for (a, d) in sp.anchors.iter_mut().zip(m) { + if d.x != 0.0 || d.y != 0.0 { + a.translate(d); + } + } + } + true +} + +/// How far [`snap_segments`] moves each anchor of each subpath of `path`, or `None` when it moves +/// no anchor. +fn segment_moves(path: &PathData, half: bool) -> Option>> { + let mut changed = false; + let mut moves = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let n = sp.anchors.len(); + let (mut snap_x, mut snap_y) = (vec![false; n], vec![false; n]); + for i in 0..sp.segment_count() { + if !sp.segment_is_line(i) { + continue; + } + let (a, b) = (i % n, (i + 1) % n); + let (p, q) = (sp.anchors[a].p, sp.anchors[b].p); + let (dx, dy) = ((q.x - p.x).abs(), (q.y - p.y).abs()); + if dy <= EPS && dx > EPS { + (snap_y[a], snap_y[b]) = (true, true); + } else if dx <= EPS && dy > EPS { + (snap_x[a], snap_x[b]) = (true, true); + } + } + let xs = snapped_values(sp.anchors.iter().zip(&snap_x).filter(|(_, s)| **s).map(|(a, _)| a.p.x), half); + let ys = snapped_values(sp.anchors.iter().zip(&snap_y).filter(|(_, s)| **s).map(|(a, _)| a.p.y), half); + let mut m = Vec::with_capacity(n); + for (i, a) in sp.anchors.iter().enumerate() { + let dx = if snap_x[i] { snapped(&xs, a.p.x) - a.p.x } else { 0.0 }; + let dy = if snap_y[i] { snapped(&ys, a.p.y) - a.p.y } else { 0.0 }; + changed |= dx != 0.0 || dy != 0.0; + m.push(Vec2::new(dx, dy)); + } + moves.push(m); + } + changed.then_some(moves) +} + +/// The coordinates `values` and where each snaps to, in increasing order: the nearest whole point +/// (`half`: half point), and at least 1 point past where the coordinate before it snaps when the +/// two differ. +fn snapped_values(values: impl Iterator, half: bool) -> Vec<(f64, f64)> { + let mut sorted: Vec = values.filter(|v| v.is_finite()).collect(); + sorted.sort_by(f64::total_cmp); + let mut out: Vec<(f64, f64)> = vec![]; + for v in sorted { + match out.last() { + Some(&(last, _)) if v - last <= EPS => {} + Some(&(_, at)) => out.push((v, nearest(v, half).max(at + 1.0))), + None => out.push((v, nearest(v, half))), + } + } + out +} + +/// The whole point (`half`: half point) nearest `v`. A whole point halfway between two half points +/// goes to the half point above it, and a half point halfway between two whole points goes to the +/// whole point below it, so that switching between the two returns a coordinate to where it was. +fn nearest(v: f64, half: bool) -> f64 { + if half { (v + EPS).floor() + 0.5 } else { (v - 0.5 - EPS).ceil() } +} + +/// Where coordinate `v` snaps to, from [`snapped_values`] (`v` itself when it is not listed). A +/// coordinate merged into one listed is at most [`EPS`] past it. +fn snapped(values: &[(f64, f64)], v: f64) -> f64 { + let i = values.partition_point(|(x, _)| *x < v - EPS); + values.get(i).filter(|(x, _)| (x - v).abs() <= EPS).map_or(v, |(_, to)| *to) +} diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index eddc05763..2b32c5998 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -254,6 +254,9 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { }; recut(path, live, partial, &picked, radius, kind); } + for id in &ids { + super::pixelgrid::resnap(d, *id); + } Ok(()) })?; ok() diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 061f4fb9d..7940fc879 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -70,10 +70,12 @@ pub(crate) fn transforms_objects(p: &Value) -> bool { /// check them with [`check_switches`] before they change anything. pub(super) const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"]; -/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. +/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. The objects +/// in `n` that are aligned to the pixel grid snap to it again ([`super::pixelgrid::keep_pixel_aligned`]). pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) { if objects { n.transform(xf, sc); + super::pixelgrid::keep_pixel_aligned(n, sc); } else if sc.patterns { vectorcraft_doc::pattern::transform_patterns_only(n, xf); } @@ -168,6 +170,11 @@ fn apply_transform_moving_guides( transform_node(n, xf, sc, objects); } } + if objects { + for id in &targets { + super::pixelgrid::keep_compound_aligned(d, *id, sc); + } + } if with_guides && (delta.x != 0.0 || delta.y != 0.0) { for i in sel.guides.clone() { if let Some(g) = d.guides.get_mut(i) { diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index 93e2bd929..1a36a4e29 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -314,13 +314,18 @@ fn expand(s: &mut Session, p: &Value) -> Result { /// After an edit: put the objects of every Objects on Path group that changed back on its path /// ([`place`]), inner groups first. Only the parts of the tree that the edit changed are searched. -pub(crate) fn refresh(before: &Document, doc: &mut Document) { +/// Returns the groups that placing changed. +pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Vec { let mut groups = vec![]; changed_groups(&before.layers, &doc.layers, &mut groups); + let mut placed = vec![]; for id in groups.iter().rev() { follow_replacements(before, doc, *id); - place_in(doc, *id); + if place_in(doc, *id) { + placed.push(*id); + } } + placed } /// Members of the group `id` that the edit replaced with new art at the same index keep their @@ -339,14 +344,17 @@ fn follow_replacements(before: &Document, doc: &mut Document, id: NodeId) { } /// [`place`] the Objects on Path group `id` of `doc`, measuring symbol instances by their art. -fn place_in(doc: &mut Document, id: NodeId) { - let Some(n) = doc.node(id) else { return }; +/// Returns whether the group changed. +fn place_in(doc: &mut Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; let mut g = n.clone(); if place(&mut g, &doc.symbol_boxes()) && let Some(slot) = doc.node_mut(id) { *slot = g; + return true; } + false } /// The Objects on Path groups among `after` and inside them that are not the same nodes as before diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b1776c9a0..19cdcfbbd 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -577,6 +577,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) // A stroke without paint has no weight; painting it again restores the 1 pt default. stroke.width = 0.0; } + if !fill { + super::object::snap_if_aligned(n); + } Ok(()) }, |d, changed| { diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 073031a8f..052a5c69c 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -377,7 +377,10 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec if let Some(id) = d.artboards.get(index).map(|a| a.id) { d.copy_artboard_guides(src.id, id, dv); } - let copies = super::edit::copy_above(d, art, |_, _, c| c.transform(Affine::translate(dv), sc))?; + let copies = super::edit::copy_above(d, art, |_, _, c| { + c.transform(Affine::translate(dv), sc); + super::object::keep_pixel_aligned(c, sc); + })?; Ok((index, copies)) } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 4aeeed95c..fb81e1e49 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -262,11 +262,19 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge *a = a.transform(xf); } } + // A pixel-aligned path, or the pixel-aligned compound path that holds it, snaps + // to the pixel grid again after its anchors move. + let top = compound_or_self(doc, *id); + if let Some(n) = doc.node_mut(top) { + super::object::snap_if_aligned(n); + } } None => { if let Some(n) = doc.node_mut(*id) { n.transform(xf, sc); + super::object::keep_pixel_aligned(n, sc); } + super::object::keep_compound_aligned(doc, *id, sc); } } } @@ -308,6 +316,12 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result { let path = path_mut(d, id)?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; *a = a.transform(vectorcraft_geom::Affine::translate(pos - a.p)); + // A pixel-aligned path, or the pixel-aligned compound path that holds it, snaps to the + // pixel grid again. + let top = compound_or_self(d, id); + if let Some(n) = d.node_mut(top) { + super::object::snap_if_aligned(n); + } Ok(()) })?; ok() @@ -337,6 +351,7 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { ); s.edit("Reshape", |d, _| { *path_mut(d, id)? = data; + super::object::resnap(d, id); Ok(()) })?; ok() diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index c2092da0a..f52c26a1c 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -708,7 +708,9 @@ fn move_slices(s: &mut Session, p: &Value) -> Result { None => { if let Some(n) = d.node_mut(*id) { n.transform(vectorcraft_geom::Affine::translate(v), vectorcraft_doc::Scaling::default()); + super::object::keep_pixel_aligned(n, vectorcraft_doc::Scaling::default()); } + super::object::keep_compound_aligned(d, *id, vectorcraft_doc::Scaling::default()); } } } diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c9df27002..1fb3373c7 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -266,6 +266,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { if let Some(st) = n.appearance.stroke_at_mut(index) { set(st); } + super::object::snap_if_aligned(n); Ok(()) })?; // Commit the new-art template only after the complete request is validated @@ -329,6 +330,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result { if let Some(st) = n.appearance.stroke_at_mut(index) { st.width = step_weight(st.width, unit, up); } + super::object::snap_if_aligned(n); Ok(()) })?; Ok(json!({"objects": ids.iter().collect::>().len()})) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index ae8f9f69d..5981396f1 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -273,6 +273,7 @@ fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) { if let Some(n) = d.node_mut(id) { style_node(n, g, chars); n.extras_mut().graphic_style = Some(g.id); + super::object::snap_if_aligned(n); } } diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 998109f3d..7af7cf1f8 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -414,6 +414,7 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result { for id in art { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(dv), sc); + super::object::keep_pixel_aligned(n, sc); } } index.push(*i); diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index e3fae1030..7fcbdc912 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -26,7 +26,11 @@ impl Session { if let Some(ge) = &st.global_edit { spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]); } - cmd::objects_on_path::refresh(&before, doc); + // Pixel-aligned objects the edit changed go back on the pixel grid. + cmd::refresh_pixel_aligned(&before, doc); + let placed = cmd::objects_on_path::refresh(&before, doc); + // Pixel-aligned members that placing moved go back on the pixel grid. + cmd::keep_aligned_in(doc, &placed); dim_changed = cmd::dimension::refresh(&before, doc)?; Ok(v) }) { @@ -35,7 +39,9 @@ impl Session { Ok(shaper_changed) => { // Attached Shaper groups whose art was rebuilt go back on their path. if shaper_changed || dim_changed { - cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc)); + let doc = Arc::make_mut(&mut st.doc); + let placed = cmd::objects_on_path::refresh(&before, doc); + cmd::keep_aligned_in(doc, &placed); } // Symbol editing: the symbol follows its art on the editing layer, before // inline graphics follow their symbols. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4676474b7..812f08b35 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -125,6 +125,7 @@ mod persp_planes; pub(super) mod persp_select; mod persp_text; mod perspgrid; +mod pixelalign; mod place; mod placed_document; mod plugins; diff --git a/crates/engine/src/tests/pixelalign.rs b/crates/engine/src/tests/pixelalign.rs new file mode 100644 index 000000000..d348b13ae --- /dev/null +++ b/crates/engine/src/tests/pixelalign.rs @@ -0,0 +1,768 @@ +//! Transform panel › Align to Pixel Grid (`object.setPixelAligned`): a pixel-aligned path keeps its +//! horizontal and vertical segments on the pixel grid through transforms. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +/// A closed path from SVG path data `d`, selected, with a stroke of `weight` points. +fn path(s: &mut Session, d: &str, weight: f64) -> u64 { + let id = s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("stroke.set", &json!({"weight": weight})).unwrap(); + id +} + +/// The anchor points of path object `id`, every subpath's in order. +fn points(s: &Session, id: u64) -> Vec<(f64, f64)> { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + n.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() +} + +fn aligned(s: &Session, id: u64) -> bool { + s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().pixel_aligned +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Whether every coordinate is a whole number of points (within rounding error). +fn whole(points: &[(f64, f64)]) -> bool { + points.iter().all(|(x, y)| (x - x.round()).abs() < 1e-9 && (y - y.round()).abs() < 1e-9) +} + +fn close(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 +} + +/// Turning it on sets the property and snaps the path's horizontal and vertical edges to whole +/// points, in one undo step; turning it on again changes nothing and makes no undo step; undo +/// puts the path and the property back. +#[test] +fn turning_it_on_snaps_the_path_in_one_undo_step() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let before = points(&s, id); + let steps = undo_len(&s); + let r = s.execute("object.setPixelAligned", &json!({"on": true})).unwrap(); + assert_eq!(r["ids"], json!([id])); + assert!(aligned(&s, id)); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert_eq!(undo_len(&s), steps + 1); + let r = s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + assert_eq!(r["ids"], json!([])); + assert_eq!(undo_len(&s), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!aligned(&s, id)); + assert_eq!(points(&s, id), before); + // An odd stroke weight centered on the path puts the edges on half points, and the stroke + // weight becomes whole. + s.execute("stroke.set", &json!({"weight": 0.8})).unwrap(); + s.execute("object.setPixelAligned", &json!({"on": true})).unwrap(); + assert_eq!(points(&s, id), [(10.5, 20.5), (40.5, 20.5), (40.5, 30.5), (10.5, 30.5)]); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(), 1.0); + // Off keeps the geometry. + s.execute("object.setPixelAligned", &json!({"on": false})).unwrap(); + assert!(!aligned(&s, id)); + assert_eq!(points(&s, id), [(10.5, 20.5), (40.5, 20.5), (40.5, 30.5), (10.5, 30.5)]); +} + +/// A pixel-aligned path stays on the pixel grid after a fractional move, a scale, a rotation about +/// a fractional point, a reflection, Transform Each and a move of its group; one that is not +/// moves exactly as before. +#[test] +fn it_stays_aligned_through_transforms() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let other = path(&mut s, "M10 121 L41 121 L41 130 L10 130 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + s.execute("select.set", &json!({"ids": [id, other]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.45})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert!(close(points(&s, other)[0], (10.3, 121.45)), "{:?}", points(&s, other)); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (cmd, p) in [ + ("object.move", json!({"dx": 5.7, "dy": -2.2})), + ("object.scale", json!({"sx": 133, "sy": 71})), + ("object.rotate", json!({"angle": 90, "origin": [20.2, 20.7]})), + ("object.reflect", json!({"axis": "horizontal", "origin": [0.3, 0.3]})), + ("object.transformEach", json!({"moveH": 0.4, "moveV": 0.35})), + ("object.setBounds", json!({"width": 17.3})), + ] { + s.execute(cmd, &p).unwrap(); + let pts = points(&s, id); + assert!(whole(&pts), "{cmd}: {pts:?}"); + let xs: Vec = pts.iter().map(|p| p.0).collect(); + assert!(xs.iter().any(|x| (x - xs[0]).abs() >= 1.0), "{cmd}: the path collapsed: {pts:?}"); + } + // In a group that moves. + s.execute("select.set", &json!({"ids": [id, other]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let before = points(&s, other); + s.execute("object.move", &json!({"dx": 0.25, "dy": 0.0})).unwrap(); + assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); + assert!(close(points(&s, other)[0], (before[0].0 + 0.25, before[0].1))); +} + +/// Art moved with its artboard stays on the pixel grid. +#[test] +fn it_stays_aligned_when_its_artboard_moves() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + s.execute("artboard.move", &json!({"index": 0, "dx": 0.4, "dy": 0.3, "moveArt": true})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + s.execute("artboard.move", &json!({"index": 0, "dx": 0.6, "dy": 0.7, "moveArt": true})).unwrap(); + assert_eq!(points(&s, id), [(11.0, 22.0), (42.0, 22.0), (42.0, 31.0), (11.0, 31.0)]); +} + +/// Diagonal segments and curves keep their shape: only the anchors that horizontal and vertical +/// segments share move, each with its handles. +#[test] +fn diagonal_segments_and_curves_keep_their_shape() { + let mut s = new_doc(); + // A triangle: a horizontal base and two diagonal sides. Only the base's y snaps. + let tri = path(&mut s, "M0.2 0.2 L20.4 0.2 L10.3 15.7 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let pts = points(&s, tri); + assert!(close(pts[0], (0.2, 0.0)) && close(pts[1], (20.4, 0.0)) && close(pts[2], (10.3, 15.7)), "{pts:?}"); + s.execute("object.move", &json!({"dx": 0.5, "dy": 0.3})).unwrap(); + let pts = points(&s, tri); + assert!(close(pts[0], (0.7, 0.0)) && close(pts[1], (20.9, 0.0)) && close(pts[2], (10.8, 16.0)), "{pts:?}"); + // A curve between two horizontal segments: its ends move with them and its handles with its + // ends. + let curved = path(&mut s, "M100.3 100.3 L120.3 100.3 C125 105 125 115 120.3 120.3 L100.3 120.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(curved)).unwrap().clone(); + let sp = &n.path_data().unwrap().subpaths[0]; + let a = &sp.anchors; + assert!(close((a[0].p.x, a[0].p.y), (100.0, 100.0)) && close((a[3].p.x, a[3].p.y), (100.0, 120.0))); + assert!(close((a[1].p.x, a[1].p.y), (120.3, 100.0)) && close((a[1].h_out.x, a[1].h_out.y), (125.0, 104.7))); + assert!(close((a[2].p.x, a[2].p.y), (120.3, 120.0)) && close((a[2].h_in.x, a[2].h_in.y), (125.0, 114.7))); + // A rectangle turned 45° has no horizontal or vertical segment, and stays as it is. + let turned = path(&mut s, "M200.5 200 L210.5 210.3 L200.5 220.6 L190.5 210.3 Z", 2.0); + let before = points(&s, turned); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + assert_eq!(points(&s, turned), before); +} + +/// Live shapes stay live, and compound paths snap each of their paths. +#[test] +fn live_shapes_and_compound_paths_snap() { + let mut s = new_doc(); + let rect = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().clone(); + assert!(n.live_shape().is_some()); + let b = n.geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 21.0, 41.0, 30.0)); + s.execute("object.move", &json!({"dx": 0.4, "dy": 0.4})).unwrap(); + let b = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 21.0, 41.0, 30.0)); + let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 2.0); + let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.0})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); + let pts: Vec<(f64, f64)> = + n.children().unwrap().iter().flat_map(|k| k.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>()).collect(); + assert_eq!(pts.len(), 8); + assert!(whole(&pts), "{pts:?}"); +} + +/// The anchor points of every path in compound path `id`. +fn compound_points(s: &Session, id: u64) -> Vec<(f64, f64)> { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + n.children().unwrap().iter().flat_map(|k| k.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>()).collect() +} + +/// A stroke edit on a pixel-aligned path rounds the new weight and snaps the path again: a 0.8 pt +/// weight becomes 1 pt with the edges on half points, a centered 3 pt stroke keeps the edges on +/// half points, and Stroke Weight Up keeps the weight whole; all in the stroke edit's one undo step. +#[test] +fn stroke_edits_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let steps = undo_len(&s); + s.execute("stroke.set", &json!({"weight": 0.8})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(), 1.0); + assert_eq!(points(&s, id), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); + assert_eq!(undo_len(&s), steps + 1); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); + s.execute("stroke.set", &json!({"weight": 3})).unwrap(); + let pts = points(&s, id); + assert!(pts.iter().all(|(x, y)| (x - x.floor() - 0.5).abs() < 1e-9 && (y - y.floor() - 0.5).abs() < 1e-9), "{pts:?}"); + // Inside alignment puts the stroke within the path, so the anchors go back to whole points. + s.execute("stroke.set", &json!({"align": "inside"})).unwrap(); + assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); + s.execute("stroke.set", &json!({"align": "center", "weight": 0.6})).unwrap(); + s.execute("stroke.weightUp", &json!({})).unwrap(); + let w = s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(); + assert_eq!(w, w.round(), "{w}"); + // A path that is not pixel-aligned takes the weight as given. + let plain = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 0.8); + assert_eq!(s.doc().unwrap().doc.node(NodeId(plain)).unwrap().appearance.stroke_width(), 0.8); + assert_eq!(points(&s, plain), [(110.3, 20.6), (140.7, 20.6), (140.7, 30.3), (110.3, 30.3)]); +} + +/// Make Compound Path from pixel-aligned paths gives a pixel-aligned compound path whose paths do +/// not keep the property; they snap with the compound path's stroke, so a centered 1 pt stroke puts +/// them on half points after a move. Release gives each path the property back. +#[test] +fn a_compound_path_of_aligned_paths_is_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 1.0); + let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 1.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, c)); + let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); + assert!(n.children().unwrap().iter().all(|k| !k.extras().pixel_aligned)); + let half = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - x.floor() - 0.5).abs() < 1e-9 && (y - y.floor() - 0.5).abs() < 1e-9); + assert!(half(&compound_points(&s, c)), "{:?}", compound_points(&s, c)); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.2})).unwrap(); + let pts = compound_points(&s, c); + assert_eq!(pts.len(), 8); + assert!(half(&pts), "{pts:?}"); + // A path inside the compound path cannot take the property alone. + let member = n.children().unwrap()[0].id.0; + let e = s.execute("object.setPixelAligned", &json!({"ids": [member]})).unwrap_err().to_string(); + assert!(e.contains("only paths and compound paths"), "{e}"); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("object.compoundPath.release", &json!({})).unwrap(); + let released = s.doc().unwrap().selection.objects.clone(); + assert_eq!(released.len(), 2); + assert!(released.iter().all(|id| aligned(&s, id.0))); +} + +/// Make Compound Path from a pixel-aligned compound path and another path gives a pixel-aligned +/// compound path, which keeps snapping after a move. +#[test] +fn a_compound_path_of_an_aligned_compound_path_is_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 2.0); + let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + let other = path(&mut s, "M300.2 100.2 L310.6 100.2 L310.6 110.6 L300.2 110.6 Z", 2.0); + s.execute("select.set", &json!({"ids": [c, other]})).unwrap(); + let joined = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, joined)); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + let pts = compound_points(&s, joined); + assert_eq!(pts.len(), 12); + assert!(whole(&pts), "{pts:?}"); +} + +/// A stroke with no paint does not count: a fill-only path whose stroke item keeps an odd weight +/// snaps to whole points. +#[test] +fn a_stroke_without_paint_snaps_to_whole_points() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0); + // An imported file can hold a stroke item without paint that keeps its weight. + let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).unwrap(); + let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) = + n.appearance.items.iter_mut().find(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))) + else { + panic!("no stroke"); + }; + st.paint = vectorcraft_color::Paint::None; + assert_eq!(st.width, 1.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); +} + +/// With direct-selected anchors in the selection, a move snaps the pixel-aligned path whose anchors +/// moved and the pixel-aligned object selected whole. +#[test] +fn moving_direct_selected_anchors_keeps_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 0.4, "dy": 0.0})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert_eq!(points(&s, b), [(110.0, 21.0), (141.0, 21.0), (141.0, 30.0), (110.0, 30.0)]); + s.execute("path.moveAnchors", &json!({"dx": 0.7, "dy": 0.0})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (42.0, 21.0), (42.0, 30.0), (10.0, 30.0)]); + assert_eq!(points(&s, b), [(111.0, 21.0), (142.0, 21.0), (142.0, 30.0), (111.0, 30.0)]); +} + +/// A path with Live Corners snaps the outline its corners are cut from, inner edges included, and +/// keeps its Live Corners. +#[test] +fn a_path_with_live_corners_snaps_its_inner_edges() { + let mut s = new_doc(); + let id = path(&mut s, "M10 10 L40 10 L40 20.3 L25 20.3 L25 40 L10 40 Z", 2.0); + s.execute("object.setLiveShape", &json!({"id": id, "radius": 2})).unwrap(); + let base = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("no Live Corners") }; + let vectorcraft_doc::LiveShape::Path { base, .. } = &**live else { panic!("not a path with Live Corners") }; + base.anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>() + }; + assert!(base(&s).iter().any(|p| close(*p, (40.0, 20.3)))); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + assert_eq!(base(&s), [(10.0, 10.0), (40.0, 10.0), (40.0, 20.0), (25.0, 20.0), (25.0, 40.0), (10.0, 40.0)]); + let pts = points(&s, id); + assert!(pts.iter().all(|p| (p.1 - 20.3).abs() > 1e-6), "{pts:?}"); +} + +/// `path.setAnchors` and `object.setLiveShape` snap a pixel-aligned path again after they replace +/// its geometry. +#[test] +fn geometry_edits_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + let anchors = json!([{"x": 10.3, "y": 21.4}, {"x": 41.3, "y": 21.4}, {"x": 41.3, "y": 30.4}, {"x": 10.3, "y": 30.4}]); + s.execute("path.setAnchors", &json!({"id": id, "subpaths": [{"anchors": anchors, "closed": true}]})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let poly = s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [poly]})).unwrap(); + s.execute("object.setLiveShape", &json!({"id": poly, "sides": 5})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(poly)).unwrap().clone(); + let b = n.geometric_bounds().unwrap(); + let on_grid = |v: f64| ((v - 0.5) - (v - 0.5).round()).abs() < 1e-9 || (v - v.round()).abs() < 1e-9; + assert!(on_grid(b.y1), "{b:?}"); +} + +/// Any other edit of a pixel-aligned path, here Object › Path › Average, snaps it again too. +#[test] +fn other_path_edits_keep_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let b = path(&mut s, "M60.3 50 L80 50 L80 60 L60.3 60 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); + s.execute("select.anchors", &json!({"id": b, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + s.execute("path.average", &json!({"axis": "vertical"})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (47.0, 21.0), (47.0, 30.0), (10.0, 30.0)]); + assert!((points(&s, b)[0].0 - (41.0 + 41.0 + 60.3) / 3.0).abs() < 1e-9, "{:?}", points(&s, b)); +} + +/// A stroke edit that Global Edit carries to the other members snaps the pixel-aligned ones. +#[test] +fn global_edit_keeps_its_members_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + // The same shape at twice the size. + let b = path(&mut s, "M110 21 L172 21 L172 39 L110 39 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a, b]})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(r["active"], true); + s.execute("stroke.set", &json!({"weight": 1})).unwrap(); + assert_eq!(strokes(&s, b)[0].1, 1.0); + assert_eq!(points(&s, a), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); + assert!(halves(&points(&s, b)), "{:?}", points(&s, b)); +} + +/// Align with direct-selected anchors snaps the pixel-aligned path whose anchors moved. +#[test] +fn aligning_direct_selected_anchors_keeps_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let b = path(&mut s, "M60.4 50.3 L80 50.3 L80 60 L60.4 60 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); + s.execute("select.anchors", &json!({"id": b, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + s.execute("object.align", &json!({"horizontal": "right"})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (60.0, 21.0), (60.0, 30.0), (10.0, 30.0)]); + assert!(close(points(&s, b)[0], (60.4, 50.3)), "{:?}", points(&s, b)); +} + +/// The art of a linked opacity mask moves and scales with a path that snaps, and a placed gradient +/// keeps its place too. +#[test] +fn a_linked_opacity_mask_follows_a_snap() { + let mut s = new_doc(); + let o = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let m = path(&mut s, "M12 23 L39 23 L39 28 L12 28 Z", 2.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [o, m]})).unwrap(); + let mask_bounds = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(o)).unwrap().clone(); + n.extras().mask.clone().expect("an opacity mask").art.geometric_bounds().unwrap() + }; + let (from, mask) = (s.doc().unwrap().doc.node(NodeId(o)).unwrap().geometric_bounds().unwrap(), mask_bounds(&s)); + s.execute("object.setPixelAligned", &json!({"ids": [o]})).unwrap(); + let to = s.doc().unwrap().doc.node(NodeId(o)).unwrap().geometric_bounds().unwrap(); + assert_eq!(points(&s, o), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let at = |r: vectorcraft_geom::Rect, x: f64, y: f64| ((x - r.x0) / r.width(), (y - r.y0) / r.height()); + let after = mask_bounds(&s); + assert!(close(at(to, after.x0, after.y0), at(from, mask.x0, mask.y0)), "{after:?} from {mask:?}"); + assert!(close(at(to, after.x1, after.y1), at(from, mask.x1, mask.y1)), "{after:?} from {mask:?}"); +} + +/// `path.moveAnchor` (the Anchor Point tool, Smart Pen) snaps a pixel-aligned path again: two moves +/// that leave a horizontal segment off the grid put it back on whole points. +#[test] +fn moving_one_anchor_keeps_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + s.execute("path.moveAnchor", &json!({"id": id, "anchor": 0, "x": 10, "y": 21.3})).unwrap(); + s.execute("path.moveAnchor", &json!({"id": id, "anchor": 1, "x": 41, "y": 21.3})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); +} + +/// A path of a pixel-aligned compound path moved on its own (Group Selection) snaps with the +/// compound path. +#[test] +fn moving_a_path_of_an_aligned_compound_path_keeps_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 2.0); + let b = path(&mut s, "M200 100 L210 100 L210 110 L200 110 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); + let member = n.children().unwrap()[0].id.0; + s.execute("select.set", &json!({"ids": [member]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + let pts = compound_points(&s, c); + assert_eq!(pts.len(), 8); + assert!(whole(&pts), "{pts:?}"); +} + +/// A path that Paste in Front puts inside a pixel-aligned compound path, or that an object slice +/// move moves there, snaps with the compound path's stroke, and a pasted path drops its own +/// property there. +#[test] +fn paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0); + let b = path(&mut s, "M200 100 L210 100 L210 110 L200 110 Z", 1.0); + let loose = path(&mut s, "M300 100 L310 100 L310 110 L300 110 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [loose]})).unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + assert!(halves(&compound_points(&s, c))); + let member = s.doc().unwrap().doc.node(NodeId(c)).unwrap().children().unwrap()[0].id.0; + s.execute("select.set", &json!({"ids": [loose]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [member]})).unwrap(); + s.execute("edit.pasteInFront", &json!({})).unwrap(); + let pasted = s.doc().unwrap().selection.objects[0]; + assert_eq!(s.doc().unwrap().doc.parent_of(pasted), Some(NodeId(c))); + assert!(!aligned(&s, pasted.0)); + let pts = compound_points(&s, c); + assert_eq!(pts.len(), 12); + assert!(halves(&pts), "paste: {pts:?}"); + s.execute("object.slice.make", &json!({"ids": [member]})).unwrap(); + s.execute("object.slice.move", &json!({"slices": [member], "dx": 0.3, "dy": 0.4})).unwrap(); + let pts = compound_points(&s, c); + assert!(halves(&pts), "slice: {pts:?}"); +} + +/// Art moved by Artboard Options with Move Artwork with Artboard, and the art copies of an +/// artboard copied with its art, stay on the pixel grid. +#[test] +fn artboard_options_and_artboard_copies_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let r = s.doc().unwrap().doc.artboards[0].rect; + s.execute("artboard.setProps", &json!({"index": 0, "x": r.x0 + 0.4, "y": r.y0 + 0.3, "moveArt": true})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let out = s.execute("artboard.move", &json!({"index": 0, "dx": 500.4, "dy": 0.3, "copy": true, "moveArt": true})).unwrap(); + let copies = out["moved"].as_array().unwrap(); + assert_eq!(copies.len(), 1); + let copy = copies[0].as_u64().unwrap(); + assert!(aligned(&s, copy)); + assert!(whole(&points(&s, copy)), "{:?}", points(&s, copy)); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); +} + +/// Type, images and groups cannot be aligned: the command is an error and nothing changes, also +/// for a path selected with one of them. +#[test] +fn type_and_images_cannot_be_aligned() { + let mut s = new_doc(); + let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let before = points(&s, p); + let text = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + let png = crate::tests_links::png(4, 4, [255, 0, 0]); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + let image = { + let st = s.doc().unwrap(); + let id = st.selection.objects[0]; + assert!(matches!(st.doc.node(id).unwrap().kind, NodeKind::Image(_))); + id.0 + }; + let steps = undo_len(&s); + for ids in [json!([text]), json!([image]), json!([p, text])] { + let e = s.execute("object.setPixelAligned", &json!({"ids": ids})).unwrap_err().to_string(); + assert!(e.contains("only paths and compound paths"), "{ids}: {e}"); + assert_eq!(undo_len(&s), steps, "{ids}"); + assert!(!aligned(&s, p)); + assert_eq!(points(&s, p), before); + } + let e = s.execute("object.setPixelAligned", &json!({"ids": [p], "on": "yes"})).unwrap_err().to_string(); + assert!(e.contains("`on` must be true or false"), "{e}"); + assert_eq!(undo_len(&s), steps); +} + +/// The property is saved in the native file and read back; an object without it is saved without +/// the key and reads back off. +#[test] +fn the_property_is_saved_and_read_back() { + let mut s = new_doc(); + let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert!(back.node(NodeId(a)).unwrap().extras().pixel_aligned); + assert!(!back.node(NodeId(b)).unwrap().extras().pixel_aligned); + let plain: Value = serde_json::to_value(d.node(NodeId(b)).unwrap()).unwrap(); + assert!(plain.get("pixel_aligned").is_none(), "{plain}"); + let node = s.execute("document.node", &json!({"id": a})).unwrap(); + assert_eq!(node["pixel_aligned"], true, "{node}"); +} + +/// A copy of a pixel-aligned path that Duplicate or Paste moves by a fractional offset is snapped +/// to the pixel grid and keeps the property, and so is a pixel-aligned object slice that moves. +#[test] +fn duplicates_and_pastes_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + s.execute("edit.duplicate", &json!({"dx": 100.3, "dy": 0.4})).unwrap(); + let copy = s.doc().unwrap().selection.objects[0].0; + assert_ne!(copy, id); + assert!(aligned(&s, copy)); + assert_eq!(points(&s, copy), [(110.0, 21.0), (141.0, 21.0), (141.0, 30.0), (110.0, 30.0)]); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({"dx": 0.3, "dy": 50.4})).unwrap(); + let pasted = s.doc().unwrap().selection.objects[0].0; + assert!(pasted != id && pasted != copy); + assert!(aligned(&s, pasted)); + assert_eq!(points(&s, pasted), [(10.0, 71.0), (41.0, 71.0), (41.0, 80.0), (10.0, 80.0)]); + // Moving an object slice moves its object, which snaps too. + s.execute("object.slice.make", &json!({"ids": [pasted]})).unwrap(); + s.execute("object.slice.move", &json!({"slices": [pasted], "dx": 0.3, "dy": 0.4})).unwrap(); + assert_eq!(points(&s, pasted), [(10.0, 71.0), (41.0, 71.0), (41.0, 80.0), (10.0, 80.0)]); +} + +/// Pixel-aligned art of a linked opacity mask snaps when its object moves. +#[test] +fn the_art_of_a_linked_opacity_mask_keeps_it_aligned() { + let mut s = new_doc(); + let o = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let m = path(&mut s, "M12 23 L39 23 L39 28 L12 28 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [m]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({"ids": [o, m]})).unwrap(); + let mask_points = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(o)).unwrap().clone(); + let mask = n.extras().mask.clone().expect("an opacity mask"); + assert!(mask.art.extras().pixel_aligned); + mask.art.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>() + }; + s.execute("select.set", &json!({"ids": [o]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + assert_eq!(mask_points(&s), [(12.0, 23.0), (39.0, 23.0), (39.0, 28.0), (12.0, 28.0)]); + // The masked path inside a pixel-aligned compound path. + let other = path(&mut s, "M110 21 L141 21 L141 30 L110 30 Z", 2.0); + s.execute("select.set", &json!({"ids": [o, other]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + assert!(whole(&mask_points(&s)), "{:?}", mask_points(&s)); +} + +/// The start point of path object `id`'s placed gradient fill. +fn gradient_start(s: &Session, id: u64) -> (f64, f64) { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + let Some(vectorcraft_color::Paint::Gradient(g)) = n.appearance.paint_at(None, true) else { panic!("no gradient fill") }; + let p = g.geom.expect("a placed gradient").start; + (p.x, p.y) +} + +/// A placed gradient moves with a pixel-aligned path when a stroke edit snaps it. +#[test] +fn placed_gradients_follow_the_snapped_path() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [id]})).unwrap(); + { + let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).unwrap(); + let bounds = n.geometric_bounds().unwrap(); + let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| i.is_fill()) else { panic!("no fill") }; + let vectorcraft_color::Paint::Gradient(g) = &mut f.paint else { panic!("no gradient fill") }; + g.pin(bounds); + } + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + let before = gradient_start(&s, id); + let i = strokes(&s, id)[0].0; + s.execute("appearance.setItem", &json!({"ids": [id], "index": i, "weight": 1})).unwrap(); + assert_eq!(points(&s, id), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); + assert!(close(gradient_start(&s, id), (before.0 + 0.5, before.1 + 0.5)), "{:?} from {before:?}", gradient_start(&s, id)); +} + +/// The paint-order indices of path object `id`'s strokes, and their weights. +fn strokes(s: &Session, id: u64) -> Vec<(usize, f64)> { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + n.appearance + .items + .iter() + .enumerate() + .filter_map(|(i, it)| match it { + vectorcraft_doc::AppearanceItem::Stroke(st) => Some((i, st.width)), + _ => None, + }) + .collect() +} + +/// Whether every coordinate is a whole number of points plus a half (within rounding error). +fn halves(points: &[(f64, f64)]) -> bool { + points.iter().all(|(x, y)| ((x - 0.5) - (x - 0.5).round()).abs() < 1e-9 && ((y - 0.5) - (y - 0.5).round()).abs() < 1e-9) +} + +/// Appearance edits that add, remove, hide, repaint or reweigh a stroke snap a pixel-aligned path +/// again: Appearance panel item edits, Remove Item, Add New Stroke, Clear Appearance, Reduce to +/// Basic Appearance, Show All Hidden Attributes, moving or copying an appearance, +/// `paint.setStroke`, a graphic style and the Eyedropper. The edges go to half +/// points while a visible, painted centered stroke has an odd weight, otherwise to whole points. +#[test] +fn appearance_edits_keep_it_aligned() { + let mut s = new_doc(); + let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.2); + let c = path(&mut s, "M210.3 20.6 L240.7 20.6 L240.7 30.3 L210.3 30.3 Z", 1.0); + s.execute("graphicStyle.new", &json!({"id": c, "name": "One"})).unwrap(); + let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + assert!(whole(&points(&s, a))); + let top = |s: &Session| strokes(s, a).last().map(|(i, _)| *i).unwrap(); + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 3.3})).unwrap(); + assert_eq!(strokes(&s, a).last().unwrap().1, 3.0); + assert!(halves(&points(&s, a)), "weight: {:?}", points(&s, a)); + // Hiding and showing the stroke again puts the path back where it was. + let shown = points(&s, a); + for _ in 0..2 { + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": false})).unwrap(); + assert!(whole(&points(&s, a)), "hidden: {:?}", points(&s, a)); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": true})).unwrap(); + assert_eq!(points(&s, a), shown); + } + s.execute("paint.setStroke", &json!({"ids": [a], "none": true})).unwrap(); + assert!(whole(&points(&s, a)), "no paint: {:?}", points(&s, a)); + s.execute("paint.setStroke", &json!({"ids": [a], "color": "#00ff00"})).unwrap(); + assert_eq!(strokes(&s, a).last().unwrap().1, 1.0); + assert!(halves(&points(&s, a)), "painted: {:?}", points(&s, a)); + let i = top(&s); + s.execute("appearance.removeItem", &json!({"ids": [a], "index": i})).unwrap(); + assert!(strokes(&s, a).is_empty()); + assert!(whole(&points(&s, a)), "removed: {:?}", points(&s, a)); + s.execute("appearance.addStroke", &json!({"ids": [a]})).unwrap(); + assert!(halves(&points(&s, a)), "added: {:?}", points(&s, a)); + s.execute("appearance.clear", &json!({"ids": [a]})).unwrap(); + assert!(whole(&points(&s, a)), "cleared: {:?}", points(&s, a)); + s.execute("graphicStyle.apply", &json!({"ids": [a], "name": "One"})).unwrap(); + assert!(halves(&points(&s, a)), "style: {:?}", points(&s, a)); + s.execute("appearance.copyFrom", &json!({"source": b, "ids": [a]})).unwrap(); + assert_eq!(strokes(&s, a).last().unwrap().1, 2.0); + assert!(whole(&points(&s, a)), "eyedropper: {:?}", points(&s, a)); + // A 1 pt stroke below a 2 pt one: half points, and whole points once Reduce to Basic Appearance + // keeps only the top stroke. + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [a]})).unwrap(); + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 2})).unwrap(); + assert_eq!(strokes(&s, a).iter().map(|(_, w)| *w).collect::>(), [1.0, 2.0]); + assert!(halves(&points(&s, a)), "two strokes: {:?}", points(&s, a)); + s.execute("appearance.reduceToBasic", &json!({"ids": [a]})).unwrap(); + assert_eq!(strokes(&s, a).iter().map(|(_, w)| *w).collect::>(), [2.0]); + assert!(whole(&points(&s, a)), "reduced: {:?}", points(&s, a)); + // Show All Hidden Attributes, and moving or copying an appearance onto or off the path. + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": false})).unwrap(); + assert!(whole(&points(&s, a)), "hidden: {:?}", points(&s, a)); + s.execute("appearance.showAllHidden", &json!({"ids": [a]})).unwrap(); + assert!(halves(&points(&s, a)), "show all: {:?}", points(&s, a)); + s.execute("appearance.transfer", &json!({"source": b, "target": a, "copy": true})).unwrap(); + assert!(whole(&points(&s, a)), "copied onto: {:?}", points(&s, a)); + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); + assert!(halves(&points(&s, a)), "1 pt: {:?}", points(&s, a)); + s.execute("appearance.transfer", &json!({"source": a, "target": c})).unwrap(); + assert!(whole(&points(&s, a)), "moved off: {:?}", points(&s, a)); + // A path that is not aligned keeps its coordinates through the same edits. + let before = points(&s, b); + s.execute("appearance.setItem", &json!({"ids": [b], "index": strokes(&s, b)[0].0, "weight": 3.3})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [b]})).unwrap(); + assert_eq!(points(&s, b), before); +} + +#[test] +fn patterns_follow_the_snap_with_transform_patterns() { + let mut s = new_doc(); + let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [tile], "color": "#ff0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [tile]})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + s.execute("object.pattern.done", &json!({})).unwrap(); + let a = path(&mut s, "M 100 100 L 150 100 L 150 150 L 100 150 Z", 2.0); + s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Dots"})).unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + let tiles = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(a)).unwrap().clone(); + let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("no pattern fill") }; + xf.as_coeffs() + }; + let (before, xf) = (points(&s, a), tiles(&s)); + // The move snaps the path back to where it was, and its tiles go back with it. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.0, "patterns": true})).unwrap(); + assert_eq!(points(&s, a), before); + assert!(tiles(&s).iter().zip(xf).all(|(t, x)| (t - x).abs() < 1e-9), "tiles: {:?}, before {xf:?}", tiles(&s)); + // A move that the snap keeps moves the tiles as far as the path. + s.execute("object.move", &json!({"dx": 0.7, "dy": 0.0, "patterns": true})).unwrap(); + assert_eq!(points(&s, a)[0], (before[0].0 + 1.0, before[0].1)); + assert!((tiles(&s)[4] - (xf[4] + 1.0)).abs() < 1e-9, "tiles: {:?}, before {xf:?}", tiles(&s)); +} + +#[test] +fn objects_on_path_members_stay_on_the_grid() { + let mut s = new_doc(); + let spine = s.execute("path.create", &json!({"d": "M 0.3 100.4 L 300.3 100.4"})).unwrap()["id"].as_u64().unwrap(); + let a = path(&mut s, "M 10 10 L 30 10 L 30 30 L 10 30 Z", 2.0); + s.execute("object.objectsOnPath.attach", &json!({"path": spine, "ids": [a]})).unwrap(); + assert!(!whole(&points(&s, a)), "placed: {:?}", points(&s, a)); + // Placing the member on the path after the edit must not move it off the grid again. + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + assert!(whole(&points(&s, a)), "aligned: {:?}", points(&s, a)); + // Editing the path places the member again, and it goes back on the grid. + let anchors = json!([{"x": 0.6, "y": 120.7}, {"x": 300.6, "y": 120.7}]); + s.execute("path.setAnchors", &json!({"id": spine, "subpaths": [{"anchors": anchors, "closed": false}]})).unwrap(); + assert!(whole(&points(&s, a)), "path edited: {:?}", points(&s, a)); + assert!(points(&s, a).iter().all(|(_, y)| *y > 105.0), "followed the path: {:?}", points(&s, a)); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6ca909b49..b512e574b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6336,3 +6336,6 @@ breakText Glyphs Глифи # 3D Box add-on 3D Box 3D кутия 3D Box Tool Инструмент за 3D кутия + +# Align to Pixel Grid + Align to Pixel Grid Подравняване към пикселната мрежа diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index e159a9343..90b5e4c4e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6386,3 +6386,6 @@ shape Open Otvoren # 3D Box add-on 3D Box 3D kutija 3D Box Tool Alat za 3D kutiju + +# Align to Pixel Grid + Align to Pixel Grid Poravnaj s mrežom piksela diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 391ad658e..d5d779aff 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6501,3 +6501,7 @@ breakText Glyphs Glifs # 3D Box add-on 3D Box Caixa 3D 3D Box Tool Eina Caixa 3D + +# Align to Pixel Grid + Align to Pixel Grid Alinea a la quadrícula de píxels +@msg only paths and compound paths can be aligned to the pixel grid només els traçats i els traçats compostos es poden alinear a la quadrícula de píxels diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8f2dcb816..c87d10ca7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6518,3 +6518,7 @@ breakText Glyphs Glyfy # 3D Box add-on 3D Box 3D kvádr 3D Box Tool Nástroj 3D kvádr + +# Align to Pixel Grid + Align to Pixel Grid Zarovnat na mřížku obrazových bodů +@msg only paths and compound paths can be aligned to the pixel grid na mřížku obrazových bodů lze zarovnat jen cesty a složené cesty diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index aabdbeb90..9ab09bf8e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6350,3 +6350,6 @@ breakText Glyphs Glyffer # 3D Box add-on 3D Box 3D-boks 3D Box Tool 3D-boksværktøj + +# Align to Pixel Grid + Align to Pixel Grid Juster efter pixelgitter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9a8669767..2dc09fd75 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6104,3 +6104,7 @@ breakText Glyphs Glyphen # 3D Box add-on 3D Box 3D-Quader 3D Box Tool 3D-Quader-Werkzeug + +# Align to Pixel Grid + Align to Pixel Grid Am Pixelraster ausrichten +@msg only paths and compound paths can be aligned to the pixel grid nur Pfade und zusammengesetzte Pfade können am Pixelraster ausgerichtet werden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2bab85fec..b2b48e82c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6470,3 +6470,7 @@ breakText Glyphs Γλύφοι # 3D Box add-on 3D Box Κουτί 3D 3D Box Tool Εργαλείο κουτιού 3D + +# Align to Pixel Grid + Align to Pixel Grid Στοίχιση στο πλέγμα εικονοστοιχείων +@msg only paths and compound paths can be aligned to the pixel grid μόνο διαδρομές και σύνθετες διαδρομές μπορούν να στοιχιστούν στο πλέγμα εικονοστοιχείων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7a6ec6794..284a7062d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6460,3 +6460,7 @@ breakText Glyphs Glifos # 3D Box add-on 3D Box Caja 3D 3D Box Tool Herramienta Caja 3D + +# Align to Pixel Grid + Align to Pixel Grid Alinear con cuadrícula de píxeles +@msg only paths and compound paths can be aligned to the pixel grid solo los trazados y los trazados compuestos se pueden alinear con la cuadrícula de píxeles diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index eca3d4a46..f5bbceed9 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6553,3 +6553,7 @@ breakText Glyphs Merkit # 3D Box add-on 3D Box 3D-laatikko 3D Box Tool 3D-laatikkotyökalu + +# Align to Pixel Grid + Align to Pixel Grid Tasaa pikseliruudukkoon +@msg only paths and compound paths can be aligned to the pixel grid vain polut ja yhdistelmäpolut voi tasata pikseliruudukkoon diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index acecace4d..218554e7e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6513,3 +6513,7 @@ breakText Glyphs Glyphes # 3D Box add-on 3D Box Boîte 3D 3D Box Tool Outil Boîte 3D + +# Align to Pixel Grid + Align to Pixel Grid Aligner sur la grille de pixels +@msg only paths and compound paths can be aligned to the pixel grid seuls les tracés et les tracés transparents peuvent être alignés sur la grille de pixels diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 81481eb01..1168c1095 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7975,6 +7975,10 @@ swatch White, Black सफ़ेद, काला swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस + +# Align to Pixel Grid + Align to Pixel Grid पिक्सेल ग्रिड से संरेखित करें +@msg only paths and compound paths can be aligned to the pixel grid केवल पाथ और कंपाउंड पाथ को पिक्सेल ग्रिड से संरेखित किया जा सकता है Start Lists From Typed Markers टाइप किए गए चिह्नों से सूचियाँ शुरू करें Marquee Selection मार्की चयन Touching स्पर्श करते हुए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a362a3b68..14d72c658 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6502,3 +6502,7 @@ breakText Glyphs Glifák # 3D Box add-on 3D Box 3D doboz 3D Box Tool 3D doboz eszköz + +# Align to Pixel Grid + Align to Pixel Grid Igazítás a pixelrácshoz +@msg only paths and compound paths can be aligned to the pixel grid csak útvonalak és összetett útvonalak igazíthatók a pixelrácshoz diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3c0dffa2e..49083eb9c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6350,3 +6350,6 @@ breakText Glyphs Glif # 3D Box add-on 3D Box Kotak 3D 3D Box Tool Alat Kotak 3D + +# Align to Pixel Grid + Align to Pixel Grid Sejajarkan ke grid piksel diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1628344a1..d3d7411a9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6138,3 +6138,7 @@ breakText Glyphs Glifi # 3D Box add-on 3D Box Scatola 3D 3D Box Tool Strumento Scatola 3D + +# Align to Pixel Grid + Align to Pixel Grid Allinea alla griglia pixel +@msg only paths and compound paths can be aligned to the pixel grid solo i tracciati e i tracciati composti possono essere allineati alla griglia pixel diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 19e8a2803..04f8ecc3a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6203,3 +6203,7 @@ breakText Glyphs 字形 # 3D Box add-on 3D Box 3Dボックス 3D Box Tool 3Dボックスツール + +# Align to Pixel Grid + Align to Pixel Grid ピクセルグリッドに整合 +@msg only paths and compound paths can be aligned to the pixel grid ピクセルグリッドに整合できるのはパスと複合パスのみです diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index bf7efc0e7..be1f95592 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6530,3 +6530,7 @@ font-script Japanese იაპონური # 3D Box add-on 3D Box 3D ყუთი 3D Box Tool 3D ყუთის ხელსაწყო + +# Align to Pixel Grid + Align to Pixel Grid პიქსელურ ბადეზე გასწორება +@msg only paths and compound paths can be aligned to the pixel grid პიქსელურ ბადეზე მხოლოდ ტრაექტორიების და შედგენილი ტრაექტორიების გასწორებაა შესაძლებელი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 21d443316..6ae7dc28f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6099,3 +6099,7 @@ breakText Glyphs 글리프 # 3D Box add-on 3D Box 3D 상자 3D Box Tool 3D 상자 도구 + +# Align to Pixel Grid + Align to Pixel Grid 픽셀 격자에 정렬 +@msg only paths and compound paths can be aligned to the pixel grid 패스와 컴파운드 패스만 픽셀 격자에 정렬할 수 있습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 08d36a234..99021e1d6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6396,3 +6396,6 @@ breakText Glyphs Glyphs # 3D Box add-on 3D Box 3D-doos 3D Box Tool 3D-doosgereedschap + +# Align to Pixel Grid + Align to Pixel Grid Uitlijnen op pixelraster diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 089cd17ec..4e254f592 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6373,3 +6373,7 @@ breakText Glyphs Glify # 3D Box add-on 3D Box Bryła 3D 3D Box Tool Narzędzie Bryła 3D + +# Align to Pixel Grid + Align to Pixel Grid Wyrównaj do siatki pikseli +@msg only paths and compound paths can be aligned to the pixel grid do siatki pikseli można wyrównać tylko ścieżki i ścieżki złożone diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 17428e1cf..ae9360a10 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6591,3 +6591,7 @@ swatch Yellow Green Verde-amarelado # 3D Box add-on 3D Box Caixa 3D 3D Box Tool Ferramenta Caixa 3D + +# Align to Pixel Grid + Align to Pixel Grid Alinhar à Grade de Pixels +@msg only paths and compound paths can be aligned to the pixel grid somente caminhos e caminhos compostos podem ser alinhados à grade de pixels diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 102848cbf..a6826ebbd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6607,3 +6607,7 @@ shape Open Открытый # 3D Box add-on 3D Box 3D-параллелепипед 3D Box Tool Инструмент «3D-параллелепипед» + +# Align to Pixel Grid + Align to Pixel Grid Выровнять по пиксельной сетке +@msg only paths and compound paths can be aligned to the pixel grid по пиксельной сетке можно выровнять только контуры и составные контуры diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d1dd3ca1f..139aca78b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8005,3 +8005,7 @@ digits Hindi Hindi @msg x and y must be given together x och y måste anges tillsammans @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone behöver ett ankare: docka bredvid en panel med anchor eller i en ny dockkolumn med side (Start eller End) @msg zoom must be positive zoom måste vara positiv + +# Align to Pixel Grid + Align to Pixel Grid Justera mot pixelrutnät +@msg only paths and compound paths can be aligned to the pixel grid bara banor och sammansatta banor kan justeras mot pixelrutnätet diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b70d49803..2e2d52e00 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6475,3 +6475,7 @@ breakText Glyphs Glifler # 3D Box add-on 3D Box 3B Kutu 3D Box Tool 3B Kutu Aracı + +# Align to Pixel Grid + Align to Pixel Grid Piksel Izgarasına Hizala +@msg only paths and compound paths can be aligned to the pixel grid piksel ızgarasına yalnızca yollar ve bileşik yollar hizalanabilir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9c1fbf9ce..095bb1a66 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6118,3 +6118,7 @@ breakText Glyphs Гліфи # 3D Box add-on 3D Box 3D-паралелепіпед 3D Box Tool Інструмент «3D-паралелепіпед» + +# Align to Pixel Grid + Align to Pixel Grid Вирівняти за піксельною сіткою +@msg only paths and compound paths can be aligned to the pixel grid за піксельною сіткою можна вирівняти лише контури і складені контури diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d9239a386..448bc4627 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6119,3 +6119,7 @@ breakText Glyphs Glyph # 3D Box add-on 3D Box Hộp 3D 3D Box Tool Công cụ Hộp 3D + +# Align to Pixel Grid + Align to Pixel Grid Căn theo lưới pixel +@msg only paths and compound paths can be aligned to the pixel grid chỉ đường dẫn và đường dẫn phức mới căn theo lưới pixel được diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index cbfd6010d..81fd9ed81 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6359,3 +6359,7 @@ breakText Glyphs 字形 # 3D Box add-on 3D Box 3D 盒子 3D Box Tool 3D 盒子工具 + +# Align to Pixel Grid + Align to Pixel Grid 对齐像素网格 +@msg only paths and compound paths can be aligned to the pixel grid 只有路径和复合路径可以对齐像素网格 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 86cf8db1e..f46c17ec9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6156,3 +6156,7 @@ breakText Glyphs 字符 # 3D Box add-on 3D Box 3D 盒子 3D Box Tool 3D 盒子工具 + +# Align to Pixel Grid + Align to Pixel Grid 對齊像素格點 +@msg only paths and compound paths can be aligned to the pixel grid 只有路徑和複合路徑可以對齊像素格點 diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index ee3ce335c..b1f8322fd 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -1,8 +1,8 @@ //! Transform panel: reference point, X/Y/W/H with the constrain link, rotate and shear, flips, -//! live-shape properties and the Scale Corners / Scale Strokes & Effects options. While the -//! Artboard tool is in use it shows and edits the active artboard's X/Y/W/H instead (a new W or H -//! resizes every selected artboard), and with only ruler guides selected their X (vertical guides) -//! and Y (horizontal ones). +//! live-shape properties and the Scale Corners / Scale Strokes & Effects / Align to Pixel Grid +//! options. While the Artboard tool is in use it shows and edits the active artboard's X/Y/W/H +//! instead (a new W or H resizes every selected artboard), and with only ruler guides selected +//! their X (vertical guides) and Y (horizontal ones). use egui::Ui; use serde_json::json; @@ -363,6 +363,32 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) { set_pref(app, "scaleStrokes", !ss); } + pixel_grid_check(app, ui); +} + +/// The selected objects' Align to Pixel Grid: on or off when they agree, `None` when they differ, +/// with whether every one can be aligned (a path that is not a guide and not inside a compound path, +/// or a compound path) and their ids. `None` with nothing selected. +fn pixel_aligned_state(app: &VectorcraftApp) -> Option<(Option, bool, Vec)> { + let st = app.session.active()?; + let nodes: Vec<&Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect(); + let first = nodes.first()?.extras().pixel_aligned; + let value = nodes.iter().all(|n| n.extras().pixel_aligned == first).then_some(first); + let in_compound = |n: &Node| st.doc.parent_of(n.id).and_then(|p| st.doc.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + let alignable = nodes.iter().all(|n| matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) && !in_compound(n)); + Some((value, alignable, nodes.iter().map(|n| n.id.0).collect())) +} + +/// The Align to Pixel Grid checkbox (`object.setPixelAligned`): a dash when the selected objects +/// differ, and unavailable unless every selected object can be aligned. A click turns it on for +/// them all, or off when it is on for them all. +fn pixel_grid_check(app: &mut VectorcraftApp, ui: &mut Ui) { + let (value, alignable, ids) = pixel_aligned_state(app).unwrap_or((Some(false), false, vec![])); + if widgets::check3(ui, tl!("Align to Pixel Grid"), value, alignable) + && let Err(e) = app.run("object.setPixelAligned", json!({"ids": ids, "on": value != Some(true)})) + { + app.status(e); + } } /// A live rectangle's, polygon's or ellipse's properties (Transform and Properties panels, and the diff --git a/crates/ui-egui/src/panels/transform/tests.rs b/crates/ui-egui/src/panels/transform/tests.rs index a945f9ec1..b5598689a 100644 --- a/crates/ui-egui/src/panels/transform/tests.rs +++ b/crates/ui-egui/src/panels/transform/tests.rs @@ -503,3 +503,47 @@ fn rotate_and_shear_fields_are_named() { let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Rotate"); let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Shear"); } + +/// The Align to Pixel Grid checkbox turns the selected paths' pixel-aligned property on, shows a +/// dash when they differ (a click then turns it on for both), turns it off when it is on for all, +/// and is unavailable with type selected. +#[test] +fn align_to_pixel_grid_shows_and_sets_the_property() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("shape.rectangle", json!({"x": 60.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let aligned = |app: &VectorcraftApp, id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().extras().pixel_aligned; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let toggle = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let texts = frame_events(ctx, app, vec![], show); + click(ctx, app, text_rect(&texts, "Align to Pixel Grid").center(), show); + }; + assert_eq!(pixel_aligned_state(&app).map(|s| (s.0, s.1)), Some((Some(false), true))); + toggle(&ctx, &mut app); + assert!(aligned(&app, a) && aligned(&app, b)); + let x0 = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().geometric_bounds().unwrap().x0; + // The default 1 pt stroke is odd and centered: the edges go to half points. + assert_eq!(x0, 10.5); + app.run("object.setPixelAligned", json!({"ids": [b], "on": false})).unwrap(); + assert_eq!(pixel_aligned_state(&app).map(|s| s.0), Some(None)); + toggle(&ctx, &mut app); + assert!(aligned(&app, a) && aligned(&app, b)); + toggle(&ctx, &mut app); + assert!(!aligned(&app, a) && !aligned(&app, b)); + let t = app.run("text.create", json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, t]})).unwrap(); + assert_eq!(pixel_aligned_state(&app).map(|s| s.1), Some(false)); + toggle(&ctx, &mut app); + assert!(!aligned(&app, a)); + // A path inside a compound path snaps with the compound path, so it cannot be aligned alone. + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let c = app.run("object.compoundPath.make", json!({})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(pixel_aligned_state(&app).map(|s| s.1), Some(true)); + let member = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(c)).unwrap().children().unwrap()[0].id.0; + app.run("select.set", json!({"ids": [member]})).unwrap(); + assert_eq!(pixel_aligned_state(&app).map(|s| s.1), Some(false)); +} diff --git a/docs/mcp.md b/docs/mcp.md index 891fd6e14..29efd60bd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3429,6 +3429,56 @@ Absolute target, so it leaves objects that already have that size as they are; r {"name":"run_command","arguments":{"command":"object.transformEach","params":{"relative":false,"scaleH":50,"scaleV":50}}} ``` +## Align to Pixel Grid + +`object.setPixelAligned {ids?|id?, on?}` turns the pixel-aligned property of the objects given (default: the selected +objects) on or off (`on` defaults to true), as the Transform panel's Align to Pixel Grid checkbox does (described on +the help page, +). +It returns `{ids}`, the objects whose property changed. While the property is on: + +- The object's horizontal and vertical straight segments sit on whole points, or on half points when a stroke centered + on the path has an odd weight, so that the stroke's edges fall on the pixel grid. A stroke that is hidden or has no + paint does not count. Its stroke weights are whole points, at least 1. +- Every move, scale, rotation, reflection, shear, Transform Each, Align or Distribute move (of objects or of + direct-selected anchors), Transform panel change, move of direct-selected anchors or of one anchor + (`path.moveAnchor`), new anchors from `path.setAnchors`, live shape change (`object.setLiveShape`), move of a + compound path's path on its own, and artboard move or resize that carries the art (`artboard.move` with + `moveArt`, `artboard.setProps` with art, Rearrange Artboards) snaps those segments again, as do the art copies of `artboard.move` with `copy` and Duplicate + Artboards, the copies that `edit.duplicate` and the paste commands move, and `object.slice.move` of an object slice, + and so does every `stroke.set`, `stroke.weightUp` and `stroke.weightDown` edit, which also rounds the new weight. An + appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again too: + `appearance.setItem`, `appearance.addStroke`, `appearance.removeItem`, `appearance.clear`, + `appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer` (both objects), `appearance.copyFrom` + (the Eyedropper), `paint.setStroke` and `graphicStyle.apply`, in the same undo step as the edit, as is every member + of an active Global Edit that such an edit or move reaches. Every other command that changes a pixel-aligned object + (for example Object › Path › Average) snaps it again as part of its undo step. Each anchor that such a segment shares moves with its handles, so diagonal segments and curves keep their shape apart from those + anchors. A rotated shape with no horizontal or vertical segment is left as it is. Pixel-aligned art of a linked + opacity mask snaps when its object moves. +- Coordinates that differ stay at least 1 pt apart within a subpath, so a narrow shape does not collapse. A + coordinate on a whole point snaps to the half point above it and one on a half point to the whole point below it, so + hiding and showing an odd stroke returns the path to where it was. A placed gradient and the art of a linked opacity + mask move with the snapped anchors, and so do pattern tiles when the move transforms patterns. A pixel-aligned member + of an Objects on Path group snaps again after the group places it on its path. +- A live rectangle, polygon, line or pie is moved and scaled as a whole to the snapped bounds, and stays live. A full + ellipse has only curves and is left as it is. A path with Live Corners snaps the outline its corners are cut from and keeps + its Live Corners. A compound path snaps each of its paths with the compound path's stroke. +- Make Compound Path from paths or compound paths that include a pixel-aligned one gives a pixel-aligned compound path + and snaps it; its paths do not keep the property, and a path inside a compound path cannot take it alone (an error). + Release Compound Path gives each released path the property of the compound path. A path pasted into a compound + path, alone or from a pasted compound path, drops its own property and snaps with the compound path. + +Turning the property on snaps the objects at once; turning it off keeps their geometry. These rules are VectorCraft's +own: only paths and compound paths can be aligned, and type, images, groups, guides and other objects are an error +that leaves the document as it was. The change is one undo step, and when every object already has the value nothing +changes and no undo step is made. The property is saved in the native file (`pixel_aligned` in `document.node`), and +files saved without it read back with it off. + +```json +{"name":"run_command","arguments":{"command":"object.setPixelAligned","params":{"on":true}}} +{"name":"run_command","arguments":{"command":"object.setPixelAligned","params":{"ids":[12],"on":false}}} +``` + ## Live Corners `object.setLiveShape {id?, ids?, radius?, kind?, corners?, items?}` sets the corners of any path (one undo step): a live diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 43844d158..79ba65a05 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -711,6 +711,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear. - The New Document from State button at the bottom of the History panel (`edit.newDocumentFromState`) makes a new document, which becomes active, from the state the panel has selected ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/undo-edits-and-manage-design-history.html)). VectorCraft's own rules: the new document takes the next Untitled-N name, is marked unsaved, has nothing selected and has no steps to undo or redo, and the source document and its steps stay as they are. - The Transform panel menu's Transform Object Only, Transform Pattern Only and Transform Both (`transformPanelPatterns`) choose whether the fields, flips and menu flips of the Transform panel and the Properties panel's Transform section move the object and leave its pattern tiles, move the tiles alone, or both (until a choice they follow General › Transform Pattern Tiles, as before); Use Registration Point for Symbol (`useRegistrationPointForSymbol`) measures and transforms one selected symbol instance from its symbol's registration point, where the instance's flips, rotations and shears put it ([Transform panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/transform-panel-overview.html); `panels::transform::tests::the_menu_chooses_whether_the_object_its_pattern_or_both_transform`, `use_registration_point_for_symbol_measures_an_instance_from_its_registration_point`, `use_registration_point_for_symbol_follows_a_transformed_instance`, `tests_prefs::transform_panel_patterns_defaults_to_following_the_general_preference`). + - The Transform panel's Align to Pixel Grid checkbox (`object.setPixelAligned`) gives paths and compound paths a pixel-aligned property, saved in the native file: their horizontal and vertical straight segments sit on whole points (half points under an odd centered stroke) with whole stroke weights, and every move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor, move of a compound path's path on its own, artboard move, resize or copy that carries the art, Duplicate or paste that moves a copy, stroke edit and appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again, while diagonal segments and curves keep their shape; a compound path made from an aligned path or compound path is aligned and snaps its paths with its own stroke; the box shows a dash for a mixed selection and is unavailable for type, images and groups ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::turning_it_on_snaps_the_path_in_one_undo_step`, `it_stays_aligned_through_transforms`, `it_stays_aligned_when_its_artboard_moves`, `diagonal_segments_and_curves_keep_their_shape`, `type_and_images_cannot_be_aligned`, `the_property_is_saved_and_read_back`, `stroke_edits_keep_it_aligned`, `a_compound_path_of_aligned_paths_is_aligned`, `a_compound_path_of_an_aligned_compound_path_is_aligned`, `a_stroke_without_paint_snaps_to_whole_points`, `moving_direct_selected_anchors_keeps_it_aligned`, `moving_one_anchor_keeps_it_aligned`, `moving_a_path_of_an_aligned_compound_path_keeps_it_aligned`, `artboard_options_and_artboard_copies_keep_it_aligned`, `appearance_edits_keep_it_aligned`, `placed_gradients_follow_the_snapped_path`, `duplicates_and_pastes_keep_it_aligned`, `paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned`, `the_art_of_a_linked_opacity_mask_keeps_it_aligned`, `a_linked_opacity_mask_follows_a_snap`, `aligning_direct_selected_anchors_keeps_it_aligned`, `global_edit_keeps_its_members_aligned`, `a_path_with_live_corners_snaps_its_inner_edges`, `geometry_edits_keep_it_aligned`, `other_path_edits_keep_it_aligned`, `panels::transform::tests::align_to_pixel_grid_shows_and_sets_the_property`). - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The 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`). From bf1145edd5f98adef6fd5ebc87a3f798832eb867 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:29:52 +0700 Subject: [PATCH 16/93] JavaScript: vectorcraft-js runs ES5 scripts with a garbage-collected heap and execution limits Squashed from #6178 onto main. --- crates/js/Cargo.toml | 2 +- crates/js/src/builtins.rs | 352 ++++++ crates/js/src/bytecode.rs | 362 ++++++ crates/js/src/chars.rs | 33 + crates/js/src/compiler.rs | 1260 ++++++++++++++++++ crates/js/src/heap.rs | 594 +++++++++ crates/js/src/interp.rs | 2244 +++++++++++++++++++++++++++++++++ crates/js/src/interp_tests.rs | 852 +++++++++++++ crates/js/src/lexer.rs | 22 +- crates/js/src/lib.rs | 26 + crates/js/src/realm.rs | 222 ++++ crates/js/src/tests.rs | 5 +- crates/js/src/value.rs | 397 ++++++ crates/js/tests/test262.rs | 144 ++- docs/target-app-parity.md | 3 +- xtask/src/main.rs | 6 +- xtask/src/test262.rs | 3 +- 17 files changed, 6501 insertions(+), 26 deletions(-) create mode 100644 crates/js/src/builtins.rs create mode 100644 crates/js/src/bytecode.rs create mode 100644 crates/js/src/compiler.rs create mode 100644 crates/js/src/heap.rs create mode 100644 crates/js/src/interp.rs create mode 100644 crates/js/src/interp_tests.rs create mode 100644 crates/js/src/realm.rs create mode 100644 crates/js/src/value.rs diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index 0108b50d9..4cb1e62c2 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vectorcraft-js" -description = "JavaScript for scripts: an ECMAScript 5.1 lexer and parser to a syntax tree with source positions" +description = "JavaScript for scripts: an ECMAScript 5.1 parser, and an interpreter with a garbage-collected heap and execution limits" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs new file mode 100644 index 000000000..3eda7b6a6 --- /dev/null +++ b/crates/js/src/builtins.rs @@ -0,0 +1,352 @@ +//! 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. + +use std::rc::Rc; + +use crate::bytecode::{CodeKind, Source}; +use crate::compiler::{CompileFail, compile_constructed_function}; +use crate::heap::{Class, Func, HIDDEN, NativeFn, NativeKind, ObjId, Object, Prop, key, string_bytes}; +use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; +use crate::value::{Str, Value}; + +fn alloc(interp: &mut Interp, class: Class, proto: Option) -> ObjId { + interp.heap.alloc_object(Object::new(class, proto)) +} + +/// A built-in function object. +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<()> { + 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<()> { + 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<()> { + 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. +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 + // that somehow lacks a built-in reports it when a script uses it. + let _ = e; + } +} + +fn try_install(interp: &mut Interp) -> JsResult<()> { + let object_proto = alloc(interp, Class::Ordinary, None); + interp.intrinsics.object_proto = object_proto; + let function_proto = alloc( + interp, + Class::Function(Func::Native { f: function_proto_call, kind: NativeKind::Plain, constructor: false, name: "" }), + Some(object_proto), + ); + interp.intrinsics.function_proto = function_proto; + data(interp, function_proto, "length", Value::Number(0.0), 0)?; + let global = interp.global; + if let Some(g) = interp.heap.object_mut(global) { + g.proto = Some(object_proto); + } + 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.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)?; + if let Some(t) = interp.heap.object_mut(thrower) { + t.extensible = false; + } + interp.intrinsics.throw_type_error = thrower; + + data(interp, global, "NaN", Value::Number(f64::NAN), 0)?; + data(interp, global, "Infinity", Value::Number(f64::INFINITY), 0)?; + data(interp, global, "undefined", Value::Undefined, 0)?; + let eval = native(interp, "eval", eval, 1, NativeKind::Eval, false)?; + interp.intrinsics.eval = eval; + data(interp, global, "eval", Value::Object(eval), HIDDEN)?; + + let object = native(interp, "Object", object_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "Object", object, object_proto)?; + method(interp, object_proto, "toString", object_to_string, 0)?; + method(interp, object_proto, "toLocaleString", object_to_locale_string, 0)?; + method(interp, object_proto, "valueOf", object_value_of, 0)?; + method(interp, object_proto, "hasOwnProperty", has_own_property, 1)?; + method(interp, object_proto, "isPrototypeOf", is_prototype_of, 1)?; + method(interp, object_proto, "propertyIsEnumerable", property_is_enumerable, 1)?; + + let function = native(interp, "Function", function_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "Function", function, function_proto)?; + let call = native(interp, "call", unreachable_native, 1, NativeKind::Call, false)?; + data(interp, function_proto, "call", Value::Object(call), HIDDEN)?; + let apply = native(interp, "apply", unreachable_native, 2, NativeKind::Apply, false)?; + data(interp, function_proto, "apply", Value::Object(apply), HIDDEN)?; + method(interp, function_proto, "toString", function_to_string, 0)?; + + install_errors(interp, object_proto) +} + +fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { + let kinds: [(&'static str, NativeFn); 7] = [ + ("Error", error_ctor), + ("EvalError", eval_error_ctor), + ("RangeError", range_error_ctor), + ("ReferenceError", reference_error_ctor), + ("SyntaxError", syntax_error_ctor), + ("TypeError", type_error_ctor), + ("URIError", uri_error_ctor), + ]; + let mut error_proto = object_proto; + for (i, (name, f)) in kinds.into_iter().enumerate() { + // Error.prototype and each NativeError prototype are Error objects (sections 15.11.4 and + // 15.11.7.7). + let proto = alloc(interp, Class::Error, Some(if i == 0 { object_proto } else { error_proto })); + if i == 0 { + error_proto = proto; + method(interp, proto, "toString", error_to_string, 0)?; + } + if let Some(slot) = interp.intrinsics.error_protos.get_mut(i) { + *slot = proto; + } + data(interp, proto, "name", Value::str(name), HIDDEN)?; + data(interp, proto, "message", Value::str(""), HIDDEN)?; + let ctor = native(interp, name, f, 1, NativeKind::Plain, true)?; + constructor(interp, name, ctor, proto)?; + } + Ok(()) +} + +fn arg(args: &[Value], i: usize) -> Value { + args.get(i).cloned().unwrap_or(Value::Undefined) +} + +fn unreachable_native(_: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Err(Abrupt::Internal("call or apply reached its native body".into())) +} + +fn throw_type_error(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Err(interp.type_error("'caller', 'callee' and 'arguments' cannot be used on strict mode functions")) +} + +fn function_proto_call(_: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Undefined) +} + +// ---- Object ---- + +/// `Object(value)` and `new Object(value)` (sections 15.2.1 and 15.2.2). +fn object_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let v = arg(args, 0); + if v.is_nullish() { + return Ok(Value::Object(interp.new_object())); + } + interp.to_object(&v).map(Value::Object) +} + +/// `Object.prototype.toString` (section 15.2.4.2). +fn object_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let class = match &this { + Value::Undefined => "Undefined", + Value::Null => "Null", + _ => { + let o = interp.to_object(&this)?; + interp.heap.object(o).map_or("Object", |o| o.class.name()) + } + }; + Ok(Value::str(&format!("[object {class}]"))) +} + +fn object_to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&this)?; + let f = interp.get_from(o, &key("toString"), &this)?; + if !interp.is_callable(&f) { + return Err(interp.type_error("toString is not a function")); + } + interp.call(&f, &this, &[]) +} + +fn object_value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + interp.to_object(&this).map(Value::Object) +} + +fn has_own_property(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let k = interp.to_key(&arg(args, 0))?; + let o = interp.to_object(&this)?; + Ok(Value::Bool(interp.get_own_property(o, &k)?.is_some())) +} + +fn is_prototype_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let Value::Object(mut v) = arg(args, 0) else { return Ok(Value::Bool(false)) }; + let o = interp.to_object(&this)?; + while let Some(p) = interp.heap.object(v).and_then(|v| v.proto) { + if p == o { + return Ok(Value::Bool(true)); + } + v = p; + interp.use_fuel()?; + } + Ok(Value::Bool(false)) +} + +fn property_is_enumerable(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let k = interp.to_key(&arg(args, 0))?; + let o = interp.to_object(&this)?; + Ok(Value::Bool(interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()))) +} + +// ---- Function ---- + +/// `Function(p1, …, body)` and `new Function(…)` (section 15.3.2.1): the source is read as +/// `(function anonymous(p1,…\n) {\nbody\n})` and must be exactly that one function. +fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + const HEAD: &str = "(function anonymous("; + const MID: &str = "\n) {\n"; + const TAIL: &str = "\n})"; + let mut parts = Vec::with_capacity(args.len()); + for a in args { + parts.push(interp.to_str(a)?); + } + let body = parts.pop().unwrap_or_else(|| Str::from("")); + // The length is checked against the source limit before any argument is flattened or copied. + let total = parts + .iter() + .map(Str::len) + .chain([parts.len().saturating_sub(1), HEAD.len() + MID.len() + TAIL.len(), body.len()]) + .fold(0usize, usize::saturating_add); + interp.check_source_units(total).map_err(|e| interp.compile_error_to_abrupt(e))?; + let mut units = Vec::with_capacity(total); + units.extend(HEAD.encode_utf16()); + for (i, p) in parts.iter().enumerate() { + if i > 0 { + units.push(u16::from(b',')); + } + units.extend_from_slice(&p.flat()); + } + units.extend(MID.encode_utf16()); + units.extend_from_slice(&body.flat()); + units.extend(TAIL.encode_utf16()); + interp.source_units = interp.source_units.saturating_add(units.len()); + let remaining = interp.options.max_source_units.saturating_sub(interp.source_units.saturating_sub(units.len())); + let options = crate::Options { strict: false, max_source_units: remaining, ..interp.options.clone() }; + let program = match crate::parse_utf16(&units, &options) { + Ok(p) => p, + Err(e) => return Err(interp.throw(ErrorType::Syntax, &e.message)), + }; + let source = Rc::new(Source { lines: program.lines.clone(), text: units.into() }); + let code = match compile_constructed_function(&program, &source) { + Ok(Some(code)) => code, + Ok(None) => return Err(interp.throw(ErrorType::Syntax, "the parameters or body do not form one function")), + Err(CompileFail::Syntax(e)) => return Err(interp.throw(ErrorType::Syntax, &e.message)), + Err(CompileFail::Internal(m)) => return Err(Abrupt::Internal(m)), + }; + interp.charge_code(&code, 0)?; + let env = interp.global_env; + interp.make_closure(code, env).map(Value::Object) +} + +/// `Function.prototype.toString` (section 15.3.4.2): a function's source text, or a stand-in +/// for a built-in. +fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let func = this.as_object().and_then(|o| interp.heap.object(o)).and_then(|o| match &o.class { + Class::Function(f) => Some(f.clone()), + _ => None, + }); + let text = match func { + Some(Func::Closure { code, .. }) => { + let (start, end) = (code.span.0 as usize, code.span.1 as usize); + let units = code.source.text.get(start..end).unwrap_or_default(); + let bytes = string_bytes(units.len()); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + return Ok(Value::String(Str::from_units(units.to_vec()))); + } + Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"), + Some(Func::Host { name, .. }) => format!("function {name}() {{ [native code] }}"), + None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")), + }; + Ok(Value::str(&text)) +} + +/// The global `eval` called indirectly (section 15.1.2.1.1): global code in the global +/// environment. +fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let x = arg(args, 0); + let Value::String(s) = &x else { return Ok(x) }; + interp.check_source_units(s.len()).map_err(|e| interp.compile_error_to_abrupt(e))?; + let units = s.flat(); + let code = interp.compile_source(&units, false, CodeKind::Eval).map_err(|e| interp.compile_error_to_abrupt(e))?; + interp.run_global(code, true) +} + +// ---- errors ---- + +fn make_error(interp: &mut Interp, t: ErrorType, args: &[Value]) -> JsResult { + let message = arg(args, 0); + let text = if matches!(message, Value::Undefined) { None } else { Some(interp.to_str(&message)?) }; + let o = interp.error_object(t, ""); + if let Some(text) = text { + interp.define(o, key("message"), Prop::Data { value: Value::String(text), flags: HIDDEN })?; + } + Ok(Value::Object(o)) +} + +fn error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + make_error(interp, ErrorType::Error, args) +} + +fn eval_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + make_error(interp, ErrorType::Eval, args) +} + +fn range_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + make_error(interp, ErrorType::Range, args) +} + +fn reference_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + make_error(interp, ErrorType::Reference, args) +} + +fn syntax_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + make_error(interp, ErrorType::Syntax, args) +} + +fn type_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + make_error(interp, ErrorType::Type, args) +} + +fn uri_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + make_error(interp, ErrorType::Uri, args) +} + +/// `Error.prototype.toString` (section 15.11.4.4). +fn error_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let Value::Object(o) = this else { return Err(interp.type_error("Error.prototype.toString was called on a value that is not an object")) }; + let name = interp.get_from(o, &key("name"), &this)?; + let name = if matches!(name, Value::Undefined) { Str::from("Error") } else { interp.to_str(&name)? }; + let message = interp.get_from(o, &key("message"), &this)?; + let message = if matches!(message, Value::Undefined) { Str::from("") } else { interp.to_str(&message)? }; + if name.len() == 0 { + return Ok(Value::String(message)); + } + if message.len() == 0 { + return Ok(Value::String(name)); + } + let sep = Str::from(": "); + let head = interp.concat(&name, &sep)?; + interp.concat(&head, &message).map(Value::String) +} diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs new file mode 100644 index 000000000..b23d9a525 --- /dev/null +++ b/crates/js/src/bytecode.rs @@ -0,0 +1,362 @@ +//! Bytecode: the instructions a function, a script or eval code compiles to, and the verifier +//! that checks them before they run. +//! +//! The interpreter is a stack machine. Each instruction takes its operands from the top of the +//! frame's operand stack and pushes its result there; jumps name an instruction index. The +//! verifier walks every path through the code and checks that each jump target, exception handler +//! and `finally` dispatch target is an instruction, that each constant, name and function index is +//! in its table, that the stack never goes below the frame's base, that every path reaching an +//! instruction arrives with the same stack depth, and that no path runs past the last +//! instruction. Code that fails is reported as an internal error and never runs. + +use std::rc::Rc; + +use crate::LineTable; +use crate::ast::BinaryOp; +use crate::heap::Key; +use crate::value::Str; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Op { + Undefined, + Null, + True, + False, + /// Pushes `numbers[i]`. + Number(u32), + /// Pushes `strings[i]`. + String(u32), + This, + Pop, + /// Removes the `n` values under the top one. + PopUnder(u32), + Dup, + /// `a b` → `a b a b`. + Dup2, + Swap, + /// `a b c` → `b c a`. + Rot3, + /// Pushes the value of the variable `names[i]`. + GetVar(u32), + /// Pushes the value of the variable and the `this` a call to it gets. + GetVarThis(u32), + /// `value` → `value`, assigned to the variable. + SetVar(u32), + /// Pushes the base of the reference to the variable `names[i]`, resolved before the + /// right-hand side of an assignment runs. + RefVar(u32), + /// `base` → `value`: the variable's value read through the resolved base. + GetRef(u32), + /// `base value` → `value`, assigned to the variable through the resolved base. + PutRef(u32), + TypeofVar(u32), + DeleteVar(u32), + IncVar { + name: u32, + prefix: bool, + dec: bool, + }, + /// `object` → `object.name`. + GetMember(u32), + /// `object` → `object.name object`. + GetMemberThis(u32), + /// `object value` → `value`. + SetMember(u32), + DeleteMember(u32), + IncMember { + name: u32, + prefix: bool, + dec: bool, + }, + /// `object key` → `object name`: checks that the object can have properties and turns the key + /// into a property name, before the right-hand side of an assignment runs. + ToKey, + /// `object` → `object`: throws a `TypeError` when it is `undefined` or `null`, before the + /// right-hand side of an assignment to one of its properties runs. + CheckObject, + /// `object key` → `object[key]`. + GetIndex, + /// `object key` → `object[key] object`. + GetIndexThis, + /// `object key value` → `value`. + SetIndex, + DeleteIndex, + IncIndex { + prefix: bool, + dec: bool, + }, + /// `function this arg…` → result. + Call(u32), + /// Like `Call`, but a direct eval when the function is the global `eval`. + CallEval(u32), + /// `function arg…` → the new object. + New(u32), + Binary(BinaryOp), + Neg, + Plus, + BitNot, + Not, + Typeof, + ToNumber, + Jump(u32), + /// Pops the top value and jumps when it is falsy. + JumpIfFalse(u32), + JumpIfTrue(u32), + /// Jumps, keeping the top value, when it is falsy; otherwise pops it. + JumpIfFalseKeep(u32), + JumpIfTrueKeep(u32), + Return, + /// Returns the value of the last expression statement (script and eval code). + ReturnCompletion, + Throw, + /// Throws a `ReferenceError` with the message `strings[i]` (an assignment to something that + /// cannot be assigned to). + ThrowReferenceError(u32), + /// Ends a `finally` block: `value kind` on the stack say how it was entered. Kind 0 (normal) + /// pops both and goes on, kind 1 throws the value, and kind `2 + k` jumps to entry `k` of + /// `finally_tables[i]` with the value left on the stack. + EndFinally(u32), + NewObject, + NewArray, + /// `array value` → `array`, the value appended. + ArrayPush, + /// `array` → `array`, one longer. + ArrayHole, + /// `object value` → `object`. + InitProp(u32), + /// `object function` → `object`. + InitGetter(u32), + InitSetter(u32), + /// Pushes a new function made from `functions[i]` and the current environment. + Closure(u32), + /// Pushes a new regular expression object for `regexps[i]`. + RegExp(u32), + /// `object` → nothing: enters a `with` statement's environment. + PushWith, + /// `exception` → nothing: enters a catch clause's environment, binding `names[i]`. + PushCatch(u32), + PopEnv, + /// `object` → `iterator`. + ForInStart, + /// `iterator` → `iterator name`, or jumps with `iterator` when the names are used up. + ForInNext(u32), + /// Pops the value of an expression statement into the completion value. + SetCompletion, + /// Pushes the completion value, which a `finally` block in script and eval code keeps and + /// sets back with `SetCompletion` when it ends normally. + GetCompletion, +} + +/// What code is: a function body, a script, or eval code. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CodeKind { + Function, + Global, + Eval, +} + +/// A range of instructions an exception thrown in jumps out of: to `target`, with the operand +/// stack cut back to `depth` values and the environments to `env_depth`, and the exception pushed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Handler { + pub(crate) start: u32, + pub(crate) end: u32, + pub(crate) target: u32, + pub(crate) depth: u32, + pub(crate) env_depth: u32, +} + +/// The source a piece of code came from, for error positions and `Function.prototype.toString`. +#[derive(Debug)] +pub(crate) struct Source { + pub(crate) lines: LineTable, + pub(crate) text: Rc<[u16]>, +} + +#[derive(Debug)] +pub(crate) struct Code { + pub(crate) ops: Vec, + /// The source offset each instruction comes from. + pub(crate) positions: Vec, + pub(crate) numbers: Vec, + pub(crate) strings: Vec, + pub(crate) names: Vec, + pub(crate) functions: Vec>, + pub(crate) regexps: Vec<(Str, Str)>, + pub(crate) handlers: Vec, + pub(crate) finally_tables: Vec>, + /// The most values the code keeps on the operand stack, set by the verifier. + pub(crate) max_stack: u32, + pub(crate) strict: bool, + /// A function expression's own name, bound inside it. + pub(crate) self_name: Option, + pub(crate) params: Vec, + pub(crate) vars: Vec, + /// Function declarations to instantiate on entry: their names and indices in `functions`. + pub(crate) fun_decls: Vec<(Key, u32)>, + /// The body refers to `arguments` or calls `eval`. + pub(crate) uses_arguments: bool, + pub(crate) source: Rc, + /// Where the function's text starts and ends in the source. + pub(crate) span: (u32, u32), +} + +impl Code { + /// The innermost handler around instruction `pc`. + pub(crate) fn handler(&self, pc: usize) -> Option { + self.handlers.iter().copied().find(|h| (h.start as usize) <= pc && pc < h.end as usize) + } + + /// The line and column of instruction `pc`. + pub(crate) fn line_column(&self, pc: usize) -> (u32, u32) { + let offset = self.positions.get(pc).copied().unwrap_or(self.span.0); + self.source.lines.line_column(offset) + } +} + +/// What an instruction does to the stack on each way out of it. +struct Effect { + pops: u32, + /// Values pushed when it goes on to the next instruction; `None` when it never does. + next: Option, + /// Instruction indices it may jump to, each with the values it pushes on that path. + jumps: Vec<(u32, u32)>, +} + +/// The values an instruction pops, and pushes when it goes on to the next instruction (0 for one +/// that never does), for the compiler's count of the stack depth. +pub(crate) fn fallthrough_effect(op: Op) -> (u32, u32) { + match op { + Op::EndFinally(_) => (2, 0), + _ => effect(op, &[]).map_or((0, 0), |fx| (fx.pops, fx.next.unwrap_or(0))), + } +} + +fn effect(op: Op, finally_tables: &[Vec]) -> Result { + let e = |pops, pushes| Effect { pops, next: Some(pushes), jumps: Vec::new() }; + let end = |pops| Effect { pops, next: None, jumps: Vec::new() }; + Ok(match op { + Op::Undefined | Op::Null | Op::True | Op::False | Op::Number(_) | Op::String(_) | Op::This => e(0, 1), + Op::GetVar(_) | Op::TypeofVar(_) | Op::DeleteVar(_) | Op::IncVar { .. } | Op::RefVar(_) => e(0, 1), + Op::GetRef(_) => e(1, 1), + Op::PutRef(_) => e(2, 1), + Op::NewObject | Op::NewArray | Op::Closure(_) | Op::RegExp(_) | Op::GetCompletion => e(0, 1), + Op::Pop | Op::PushWith | Op::PushCatch(_) | Op::SetCompletion => e(1, 0), + Op::PopUnder(n) => e(n.checked_add(1).ok_or("operand too large")?, 1), + Op::Dup => e(1, 2), + Op::Dup2 => e(2, 4), + Op::Swap | Op::ToKey => e(2, 2), + Op::Rot3 => e(3, 3), + Op::GetVarThis(_) => e(0, 2), + Op::SetVar(_) | Op::GetMember(_) | Op::DeleteMember(_) | Op::IncMember { .. } => e(1, 1), + Op::Neg | Op::Plus | Op::BitNot | Op::Not | Op::Typeof | Op::ToNumber | Op::ArrayHole | Op::ForInStart | Op::CheckObject => e(1, 1), + Op::GetMemberThis(_) => e(1, 2), + Op::SetMember(_) | Op::GetIndex | Op::DeleteIndex | Op::IncIndex { .. } | Op::Binary(_) => e(2, 1), + Op::ArrayPush | Op::InitProp(_) | Op::InitGetter(_) | Op::InitSetter(_) => e(2, 1), + Op::GetIndexThis => e(2, 2), + Op::SetIndex => e(3, 1), + Op::Call(n) | Op::CallEval(n) => e(n.checked_add(2).ok_or("operand too large")?, 1), + Op::New(n) => e(n.checked_add(1).ok_or("operand too large")?, 1), + Op::PopEnv => e(0, 0), + Op::Jump(t) => Effect { pops: 0, next: None, jumps: vec![(t, 0)] }, + Op::JumpIfFalse(t) | Op::JumpIfTrue(t) => Effect { pops: 1, next: Some(0), jumps: vec![(t, 0)] }, + Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) => Effect { pops: 1, next: Some(0), jumps: vec![(t, 1)] }, + Op::Return | Op::Throw => end(1), + Op::ReturnCompletion | Op::ThrowReferenceError(_) => end(0), + Op::EndFinally(t) => { + let table = finally_tables.get(t as usize).ok_or_else(|| format!("finally table {t} out of range"))?; + Effect { pops: 2, next: Some(0), jumps: table.iter().map(|&target| (target, 1)).collect() } + } + Op::ForInNext(t) => Effect { pops: 1, next: Some(2), jumps: vec![(t, 1)] }, + }) +} + +/// Checks the constant, name, function and regular expression indices an instruction uses. +fn check_indices(op: Op, code: &Code) -> Result<(), String> { + let check = |i: u32, len: usize, what: &str| if (i as usize) < len { Ok(()) } else { Err(format!("{what} {i} out of range")) }; + match op { + Op::Number(i) => check(i, code.numbers.len(), "number"), + Op::String(i) | Op::ThrowReferenceError(i) => check(i, code.strings.len(), "string"), + Op::GetVar(i) + | Op::GetVarThis(i) + | Op::SetVar(i) + | Op::RefVar(i) + | Op::GetRef(i) + | Op::PutRef(i) + | Op::TypeofVar(i) + | Op::DeleteVar(i) + | Op::IncVar { name: i, .. } + | Op::GetMember(i) + | Op::GetMemberThis(i) + | Op::SetMember(i) + | Op::DeleteMember(i) + | Op::IncMember { name: i, .. } + | Op::InitProp(i) + | Op::InitGetter(i) + | Op::InitSetter(i) + | Op::PushCatch(i) => check(i, code.names.len(), "name"), + Op::Closure(i) => check(i, code.functions.len(), "function"), + Op::RegExp(i) => check(i, code.regexps.len(), "regular expression"), + _ => Ok(()), + } +} + +/// Verifies `code` (not the functions inside it, which are verified when they are compiled) and +/// returns the most values it keeps on the stack. +pub(crate) fn verify(code: &Code) -> Result { + let len = code.ops.len(); + if len == 0 { + return Err("no instructions".into()); + } + if code.positions.len() != len { + return Err("the position table does not match the instructions".into()); + } + for (name, _) in &code.fun_decls { + if name.is_empty() { + return Err("a function declaration without a name".into()); + } + } + if code.fun_decls.iter().any(|(_, i)| *i as usize >= code.functions.len()) { + return Err("function declaration index out of range".into()); + } + let mut depth: Vec> = vec![None; len]; + let mut work: Vec<(u32, u32)> = vec![(0, 0)]; + for h in &code.handlers { + if h.start > h.end || h.end as usize > len { + return Err(format!("handler range {}..{} out of range", h.start, h.end)); + } + work.push((h.target, h.depth.checked_add(1).ok_or("handler depth too large")?)); + } + let mut max = 0; + while let Some((pc, d)) = work.pop() { + let slot = depth.get_mut(pc as usize).ok_or_else(|| format!("jump to {pc}, past the end"))?; + match *slot { + Some(seen) if seen == d => continue, + Some(seen) => return Err(format!("instruction {pc} reached with stack depths {seen} and {d}")), + None => *slot = Some(d), + } + max = max.max(d); + let op = code.ops.get(pc as usize).copied().ok_or("instruction out of range")?; + check_indices(op, code)?; + let fx = effect(op, &code.finally_tables)?; + let base = d.checked_sub(fx.pops).ok_or_else(|| format!("instruction {pc} ({op:?}) pops below the stack"))?; + if let Some(pushes) = fx.next { + if pc as usize + 1 >= len { + return Err("the code runs past its last instruction".into()); + } + work.push((pc + 1, base + pushes)); + } + for (target, pushes) in fx.jumps { + work.push((target, base + pushes)); + } + } + for h in &code.handlers { + if let Some(Some(d)) = depth.get(h.start as usize) + && *d < h.depth + && h.start < h.end + { + return Err(format!("handler at {} expects {} values but its range starts with {d}", h.target, h.depth)); + } + } + Ok(max) +} diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs index 75c0b92aa..eb61fe763 100644 --- a/crates/js/src/chars.rs +++ b/crates/js/src/chars.rs @@ -58,6 +58,39 @@ pub fn hex_value(c: u32) -> Option { } } +/// The value of the digit values `digits` in `radix` (8 or 16), rounded once to the nearest number +/// with ties to even (sections 7.8.3 and 9.3.1), or `None` when there are no digits. Digits are +/// kept exactly in a `u128` while they fit; past that, each later digit counts only for its +/// position and for whether any of the dropped bits is set. +pub fn radix_value(digits: impl IntoIterator, radix: u32) -> Option { + let bits = radix.trailing_zeros(); + let mut kept: u128 = 0; + let mut dropped_bits: u32 = 0; + let mut sticky = false; + let mut any = false; + for d in digits { + any = true; + if kept.leading_zeros() >= bits { + kept = kept << bits | u128::from(d); + } else { + dropped_bits = dropped_bits.saturating_add(bits); + sticky |= d != 0; + } + } + if !any { + return None; + } + // `kept` holds at least 125 bits once a digit is dropped, so its lowest bit lies below the + // rounding bit and stands in for every dropped bit. + let mut value = (kept | u128::from(sticky)) as f64; + while dropped_bits > 0 && value.is_finite() { + let step = dropped_bits.min(64); + value *= (1_u128 << step) as f64; + dropped_bits -= step; + } + Some(value) +} + pub fn is_decimal_digit(c: u32) -> bool { (0x30..=0x39).contains(&c) } diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs new file mode 100644 index 000000000..ae470be44 --- /dev/null +++ b/crates/js/src/compiler.rs @@ -0,0 +1,1260 @@ +//! The compiler: a syntax tree to bytecode, one [`Code`] per function, script or eval code. +//! +//! Variables are looked up by name at run time through the environment chain, as ECMA-262 5.1 +//! section 10 describes, so `with` and direct `eval` need no special analysis. Declarations are +//! hoisted: each code lists its `var` names and function declarations, and the interpreter binds +//! them when the code is entered (section 10.5). +//! +//! The compiler counts the operand stack depth as it emits, so `break`, `continue` and `return` +//! know how many values to drop. A jump out of a `try` with a `finally` block goes through the +//! block: it pushes a completion (a value and a kind number) and jumps to the block, whose +//! `EndFinally` instruction goes on with the jump afterwards. Long left-nested runs of binary +//! operators and member accesses, right-nested assignment chains, prefix operators, `else if` +//! chains and labels are compiled with loops; every other kind of nesting is counted, and code +//! nested deeper than [`MAX_DEPTH`] gives a `SyntaxError` instead of exhausting the stack. + +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use crate::ast::{ + AssignOp, Ast, BinaryOp, Expr, ExprId, ForInTarget, ForInit, FunctionId, FunctionKind, LogicalOp, PropertyValue, Stmt, StmtId, UnaryOp, UpdateOp, + VarDecl, +}; +use crate::bytecode::{Code, CodeKind, Handler, Op, Source, fallthrough_effect, verify}; +use crate::error::{SyntaxError, TOO_DEEP}; +use crate::heap::Key; +use crate::value::Str; + +/// How deeply the compiler may recurse: past the parser's default nesting limit, and shallow +/// enough for a 1 MiB stack. +pub(crate) const MAX_DEPTH: u32 = 600; + +/// Why code could not be compiled. +#[derive(Debug)] +pub(crate) enum CompileFail { + Syntax(SyntaxError), + /// The compiler made code the verifier rejects. + Internal(String), +} + +type CResult = Result; + +fn internal(message: impl Into) -> CResult { + Err(CompileFail::Internal(message.into())) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Label(usize); + +/// Where `break`, `continue` and `return` go. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Exit { + /// To the end of the jump context at this index. + Break(usize), + /// To the continue point of the loop at this index. + Continue(usize), + Return, +} + +enum Ctx { + /// A loop, a `switch` or a labeled statement. + Jump { labels: Vec, is_loop: bool, plain_break: bool, brk: Label, cont: Option