From c0279473623f98d021e2f524d751c4063ac009c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 11:08:47 +0700 Subject: [PATCH] .ai import: Objects on Path groups open live with their path, Pivot and Rotate Squashed from #5059 onto main. --- .../engine/src/cmd/fileio/tests_aiimport.rs | 27 +++ crates/engine/tests/import_fuzz.rs | 95 ++++++++++ crates/eps/src/import/ai/read.rs | 6 + crates/eps/src/import/ai/read/document.rs | 9 +- crates/eps/src/import/ai/read/on_path.rs | 176 ++++++++++++++++++ crates/eps/src/import/ai/tests.rs | 142 ++++++++++++++ crates/testkit/src/ai.rs | 38 +++- docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 1 + 9 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 crates/eps/src/import/ai/read/on_path.rs diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 6f603b200..29e0b0680 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -410,6 +410,33 @@ fn an_ai_file_with_a_variable_library_says_its_variables_werent_read() { assert!(notes(&r).is_empty(), "{r}"); } +/// An Objects on Path group in a `.ai` file opens as one: its record names the group's children by +/// their ids in the opened document. +#[test] +fn an_ai_file_opens_its_objects_on_path_group_live() { + let ids = ["9b1c0e7a-0000-4000-8000-000000000001", "9b1c0e7a-0000-4000-8000-000000000002", "9b1c0e7a-0000-4000-8000-000000000003"]; + let options = ai::on_path_options(2, 4, std::f64::consts::FRAC_PI_2, ""); + let record = ai::on_path_record("Adobe Align On Path", &ids, &options); // brand-ok: the record's name in the files + let group = format!( + "0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n10 50 m\n190 50 L\nS\n{}0 1 1 0 1 0 0 Xa\n{}f\n{}{}f\n{}U\n", + ai::on_path_member(ids[0], None, 0.0), + ai::rect(5.0, 45.0, 15.0, 55.0), + ai::on_path_member(ids[1], Some(0), 0.0), + ai::rect(185.0, 45.0, 195.0, 55.0), + ai::on_path_member(ids[2], Some(1), 0.0), + ); + let data = ai::editing_data(200.0, 100.0, &format!("{record}{}", ai::layer("Art", &group))); + let mut s = Session::new(); + let r = open(&mut s, "on-path.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 5 45 10 10 re f 185 45 10 10 re f"), json!({})).unwrap(); + assert_eq!(r["format"], json!("ai"), "{r}"); + let doc = &s.doc().unwrap().doc; + let g = &doc.layers[0].children().unwrap()[0]; + let rec = g.extras().objects_on_path.as_deref().expect("an Objects on Path group"); + let kids: Vec = g.children().unwrap().iter().map(|c| c.id).collect(); + assert!(kids.contains(&rec.path) && rec.members.iter().all(|m| kids.contains(&m.id)), "{rec:?} {kids:?}"); + assert_eq!((rec.members.len(), rec.pivot, rec.rotate.round()), (2, 4, 90.0)); +} + /// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has. const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET"; diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index b93b28b53..e09c91e86 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2980,6 +2980,101 @@ proptest! { let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); survive("mutated .ai variable library", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + + /// A `.ai` whose Objects on Path record and group are damaged or hostile. + #[test] + fn ai_objects_on_path_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, prop::sample::select(vec!['0', '1', '9', '-', '.', 'e', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U'])), 0..12)) { + let text = mutate_text(&on_path_data(&ON_PATH_IDS, &ON_PATH_IDS, 4, "4", "0.5", "", ["0", "1", "2"], true, 0), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + // Trusted, so that the layers read are built whatever the empty page shows. + survive("mutated .ai Objects on Path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } + + /// Objects on Path records of any shape: ids in any order, repeated or missing, any number of + /// objects, pivots and angles out of range or not finite, spacings, order tags that disagree, + /// groups nested or cut short, and versioned content left open. + #[test] + fn ai_objects_on_path_records_never_panic( + listed in prop::collection::vec(prop::sample::select(ON_PATH_IDS.to_vec()), 0..7), + drawn in prop::collection::vec(prop::sample::select(ON_PATH_IDS.to_vec()), 0..7), + members in 0usize..7, + pivot in prop::sample::select(vec!["0", "4", "8", "9", "-1", "2.5", "1e400"]), + rotation in prop::sample::select(vec!["0", "0.5235987755982988", "-3.2", "1e400", "-1e400", "1e300"]), + spacing in prop::sample::select(vec!["", "%_56.5 /Real (Spacing) ,\n", "%_0 /Bool (IsEquiSpaced) ,\n", "%_1 /Bool (Flip) ,\n"]), + tags in prop::array::uniform3(prop::sample::select(vec!["0", "1", "2", "-1", "7", "(x)", "1e400"])), + versioned in any::(), + nest in 0usize..3, + cut in 0usize..2_000, + ) { + let data = on_path_data(&listed, &drawn, members, pivot, rotation, spacing, tags, versioned, nest); + let cut_at = data.len().saturating_sub(cut); + let cut_at = (0..=cut_at).rev().find(|i| data.is_char_boundary(*i)).unwrap_or(0); + for data in [data.as_str(), &data[..cut_at]] { + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(data)].concat(); + survive("hostile Objects on Path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } + } +} + +/// The data [`ai_objects_on_path_never_panics`] damages, as it is, opens with its Objects on Path group. +#[test] +fn the_objects_on_path_fuzz_data_opens_live() { + let text = on_path_data(&ON_PATH_IDS, &ON_PATH_IDS, 4, "4", "0.5", "", ["0", "1", "2"], true, 0); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + // Trusted: the empty page it is read beside isn't compared with its art. + let (doc, warnings, _) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + let g = doc.layers.first().and_then(|l| l.children()).and_then(|c| c.first()).unwrap_or_else(|| panic!("{warnings:?}")); + assert!(g.extras().objects_on_path.is_some(), "{g:?}"); +} + +/// The ids of the objects of an Objects on Path group in the fuzz cases. +const ON_PATH_IDS: [&str; 5] = ["f-0001", "f-0002", "f-0003", "f-0004", "f-0005"]; + +/// Editing data with an Objects on Path record naming `listed` for `members` objects (Pivot +/// `pivot`, Rotation `rotation`, then the entries `more`) and a group of a path and squares whose +/// dictionaries give the ids `drawn` (the first the path's, the others with the order tags `tags` +/// in turn), inside `nest` more groups, in versioned content with a copy after it when `versioned` +/// is set. +#[allow(clippy::too_many_arguments)] +fn on_path_data( + listed: &[&str], + drawn: &[&str], + members: usize, + pivot: &str, + rotation: &str, + more: &str, + tags: [&str; 3], + versioned: bool, + nest: usize, +) -> String { + use vectorcraft_testkit::ai; + let options = ai::on_path_options(members, 0, 0.0, more) + .replace("%_0 /Int (PivotPosition)", &format!("%_{pivot} /Int (PivotPosition)")) + .replace("%_0 /Real (Rotation)", &format!("%_{rotation} /Real (Rotation)")); + let record = ai::on_path_record("Adobe Align On Path", listed, &options); // brand-ok: the record's name in the files + let mut group = String::from("0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n20 20 m\n100 80 L\n180 20 L\nS\n"); + for (i, id) in drawn.iter().enumerate() { + if i == 0 { + group += &ai::on_path_member(id, None, 0.0); + } else { + let x = 10.0 + 30.0 * i as f64; + let tag = tags.get(i - 1).map_or_else(|| (i - 1).to_string(), |t| t.to_string()); + group += &format!( + "0 1 1 0 1 0 0 Xa\n{}f\n%_/ArtDictionary :\n%_{tag} /Int (ConstraintArtOrderId) ,\n%_({id}) /String (Persistent ID) ,\n%_({rotation}) /String (BBAccumRotation) ,\n%_;\n%_\n", + ai::rect(x, 40.0, x + 10.0, 46.0) + ); + } + } + group += "U\n"; + for _ in 0..nest { + group = format!("0 Ae\nu\n{group}U\n"); + } + let art = if versioned { + format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{group}%AI17_End_Versioned_Content\n") + } else { + group + }; + ai::editing_data(200.0, 100.0, &format!("{record}{}", ai::layer("L", &art))) } /// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named, diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cb381d6fd..4791274bd 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -18,6 +18,8 @@ //! app wrote) the bounding box. //! - Variables: the variable library, XML nodes of the `/Document` dictionary, isn't read; a note //! gives how many variables and data sets it has ([`VariableLibrary`]). +//! - Objects on Path: a group whose objects' dictionaries carry ids, and a record of the +//! `/Document` dictionary naming them, read by [`on_path`]. //! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …` //! and the samples after `XI`. 8-bit CMYK samples keep their ink amounts in a CMYK TIFF, and //! an alpha channel that hides part of such an image becomes its opacity mask; other samples @@ -73,6 +75,7 @@ mod art; mod containers; mod directives; mod document; +mod on_path; mod ops; mod types; @@ -148,6 +151,8 @@ struct Reader<'a> { artboards: Vec<(String, Rect)>, /// The variables and data sets of the `/Document` dictionary's variable library. variables: VariableLibrary, + /// The Objects on Path records of the `/Document` dictionary and the ids of the objects. + on_path: on_path::OnPath, gradients: HashMap, gradient_def: Option, instance: Option, @@ -280,6 +285,7 @@ impl<'a> Reader<'a> { cropmarks: None, artboards: Vec::new(), variables: VariableLibrary::default(), + on_path: on_path::OnPath::default(), gradients: HashMap::new(), gradient_def: None, instance: None, diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 7e63b31da..892a8893c 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -224,6 +224,7 @@ impl<'a> Reader<'a> { /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the /// opacity mask it may carry. pub(super) fn art_dictionary(&mut self, o: &Obj) { + self.constraint_art(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) @@ -278,10 +279,11 @@ impl<'a> Reader<'a> { } } - /// The `/Document` dictionary: the artboards, and how many variables and data sets its variable - /// library has. + /// The `/Document` dictionary: the artboards, how many variables and data sets its variable + /// library has, and its Objects on Path records. pub(super) fn document(&mut self, o: &Obj) { variable_library(o, 0, &mut self.variables); + self.constraint_data(o); let Some(list) = find_entry(o, "ArtboardArray", 0) else { return }; for (i, ab) in list.items().enumerate().take(1000) { let (p1, p2) = (ab.nums("PositionPoint1"), ab.nums("PositionPoint2")); @@ -392,6 +394,7 @@ impl<'a> Reader<'a> { let to_doc = self.ensure_space(); let artboard = self.first_board(); drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); + super::on_path::apply(&mut self.doc, &self.on_path); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() @@ -549,7 +552,7 @@ fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { +pub(super) fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { if depth > 8 { return None; } diff --git a/crates/eps/src/import/ai/read/on_path.rs b/crates/eps/src/import/ai/read/on_path.rs new file mode 100644 index 000000000..909d61131 --- /dev/null +++ b/crates/eps/src/import/ai/read/on_path.rs @@ -0,0 +1,176 @@ +//! Objects on Path groups: a `u` … `U` group whose children's dictionaries carry a `Persistent ID` +//! (the path's with `KeyPath` 1, each object's with its `ConstraintArtOrderId` and the angle of its +//! frame, `BBAccumRotation`: radians, counterclockwise in art space), and a dictionary of the +//! `/Document` dictionary's `Constraint Data` array naming them: `Constraint Name`, `Constraint +//! Verison` 1, `Constraint IDs` (the path's id, then the objects' in their order along the path) +//! and `Constraint Dict` with the options (`PivotPosition` 0 to 8, `Rotation` in radians, +//! `IsEquiSpaced`, `StartRangeFraction`, `ObjectsOrder`, `ConstraintArtData`). The group opens +//! with its [`ObjectsOnPath`] record and its art where it is drawn. A record with options the +//! record can't hold (a `Spacing`, uneven spacing, a start other than 0, keys this reader doesn't +//! know), or one that doesn't match one group exactly, leaves the group a plain group. + +use std::collections::{HashMap, HashSet}; + +use vectorcraft_doc::objects_on_path::{Member, ObjectsOnPath, attachable_path}; + +use super::{Affine, Document, NodeId, NodeKind, Obj, Reader, V}; + +/// The constraint name of an Objects on Path record. +const NAME: &str = "Adobe Align On Path"; // brand-ok: the record's name in the files +/// Most records read from one file. +const MAX_RECORDS: usize = 1000; +/// Most objects one record names. +const MAX_IDS: usize = 10_000; +/// Most objects whose ids are kept. +const MAX_TAGGED: usize = 100_000; +/// The keys of `Constraint Dict` this reader reads; any other one leaves the group plain. +const KNOWN: [&str; 6] = ["StartRangeFraction", "ConstraintArtData", "ObjectsOrder", "PivotPosition", "IsEquiSpaced", "Rotation"]; + +/// An Objects on Path record of the `/Document` dictionary. +#[derive(Debug)] +pub(super) struct Record { + /// The path's id, then the objects' in their order along the path. + ids: Vec, + pivot: u8, + /// Degrees, counterclockwise. + rotate: f64, +} + +/// What an object's dictionary says of its part in an Objects on Path group. +#[derive(Clone, Copy, Debug, Default)] +struct Tag { + key_path: bool, + order: Option, + /// `BBAccumRotation`: radians, counterclockwise in art space. + angle: Option, +} + +/// The records and object ids read, for [`apply`]. +#[derive(Debug, Default)] +pub(super) struct OnPath { + records: Vec, + /// The objects with each `Persistent ID`. + ids: HashMap>, + tags: HashMap, +} + +/// The first number of entry `key`. +fn num(o: &Obj, key: &str) -> Option { + o.nums(key).first().copied() +} + +/// An Objects on Path record from a dictionary of the `Constraint Data` array: `None` for another +/// constraint, another version, or options the record can't hold. +fn record(o: &Obj) -> Option { + if o.text("Constraint Name").as_deref() != Some(NAME) || num(o, "Constraint Verison") != Some(1.0) { + return None; + } + let list = o.obj("Constraint IDs")?; + if list.entries.len() > MAX_IDS || !list.content.is_empty() { + return None; + } + let ids = list.entries.iter().map(|(_, v)| v.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text)).collect::>>()?; + let members = ids.len().checked_sub(1).filter(|n| *n > 0)?; + let options = o.obj("Constraint Dict")?; + if options.entries.iter().any(|(k, _)| !k.as_deref().is_some_and(|k| KNOWN.contains(&k))) || !options.content.is_empty() { + return None; + } + if num(options, "IsEquiSpaced") != Some(1.0) + || !matches!(options.get("StartRangeFraction").map(|_| num(options, "StartRangeFraction")), None | Some(Some(0.0))) + { + return None; + } + let pivot = num(options, "PivotPosition").filter(|p| p.fract() == 0.0 && (0.0..=8.0).contains(p))? as u8; + let rotate = num(options, "Rotation").map(f64::to_degrees).filter(|r| r.is_finite())?; + let order = options.obj("ObjectsOrder")?; + let order: Vec = order.entries.iter().flat_map(|(_, v)| v.iter().filter_map(V::num)).collect(); + if order.len() != members || order.iter().enumerate().any(|(i, n)| *n != i as f64) { + return None; + } + let data = options.obj("ConstraintArtData")?; + if data.entries.len() != members || data.items().count() != members || data.items().any(|d| !d.entries.is_empty() || !d.content.is_empty()) { + return None; + } + Some(Record { ids, pivot, rotate }) +} + +impl Reader<'_> { + /// The Objects on Path records of a `Constraint Data` array in the `/Document` dictionary `o`. + pub(super) fn constraint_data(&mut self, o: &Obj) { + let Some(list) = super::document::find_entry(o, "Constraint Data", 0) else { return }; + for d in list.items().take(MAX_RECORDS) { + if self.on_path.records.len() >= MAX_RECORDS { + return; + } + if let Some(r) = record(d) { + self.on_path.records.push(r); + } + } + } + + /// The `Persistent ID` an object's dictionary `o` gives the last object, with its part in an + /// Objects on Path group. + pub(super) fn constraint_art(&mut self, o: &Obj) { + let Some(uuid) = o.text("Persistent ID") else { return }; + let Some(id) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })).map(|n| n.id) else { return }; + if self.on_path.tags.len() >= MAX_TAGGED { + return; + } + let angle = o.text("BBAccumRotation").and_then(|s| s.trim().parse::().ok()).filter(|a| a.is_finite()); + let tag = Tag { key_path: num(o, "KeyPath") == Some(1.0), order: num(o, "ConstraintArtOrderId"), angle }; + self.on_path.tags.insert(id, tag); + self.on_path.ids.entry(uuid).or_default().push(id); + } +} + +/// Give each group that a record of `found` names exactly its [`ObjectsOnPath`] record. +pub(super) fn apply(doc: &mut Document, found: &OnPath) { + if found.records.is_empty() { + return; + } + // Where every object with an id is, found in one walk of the tree (setting a record doesn't + // move anything). + let at = doc.index_paths(found.tags.keys().copied()); + for r in &found.records { + // A record that doesn't match leaves the group as it was read. + let _ = apply_one(doc, found, &at, r); + } +} + +fn apply_one(doc: &mut Document, found: &OnPath, at: &HashMap>, r: &Record) -> Option<()> { + // The one object still in the document with the id `uuid` (a commented copy may have gone). + let resolve = |uuid: &str| -> Option<(NodeId, &Vec)> { + let mut live = found.ids.get(uuid)?.iter().filter_map(|id| at.get(id).map(|p| (*id, p))); + let one = live.next()?; + live.next().is_none().then_some(one) + }; + let (first, rest) = r.ids.split_first()?; + let (path, path_at) = resolve(first)?; + if !found.tags.get(&path)?.key_path { + return None; + } + let (_, group_at) = path_at.split_last()?; + let mut seen = HashSet::from([path]); + let mut members = Vec::with_capacity(rest.len()); + for (i, uuid) in rest.iter().enumerate() { + let (id, place) = resolve(uuid)?; + let tag = found.tags.get(&id)?; + if !seen.insert(id) || place.split_last().map(|(_, up)| up) != Some(group_at) || tag.key_path || tag.order != Some(i as f64) { + return None; + } + // The frame the object is drawn in: turned by its angle (the document's y points down) + // about the center of its bounds. + let angle = -tag.angle?; + let center = doc.node_at(place)?.geometric_bounds()?.center(); + members.push(Member { id, frame: Affine::rotate_about(angle, center), angle }); + } + let g = doc.node_at(group_at)?; + let NodeKind::Group { children, clip: false } = &g.kind else { return None }; + if g.extras().objects_on_path.is_some() || children.len() != seen.len() || children.iter().any(|c| !seen.contains(&c.id)) { + return None; + } + attachable_path(children.iter().find(|c| c.id == path)?)?; + let on_path = ObjectsOnPath { path, members, pivot: r.pivot, rotate: r.rotate }; + doc.node_mut_at(group_at)?.extras_mut().objects_on_path = Some(Box::new(on_path)); + Some(()) +} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index f20a07d49..be27b9f5c 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2478,3 +2478,145 @@ fn crop_marks_after_the_layers_are_not_art() { assert_eq!(art(&r.doc).len(), 1); assert_eq!(r.doc.layers.len(), 1); } + +/// The name of an Objects on Path record. +const ALIGN_ON_PATH: &str = "Adobe Align On Path"; // brand-ok: the record's name in the files +/// The ids of an Objects on Path group's path and its four objects. +const ON_PATH_IDS: [&str; 5] = [ + "0a6d1e52-0000-4000-8000-000000000001", + "0a6d1e52-0000-4000-8000-000000000002", + "0a6d1e52-0000-4000-8000-000000000003", + "0a6d1e52-0000-4000-8000-000000000004", + "0a6d1e52-0000-4000-8000-000000000005", +]; +const OPEN_PATH: &str = "20 20 m\n100 80 L\n180 20 L\nS\n"; +const CLOSED_PATH: &str = + "40 50 m\n40 66.57 66.86 80 100 80 c\n133.14 80 160 66.57 160 50 c\n160 33.43 133.14 20 100 20 c\n66.86 20 40 33.43 40 50 c\ns\n"; + +/// An Objects on Path group: the stroked `path` with the id `ids[0]`, then a 10 × 6 red square for +/// each other id, 50 pt apart from x 20, whose frame is turned `angle` radians. +fn on_path_group(path: &str, ids: &[&str], angle: f64) -> String { + use vectorcraft_testkit::ai::on_path_member; + let (first, rest) = ids.split_first().unwrap(); + let mut group = format!("0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n2 w\n{path}{}", on_path_member(first, None, 0.0)); + for (i, id) in rest.iter().enumerate() { + let x = 20.0 + 50.0 * i as f64; + group += &format!("{RED}{}f\n{}", rect_path(x, 40.0, x + 10.0, 46.0), on_path_member(id, Some(i), angle)); + } + group + "U\n" +} + +/// The group the layer of `body` holds first, and its Objects on Path record. +fn on_path(body: &str) -> (Arc, Option) { + let doc = read_ok(body).doc; + let g = art(&doc).first().cloned().unwrap(); + let rec = g.extras().objects_on_path.as_deref().cloned(); + (g, rec) +} + +/// An Objects on Path group opens as one, with its path, its objects in their order along the path, +/// Pivot and Rotate, and its art where the file draws it; the same art without the record opens as a +/// plain group. +#[test] +fn an_objects_on_path_group_opens_with_its_path_objects_and_options() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.5); + let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 0, std::f64::consts::FRAC_PI_6, "")); + let (g, rec) = on_path(&format!("{record}{}", layer(&group))); + let rec = rec.expect("an Objects on Path group"); + let kids = g.children().unwrap(); + assert_eq!(kids.len(), 5); + let NodeKind::Path { path, .. } = &kids.iter().find(|c| c.id == rec.path).unwrap().kind else { panic!("the path") }; + assert!(path.subpaths.first().is_some_and(|s| !s.closed), "the open path"); + let child = |id| kids.iter().find(|c| c.id == id).unwrap(); + let xs: Vec = rec.members.iter().map(|m| child(m.id).geometric_bounds().unwrap().x0).collect(); + assert_eq!(xs, [20.0, 70.0, 120.0, 170.0], "the objects in the file's order"); + for m in &rec.members { + let center = child(m.id).geometric_bounds().unwrap().center(); + assert_eq!((m.angle, m.frame), (-0.5, kurbo::Affine::rotate_about(-0.5, center)), "the frame turned counterclockwise"); + } + assert!((rec.rotate - 30.0).abs() < 1e-9, "{}", rec.rotate); + assert_eq!(rec.pivot, 0); + let (plain, none) = on_path(&layer(&group)); + assert!(none.is_none()); + let kinds = |g: &Node| g.children().unwrap().iter().map(|c| c.kind.clone()).collect::>(); + assert_eq!(kinds(&g), kinds(&plain), "the art stays where it is drawn"); +} + +/// A group on a closed path opens as an Objects on Path group too. +#[test] +fn an_objects_on_path_group_on_a_closed_path_opens_live() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, "")); + let (g, rec) = on_path(&format!("{record}{}", layer(&on_path_group(CLOSED_PATH, &ON_PATH_IDS, 0.0)))); + let rec = rec.expect("an Objects on Path group"); + let NodeKind::Path { path, .. } = &g.children().unwrap().iter().find(|c| c.id == rec.path).unwrap().kind else { panic!("the path") }; + assert!(path.subpaths.first().is_some_and(|s| s.closed), "the closed path"); + assert_eq!((rec.members.len(), rec.pivot, rec.rotate), (4, 4, 0.0)); +} + +/// A record the group can't be read from, or with options an Objects on Path group doesn't hold, +/// leaves the group a plain group with its art. +#[test] +fn an_objects_on_path_record_that_doesnt_fit_leaves_a_plain_group() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let [p, a, b, c, d] = ON_PATH_IDS; + let options = |more: &str| on_path_options(4, 4, 0.0, more); + let good = options(""); + let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0); + assert!(on_path(&format!("{}{}", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), layer(&group))).1.is_some()); + let cases: Vec<(&str, String, String)> = vec![ + ("the path's id is missing", on_path_record(ALIGN_ON_PATH, &["0a6d1e52-ffff", a, b, c, d], &good), group.clone()), + ("an object's id is missing", on_path_record(ALIGN_ON_PATH, &[p, a, b, c], &good), group.clone()), + ("an id twice", on_path_record(ALIGN_ON_PATH, &[p, a, b, b, d], &good), group.clone()), + ("objects out of order", on_path_record(ALIGN_ON_PATH, &[p, b, a, c, d], &good), group.clone()), + ("a spacing", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &options("%_56.5 /Real (Spacing) ,\n")), group.clone()), + ("an option this reader doesn't know", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &options("%_1 /Bool (Flip) ,\n")), group.clone()), + ( + "uneven spacing", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good.replace("1 /Bool (IsEquiSpaced)", "0 /Bool (IsEquiSpaced)")), + group.clone(), + ), + ( + "a later start", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good.replace("0 /Real (StartRangeFraction)", "0.25 /Real (StartRangeFraction)")), + group.clone(), + ), + ("a pivot past 8", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 9, 0.0, "")), group.clone()), + ("another constraint", on_path_record("Some Other Constraint", &ON_PATH_IDS, &good), group.clone()), + ( + "another version", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good).replace("%_1 /Int (Constraint Verison)", "%_2 /Int (Constraint Verison)"), + group.clone(), + ), + ("an object without its angle", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), group.replace("%_(0) /String (BBAccumRotation) ,\n", "")), + ( + "another child in the group", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), + group.replace("U\n", &format!("{}f\nU\n", rect_path(0.0, 0.0, 5.0, 5.0))), + ), + ("a path without its key", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), group.replace("%_1 /Bool (KeyPath) ,\n", "")), + ]; + for (why, record, group) in cases { + let (g, rec) = on_path(&format!("{record}{}", layer(&group))); + assert!(rec.is_none(), "{why}"); + assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{why}"); + assert!(g.children().unwrap().len() >= 5, "{why}: the art is kept"); + } +} + +/// The copy of the drawn group that follows it as versioned content is skipped: the group opens once, +/// live. +#[test] +fn an_objects_on_path_group_in_versioned_content_opens_once() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, "")); + let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0); + let copy = group.replace("/ConstraintGroup :\n128 /GroupType ,\n;\n", ""); + let versioned = format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{copy}%AI17_End_Versioned_Content\n"); + let doc = read_ok(&format!("{record}{}", layer(&versioned))).doc; + let kids = art(&doc); + assert_eq!(kids.len(), 1); + assert_eq!(kids[0].children().unwrap().len(), 5); + assert!(kids[0].extras().objects_on_path.is_some()); +} diff --git a/crates/testkit/src/ai.rs b/crates/testkit/src/ai.rs index c7bbcbff1..d94423ef6 100644 --- a/crates/testkit/src/ai.rs +++ b/crates/testkit/src/ai.rs @@ -1,6 +1,7 @@ //! Hand-written Illustrator editing data (the format `vectorcraft_eps`'s reader documents) and the //! EPS, `.ai` and PDF files that carry it, for import tests. Nothing here comes from files another -//! app wrote, except the key names that [`pdf`] uses and the XML node keys of [`variable_library`]. +//! app wrote, except the key names that [`pdf`] uses, the XML node keys of [`variable_library`] and +//! the keys of [`on_path_record`] and [`on_path_member`]. /// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop /// marks are the page), `body` (setup and layers), the trailer. @@ -77,6 +78,41 @@ pub fn variable_library(variables: &[&str], data_sets: &[&str]) -> String { ) } +/// The document data with an Objects on Path record: a `/Document` dictionary whose `Constraint +/// Data` array holds a record named `name` (version 1) for the objects with the ids `ids` (the +/// path's first), with the options `options` ([`on_path_options`]). +pub fn on_path_record(name: &str, ids: &[&str], options: &str) -> String { + let ids: String = ids.iter().map(|id| format!("%_({id}) /UnicodeString ,\n")).collect(); + format!( + "%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n%_/Dictionary :\n%_1 /Int (Constraint Verison) ,\n\ + %_({name}) /UnicodeString (Constraint Name) ,\n%_/Array :\n{ids}%_; (Constraint IDs) ,\n%_/Dictionary :\n{options}%_; (Constraint Dict) ,\n\ + %_; ,\n%_; (Constraint Data) ,\n%_; /Recorded ,\n%_;\n%AI9_EndDocumentData\n" + ) +} + +/// The options of an Objects on Path record for `members` objects: Pivot `pivot` (0 to 8), +/// Rotate `rotation` (radians), spaced evenly from the start of the path, then `more` entries. +pub fn on_path_options(members: usize, pivot: u8, rotation: f64, more: &str) -> String { + let data = "%_/Dictionary :\n%_; ,\n".repeat(members); + let order: String = (0..members).map(|i| format!("%_{i} /Int ,\n")).collect(); + format!( + "%_0 /Real (StartRangeFraction) ,\n%_/Array :\n{data}%_; (ConstraintArtData) ,\n%_/Array :\n{order}%_; (ObjectsOrder) ,\n\ + %_{pivot} /Int (PivotPosition) ,\n%_1 /Bool (IsEquiSpaced) ,\n%_{rotation} /Real (Rotation) ,\n{more}" + ) +} + +/// The dictionary of an object of an Objects on Path group, with the id `id`: the path's +/// (`order` `None`), or the object's at `order` along the path whose frame is turned `angle` +/// radians counterclockwise. +pub fn on_path_member(id: &str, order: Option, angle: f64) -> String { + match order { + None => format!("%_/ArtDictionary :\n%_({id}) /String (Persistent ID) ,\n%_1 /Bool (KeyPath) ,\n%_;\n%_\n"), + Some(i) => format!( + "%_/ArtDictionary :\n%_{i} /Int (ConstraintArtOrderId) ,\n%_({id}) /String (Persistent ID) ,\n%_({angle}) /String (BBAccumRotation) ,\n%_;\n%_\n" + ), + } +} + /// A document of 200 × 100 pt with a bit of everything the reader reads. A layer `Art` that shows: /// a group `Pair` (a red square `Red` and a hidden one), a compound path `Ring`, a clipping group /// `Window` (a grey square seen through a smaller one), what [`page_ps`] and [`page_pdf`] draw. A diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 10ad24c79..de7e1cfce 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -84,7 +84,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, 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), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), 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), guides, and every artboard where it is. 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 an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). 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, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's 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 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); the page's drawing of it (often in pieces) is left out. 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. 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, 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 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). 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). 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, 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), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), 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), guides, and every artboard where it is. 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 an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). 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, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's 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 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); the page's drawing of it (often in pieces) is left out. 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. 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, 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 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). 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). 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. ## Format by format diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4712f9749..7fd540e93 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1063,6 +1063,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`). PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9). A `.ai` or PDF whose editing data holds a variable library opens with a warning that its variables and data sets weren't read, with how many of each it has, whether its layers are read or it comes in as its PDF part (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). + A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).