From a90ffe2a5fd0bad447468207b38163c768b84ffa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 00:23:25 -0800 Subject: [PATCH] .ai import: object tags open as each object's URL and data `.ai` and EPS editing-data import now reads object tags (`/identifier (string) XT`) and applies each one to the object before it, whatever kind of object it is. The `AdobeURL` tag sets the object's URL. Every other tag is added to the object's data in order, up to 64 per object. A tag with no object right before it, with an empty identifier, or with an identifier or string longer than 64 KiB is ignored, and so are tags in a pattern's or brush's art. A clipping path keeps its own tags; the copy that paints its fill gets none. Before, a tag on art that shows was an operator the reader did not know, so it stopped the editing data from being read, and brush art holding a tag was not read, so the brushes using it were left out. An image's tags follow the `XH` that ends its data, and the state written after a group or a gradient mesh is still theirs when their tags come first. When a drawn look stands for an object whose style is not found, the look keeps the object's note, URL and data, and a live blend keeps those of its drawn steps. --- crates/engine/tests/import_fuzz.rs | 6 + crates/eps/src/import/ai/read/containers.rs | 2 + crates/eps/src/import/ai/read/ops.rs | 62 +++++++- crates/eps/src/import/ai/read/types.rs | 11 ++ crates/eps/src/import/ai/tests.rs | 166 ++++++++++++++++++++ crates/eps/src/import/tests_illustrator.rs | 20 +++ docs/target-app-parity.md | 1 + 7 files changed, 266 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f21f3b1ca..139268295 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3834,6 +3834,12 @@ fn arb_ai_token() -> impl Strategy { "\n%AI3_Note:\n", "\n%AI5_NoteMore:more\n", "\n%%Note:old\n", + // Object tags (version 7 §7). + "XT", + "/AdobeURL (https://example.com) XT", + "/tag () XT", + "/ (x) XT", + "(x) (y) XT", "w", "J", "j", diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 2273f087b..7ddd62323 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -573,6 +573,8 @@ impl<'a> Reader<'a> { let mut blend = Node::new(self.doc.alloc_id(), kind); (blend.name, blend.visible, blend.locked) = (steps.name.take(), steps.visible, steps.locked); (blend.opacity, blend.blend, blend.isolate, blend.knockout) = (steps.opacity, steps.blend, steps.isolate, steps.knockout); + // Its note, URL and data (object tags) too. + blend.extras_mut().attrs = steps.extras_mut().attrs.take(); f.children.push(blend); } None => { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 5d47ce16f..38acc590e 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1,6 +1,9 @@ use super::containers::close; use super::document::slot_of; -use super::types::{BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, Place, line_width}; +use super::types::{ + BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, MAX_TAG_TEXT, MAX_TAGS, Place, URL_TAG, + line_width, +}; use super::{ Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Gradient, GradientDef, Graph, HashMap, HashSet, Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, StrokeLayer, V, obj, paint, @@ -73,6 +76,36 @@ impl<'a> Reader<'a> { } } + /// `/identifier (string) XT` (version 7 specification §7): a tag on the object before it. The + /// `AdobeURL` tag sets the object's URL, and every other tag is added to the object's data in + /// order, up to [`MAX_TAGS`]. A tag with no object before it, with an empty identifier, or with + /// an identifier or string longer than [`MAX_TAG_TEXT`] bytes is ignored, and so are tags in a + /// pattern's or brush's art, which is not an object of the document. A clipping path's tags are + /// its own, not those of the copy that paints its fill. + fn object_tag(&mut self, vals: &[V], hidden: bool) { + let [.., V::Name(id), V::Str(value)] = vals else { + self.note_unknown("XT", hidden); + return; + }; + if id.is_empty() || id.len() > MAX_TAG_TEXT || value.len() > MAX_TAG_TEXT || !self.after_object || self.pattern.is_some() { + return; + } + let (id, value) = (id.clone(), obj::text(value)); + let Some(kids) = self.frames.last_mut().map(|f| &mut f.children) else { return }; + let Some(mut at) = kids.len().checked_sub(1) else { return }; + if at > 0 && kids.get(at).is_some_and(|n| self.clip_fills.contains(&n.id)) { + at -= 1; + } + let Some(n) = kids.get_mut(at) else { return }; + n.edit_attrs(|a| { + if id == URL_TAG { + a.url = value; + } else if a.data.len() < MAX_TAGS { + a.data.push((id, value)); + } + }); + } + pub(super) fn op(&mut self, w: &str, hidden: bool) -> Result<(), String> { if self.blend_record.is_some() { self.end_blend(); @@ -211,6 +244,12 @@ impl<'a> Reader<'a> { Some(0.0) => self.gs.show_center = None, _ => {} }, + // Object tags (§7): `/identifier (string) XT` after the object it tags. The state written + // after a group or a mesh is still theirs after its tags. + "XT" => { + self.object_tag(&vals, hidden); + (self.group_ended, self.mesh_ended) = (group_ended, mesh_ended); + } "w" => { if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 0.0) { self.gs.width = *v; @@ -497,7 +536,9 @@ impl<'a> Reader<'a> { /// `LB`, and can have the stroke style between its `Xy` and `Xd`: that keeps the layer's state, /// and what a mesh or blend before it had (`mesh_undo`). fn state_block_continues(&mut self, w: &str) { - if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { + // An object's tags (`XT`) follow it one after another, and an image's follow the `XH` that + // ends its data. + if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH") { return; } self.after_object = false; @@ -714,6 +755,23 @@ impl<'a> Reader<'a> { look.name = object.name.clone().or(look.name.take()); look.visible = object.visible; look.locked = object.locked; + // The object's note, Show Center, URL and data (object tags) stay with the look that stands + // for it, in place of the look's own where the object has them. + if let Some(a) = object.extras().attrs.as_deref().cloned() { + look.edit_attrs(|l| { + if a.show_center.is_some() { + l.show_center = a.show_center; + } + if !a.url.is_empty() { + l.url = a.url; + } + if !a.note.is_empty() { + l.note = a.note; + } + let room = MAX_TAGS.saturating_sub(l.data.len()); + l.data.extend(a.data.into_iter().take(room)); + }); + } if !hidden { self.drawn_looks += 1; } diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 39b77cf74..0d35458fc 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -43,6 +43,17 @@ pub(super) const MAX_PATTERNS: usize = 4096; /// Most characters of a path's note read (the version 7 specification allows 254, §5.3.1). pub(super) const MAX_NOTE: usize = 1024; +/// Most object tags (`XT`) kept in one object's data. SVG import keeps the same number of `data-*` +/// attributes per element. +pub(super) const MAX_TAGS: usize = 64; + +/// The longest identifier or string an object tag (`XT`) may have, in bytes: SVG import keeps +/// another application's attribute values up to the same length. +pub(super) const MAX_TAG_TEXT: usize = 64 * 1024; + +/// The object tag that holds the object's URL (version 7 specification §7). +pub(super) const URL_TAG: &str = "AdobeURL"; + #[derive(Clone, Debug)] pub(crate) struct GState { pub(crate) fill: Paint, diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 5fe19eac8..7639cbe83 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -229,6 +229,163 @@ fn a_note_or_ap_in_a_skipped_section_does_not_reach_the_art_after_it() { assert!(!n.shows_center()); } +/// A node's URL and data (object tags), empty when it has no attributes. +fn tags(n: &Node) -> (String, Vec<(String, String)>) { + n.extras().attrs.as_deref().map(|a| (a.url.clone(), a.data.clone())).unwrap_or_default() +} + +fn tag(id: &str, value: &str) -> (String, String) { + (id.to_string(), value.to_string()) +} + +/// `/identifier (string) XT` tags the object before it (version 7 specification §7): `AdobeURL` +/// is its URL, and other tags go to its data in order, an empty string included. Paths, groups, +/// compound paths, text objects and images take tags. +#[test] +fn object_tags_are_kept_on_the_object_before_them() { + let [a, b, c, d] = [0.0, 20.0, 40.0, 60.0].map(|x| rect_path(x, 0.0, x + 10.0, 10.0)); + let text = "0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Hi) Tx\nTO\n"; + let body = format!( + "{a}N\n/part_no (A-12) XT\n/AdobeURL (https://example.com/a) XT\n/part_no (A-13) XT\n\ + u\n{b}f\nU\n/hand () XT\n*u\n{c}f\n{d}f\n*U\n/shape (two \\(parts\\)) XT\n{text}/AdobeURL (https://example.com/t) XT\n{a}f\n" + ); + let r = read(stream7(&layer7("L", &body)).as_bytes()).unwrap(); + let got: Vec<_> = art(&r.doc).iter().map(|n| tags(n)).collect(); + assert_eq!( + got, + [ + ("https://example.com/a".to_string(), vec![tag("part_no", "A-12"), tag("part_no", "A-13")]), + (String::new(), vec![tag("hand", "")]), + (String::new(), vec![tag("shape", "two (parts)")]), + ("https://example.com/t".to_string(), vec![]), + (String::new(), vec![]), + ] + ); + assert!(r.warnings.is_empty() && r.unread.is_empty(), "{:?} {:?}", r.warnings, r.unread); + // The group's and the compound path's paths keep no tags of their own. + for n in &art(&r.doc)[1..3] { + assert!(n.children().unwrap().iter().all(|k| k.extras().attrs.is_none())); + } + // A later `AdobeURL` replaces an earlier one. + let body = format!("{a}f\n/AdobeURL (https://example.com/1) XT\n/AdobeURL (https://example.com/2) XT\n"); + assert_eq!(tags(&art(&read7(&layer7("L", &body)).doc)[0]).0, "https://example.com/2"); +} + +/// A tag with no object right before it (at a layer's start, after a state change, after a group +/// opens) and a tag with an empty identifier tag nothing; one with operands of another kind is an +/// operator the reader doesn't know. +#[test] +fn object_tags_without_an_object_before_them_tag_nothing() { + let a = rect_path(0.0, 0.0, 10.0, 10.0); + for body in + [format!("/first (x) XT\n{a}f\n"), format!("{a}f\n1 w\n/late (x) XT\n"), format!("u\n/inside (x) XT\n{a}f\nU\n"), format!("{a}f\n/ (x) XT\n")] + { + let doc = read7(&layer7("L", &body)).doc; + let mut all = art(&doc); + all.extend(all.iter().flat_map(|n| n.children().cloned().unwrap_or_default()).collect::>()); + assert!(all.iter().all(|n| n.extras().attrs.is_none()), "{body}"); + } + for bad in ["(x) (y) XT\n", "/id 3 XT\n", "XT\n"] { + let err = read(stream7(&layer7("L", &format!("{a}f\n{bad}"))).as_bytes()).unwrap_err(); + assert!(err.contains("operators VectorCraft doesn't know"), "{bad}: {err}"); + } + // Outside any layer too. + let err = read(stream7(&format!("(x) (y) XT\n{}", layer7("L", &format!("{a}f\n")))).as_bytes()).unwrap_err(); + assert!(err.contains("operators VectorCraft doesn't know"), "{err}"); +} + +/// A clipping path's tags are its own, not those of the copy that paints its fill, and a clip +/// group's tags after `Q` are the group's. One object keeps at most 64 tags in its data. +#[test] +fn object_tags_on_clipping_paths_and_the_tag_limit() { + let body = format!( + "0 Ae\nq\n{RED}{}W\nf\n/edge (clip) XT\n{}f\nQ\n/group (clipped) XT\n", + rect_path(5.0, 5.0, 25.0, 25.0), + rect_path(0.0, 0.0, 30.0, 30.0) + ); + let doc = read_ok(&layer(&body)).doc; + let top = &art(&doc); + assert_eq!(top.len(), 1); + assert_eq!(tags(&top[0]).1, [tag("group", "clipped")]); + let tagged: Vec<_> = top[0].children().unwrap().iter().filter(|k| !tags(k).1.is_empty()).map(|k| k.kind.clone()).collect(); + assert_eq!(tagged.len(), 1, "{tagged:?}"); + assert!(matches!(tagged[0], NodeKind::Path { clipping: true, .. }), "{tagged:?}"); + let many: String = (0..70).map(|i| format!("/t{i} ({i}) XT\n")).collect(); + let body = format!("{}f\n{many}/AdobeURL (https://example.com) XT\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let (url, data) = tags(&art(&read_ok(&layer(&body)).doc)[0]); + assert_eq!(data.len(), 64); + assert_eq!(data.last(), Some(&tag("t63", "63"))); + assert_eq!(url, "https://example.com", "the URL is kept past the limit"); + // A tag whose identifier or string is longer than 64 KiB is ignored. + let long = "x".repeat(64 * 1024 + 1); + let body = format!("{}f\n/{long} (a) XT\n/b ({long}) XT\n/c (ok) XT\n", rect_path(0.0, 0.0, 10.0, 10.0)); + assert_eq!(tags(&art(&read_ok(&layer(&body)).doc)[0]).1, [tag("c", "ok")]); +} + +/// An image takes the tags after it, as any other object does. +#[test] +fn object_tags_after_an_image_are_its_own() { + let mut data = image("[ 10 0 0 10 50 90 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3); + let at = data.windows(6).position(|w| w == b"N\r\nLB\r").unwrap(); + data.splice(at..at, b"/AdobeURL (https://example.com/i) XT\r\n/sku (I-1) XT\r\n".iter().copied()); + let r = read(&data).unwrap(); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Image(_)), "{a:#?}"); + assert_eq!(tags(&a[0]), ("https://example.com/i".to_string(), vec![tag("sku", "I-1")])); +} + +/// The state written after a group's end or a gradient mesh's `/End X!` is still theirs when the +/// object's tags come first. +#[test] +fn object_tags_before_a_group_or_mesh_state_leave_it_theirs() { + let body = format!("{RED}0 Ae\nu\n0 0.5 0 0 0 Xy\n{}f\nU\n/hand (x) XT\n0 0 Xd\n6 () XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let doc = read_ok(&layer(&body)).doc; + assert_eq!((art(&doc)[0].opacity, tags(&art(&doc)[0]).1), (0.5, vec![tag("hand", "x")])); + let corner = + |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| format!("%_[1 1 1 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1); + let mesh = format!( + "/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n", + corner(0, 0, (0, 10), (10, 0)), + corner(0, 40, (10, 40), (0, 30)), + corner(40, 40, (40, 30), (30, 40)), + corner(40, 0, (30, 0), (40, 10)), + ); + let doc = read_ok(&layer(&format!("{mesh}/hand (y) XT\n1 0.35 0 0 0 Xy\n0 0 Xd\n6 () XW\n"))).doc; + let (m, l) = (&art(&doc)[0], &doc.layers[0]); + assert!(matches!(m.kind, NodeKind::Mesh(_)), "{m:#?}"); + assert_eq!((m.opacity, m.blend, tags(m).1), (0.35, BlendMode::Multiply, vec![tag("hand", "y")])); + assert_eq!((l.opacity, l.blend), (1.0, BlendMode::Normal), "not the layer's"); +} + +/// The tags of an object whose drawn look stands for it (its style not found) stay with the look. +#[test] +fn a_drawn_look_keeps_its_objects_tags() { + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let body = format!( + "0 Ae\nu\n{RED}{}f\nU\n0 0 Xd\n6 () XW\n%_{hidden_rect}n\n%_/AdobeURL (https://example.com/n) XT\n%_/sku (N-1) XT\n1 (Gone style) XW\n", + rect_path(0.0, 0.0, 10.0, 10.0), + ); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(tags(&a[0]), ("https://example.com/n".to_string(), vec![tag("sku", "N-1")])); + // A look with tags of its own keeps them, and takes the object's after them. + let a = art(&read_ok(&layer(&body.replace("U\n0 0 Xd", "U\n/own (L) XT\n0 0 Xd"))).doc); + assert_eq!(tags(&a[0]), ("https://example.com/n".to_string(), vec![tag("own", "L"), tag("sku", "N-1")])); +} + +/// Tags in a brush's art don't keep it from being read, and stay out of the art: it isn't an object +/// of the document. +#[test] +fn a_brushs_art_with_tags_is_read_without_them() { + let art_ = format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n0 g\n{}f\n/sku (D-1) XT\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 4.0, 4.0)); + let r = read_ok(&format!("{art_}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(""))); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Scatter(s) = brush_named(&lib, "Spray") else { panic!("scatter: {:?}", r.warnings) }; + assert_eq!(s.art.geometric_bounds().map(|b| (b.width(), b.height())), Some((4.0, 4.0))); + assert!(s.art.extras().attrs.is_none(), "{:?}", s.art.extras().attrs); +} + #[test] fn paths_colours_and_strokes() { let art_ = format!( @@ -4552,6 +4709,15 @@ fn a_blend_comes_in_as_a_live_blend() { assert!(!s.warnings.iter().any(|w| w == super::blend::DRAWN), "{:?}", s.warnings); } +/// The tags after a blend's drawn steps are the live blend's. +#[test] +fn a_live_blend_keeps_the_tags_of_its_drawn_steps() { + let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B'])); + let (blend, warnings) = read_blend(&body.replace("\nU\n9 () XW\n0 Ae\n", "\nU\n/sku (B-1) XT\n9 () XW\n0 Ae\n")); + assert!(matches!(blend.kind, NodeKind::Blend { .. }), "a live blend: {:?} {warnings:?}", blend.kind); + assert_eq!(tags(&blend).1, [tag("sku", "B-1")]); +} + /// A spine written from the right key to the left one keeps the keys in their order on it (the /// red key first), sitting on its last and first anchors. #[test] diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index d4ae4db83..950c989f2 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -433,6 +433,26 @@ fn a_legacy_files_paths_keep_their_notes_and_show_center() { assert!(kids[0].shows_center() && !kids[1].shows_center()); } +/// A version 8 file's object tags open as each object's URL and data. +#[test] +fn a_legacy_files_object_tags_open_as_urls_and_data() { + let body = format!( + "%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Tags) Ln\n0 A\n0 0 0 1 k\n{}\n/AdobeURL (https://example.com) XT\n/sku (B-7) XT\n\ + u\n{}\nU\n/part () XT\nLB\n%AI5_EndLayer--\n", + tri(10), + tri(40) + ); + let r = import(&ai(AI8, "", &body)).unwrap(); + let kids = r.document.layers[0].children().unwrap(); + let attrs: Vec<_> = kids.iter().map(|n| n.extras().attrs.as_deref().map(|a| (a.url.clone(), a.data.clone()))).collect(); + assert_eq!( + attrs, + [Some(("https://example.com".into(), vec![("sku".into(), "B-7".into())])), Some((String::new(), vec![("part".into(), String::new())]))], + "{:?}", + r.warnings + ); +} + #[test] fn unbalanced_and_runaway_groups_are_read_safely() { // Ends without a beginning end nothing; groups left open hold the rest. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bfeee6c42..bb4b36bd1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1204,6 +1204,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` or EPS opened from its editing data, 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`). `.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`). `.ai` and EPS editing-data import keeps each path's Attributes panel note (`%AI3_Note:`, `%%Note:`, more lines in `%AI5_NoteMore:`, up to 1,024 characters) and Show Center (`1 Ap`); a note or Show Center applies to every path after it until the next one, and a note before a group or compound path is not theirs (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). + `.ai` and EPS editing-data import reads object tags (`/identifier (string) XT`) on the object before them, whatever kind of object it is: `AdobeURL` becomes the object's URL, and every other tag is kept in the object's data in order, up to 64 per object; a tag longer than 64 KiB, and tags in a pattern's or brush's art, are ignored. A tag on art that shows no longer stops the editing data from being read, and brush art that holds tags is read (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose PDF part can't be read (a stream the import refuses) opens from its editing data, with the PDF part's error as its first warning; File › Place places its first artboard the same way, and Paste of it as PDF data with `editingData: true` takes every object (part of #4366; `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).