From ddb64fceb654ae6c73c71eff2205dbcdd8196bfa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 10:04:03 -0800 Subject: [PATCH] .ai import: objects other than type keep some Distort & Transform effects live An art style with a Distort & Transform effect opened every object but type as its drawn look. Objects other than type now open as themselves with these effects live: - Any object takes a Tweak that moves nothing and Pucker & Bloat at 0, beside the effects it already took. - A path, compound path or group takes Transform without copies, or a Free Distort whose corners make a parallelogram, when it is the only effect. Neither the style nor anything in the object may paint with a gradient or pattern. Everything in the object must be a path, compound path or group without a clip, an opacity mask or effects of its own. When Transform scales, rotates or reflects, nothing in the object may be stroked. - An unstroked path or compound path whose segments are all straight takes Twist when it is the only effect. Other objects keep the drawn look, as do Zig Zag, Pucker & Bloat of another amount, a Free Distort with other corners and Transform copies. Docs: docs/file-format-parity.md, docs/target-app-parity.md. --- crates/engine/tests/import_fuzz.rs | 16 +- crates/eps/src/import/ai/read/ops.rs | 137 +++++++++++++++- crates/eps/src/import/ai/style.rs | 77 ++++++--- crates/eps/src/import/ai/style/tests.rs | 42 +++++ crates/eps/src/import/ai/tests.rs | 200 ++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 1 + 7 files changed, 450 insertions(+), 25 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 85c85dfa5..8671e165d 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4547,8 +4547,9 @@ proptest! { /// Art styles with a Distort & Transform effect (Transform, Free Distort, Pucker & Bloat, Roughen, /// Tweak, Twist, Zig Zag) whose dictionary holds any of its entries, and unknown ones, as any kind, - /// with any value (huge, tiny, not a number, a string), repeated or left out, on a path and on type, - /// on the whole object or on its fill. + /// with any value (huge, tiny, not a number, a string), repeated or left out, on a path, on type and + /// on a group nested any number of levels around a curved path, stroked or not, on the whole object + /// or on its fill. #[test] fn ai_distort_effects_never_panic( name in prop::sample::select(vec![ @@ -4575,6 +4576,8 @@ proptest! { ], ), 0..24), on_fill in any::(), + depth in 0usize..300, + stroked in any::(), ) { use vectorcraft_testkit::ai; let keys: String = entries.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect(); @@ -4589,10 +4592,15 @@ proptest! { }; let styles = format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{root}%AI9_EndArtStyles\n"); let hidden = ai::rect(20.0, 20.0, 80.0, 60.0).replace('\n', "\n%_"); + let paint = if stroked { "0 0 1 0 K\n2 w\nB\n" } else { "f\n" }; + let curve = format!("0 0 1 0 k\n20 70 m\n20 90 80 90 80 70 c\n80 50 20 50 20 70 c\n{paint}"); + let nested = format!("0 Ae\n{}{curve}{}", "u\n".repeat(depth + 1), "U\n".repeat(depth + 1)).replace('\n', "\n%_"); let art = format!( - "0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{hidden}f\n1 (Fx) XW\nu\n/AI11Text :\n1 /FreeUndo ,\n;\n0 0 1 0 k\n{}f\nU\n1 (Fx) XW\n", + "0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{hidden}f\n1 (Fx) XW\nu\n/AI11Text :\n1 /FreeUndo ,\n;\n0 0 1 0 k\n{}f\nU\n1 (Fx) XW\n\ + 0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{nested}1 (Fx) XW\n", ai::rect(20.0, 20.0, 80.0, 60.0), - ai::rect(100.0, 20.0, 180.0, 60.0) + ai::rect(100.0, 20.0, 180.0, 60.0), + ai::rect(20.0, 50.0, 80.0, 90.0) ); let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", &art))); survive("distort art style", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 0979f047c..0b27ee840 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -895,12 +895,76 @@ fn placed_paints(look: &Node, out: &mut HashMap, depth: usize) { } } +/// Is `n` a path, a compound path or a group without a clip, with no opacity mask, the objects +/// whose outline the effects that move it (Transform, Twist, Free Distort) are read on? Images, +/// type (and a text object's slot, filled with type placed where the page has it) and live objects +/// draw more than their outline, and a mask stays where it is. +fn outlines_only(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { clip: false, .. }) + && n.extras().mask.is_none() + && slot_of(n.name.as_deref()).is_none() +} + +/// Does `n`, inside an object whose outline an effect moves, move with it: is it and everything in +/// it [`outlines_only`], painting with no gradient or pattern (which stay where they are placed) +/// and with no effects of its own (drawn after the outer effect, not before)? Deeper than +/// [`MAX_DEPTH`] counts as no. +fn moves_alike(n: &Node, depth: usize) -> bool { + depth <= MAX_DEPTH + && outlines_only(n) + && n.appearance.effects.is_empty() + && n.appearance.items.iter().all(|i| i.effects().is_empty() && !matches!(i.paint(), Paint::Gradient(_) | Paint::Pattern { .. })) + && n.children().into_iter().flatten().all(|c| moves_alike(c, depth + 1)) +} + +/// Does `n` or anything in it (to [`MAX_DEPTH`]) have a stroke that paints? +fn strokes(n: &Node, depth: usize) -> bool { + depth > MAX_DEPTH + || n.appearance.items.iter().any(|i| !i.is_fill() && !i.paint().is_none()) + || n.children().into_iter().flatten().any(|c| strokes(c, depth + 1)) +} + +/// Are all of path or compound path `n`'s segments straight, each anchor's handles on it? +fn straight(n: &Node) -> bool { + match &n.kind { + NodeKind::Path { path, .. } => path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.h_in == a.p && a.h_out == a.p), + NodeKind::Compound { children, .. } => children.iter().all(|c| matches!(c.kind, NodeKind::Path { .. }) && straight(c)), + _ => false, + } +} + +/// Is `object` one that style `st`, whose effect moves the outline, is read on? It and everything +/// in it [`moves_alike`], and the style paints no gradient. Twist is read only on an unstroked path +/// or compound path with straight segments, and Transform that scales, turns or reflects only when +/// nothing is stroked (its reference point lies on the bounds without strokes). +fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool { + use crate::import::ai::style::StyleItem; + let stroked = st.items.iter().any(|i| matches!(i, StyleItem::Stroke { paint, .. } if !paint.is_none())) + || object.children().into_iter().flatten().any(|c| strokes(c, 1)); + !st.items.iter().any(|i| matches!(i, StyleItem::Gradient { .. })) + && outlines_only(object) + && object.children().into_iter().flatten().all(|c| moves_alike(c, 1)) + && st.effects.iter().all(|e| match e.id.as_str() { + "distort.twist" => !stroked && matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && straight(object), + "distort.transform" => { + let p = &e.params; + !stroked + || (p["scaleH"].as_f64() == Some(100.0) + && p["scaleV"].as_f64() == Some(100.0) + && p["rotate"].as_f64() == Some(0.0) + && p["reflectX"].as_bool() == Some(false) + && p["reflectY"].as_bool() == Some(false)) + } + _ => true, + }) +} + /// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names. const PATTERN_KEY: &str = "\u{1}pattern "; /// `object`'s appearance with style `st`, its drawn look `look`: `None` when the style isn't one -/// this reads, asks a group for fills or strokes, or has Pathfinder effects on anything but a group -/// with no fills or strokes. +/// this reads, asks a group for fills or strokes, has Pathfinder effects on anything but a group +/// with no fills or strokes, or moves the outline of an object it isn't read on ([`moves_with`]). fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, paints: &StylePaints) -> Option { use crate::import::ai::style::StyleItem; // A text object's slot (an empty group standing for it) takes the style as the type will. @@ -910,6 +974,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) || (st.pathfinder && (slot || !st.items.is_empty() || object.is_compound_shape() || !matches!(object.kind, NodeKind::Group { clip: false, .. }))) + || (!slot && st.moves_outline() && !moves_with(object, st)) { return None; } @@ -1038,3 +1103,71 @@ fn strip(n: &mut Node, hidden: &HashSet) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::ImageObject; + use vectorcraft_geom::shapes; + + fn square(id: u64, fill: Paint) -> Arc { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(fill, Paint::None, 0.0))) + } + + #[test] + fn only_outlines_without_gradients_move_alike() { + // GUARDS: an image's pixels and a placed gradient stay behind when an effect moves the outline. + let solid = || square(2, Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); + assert!(moves_alike(&Node::group(NodeId(1), vec![solid(), solid()]), 1)); + let gradient = square(3, Paint::Gradient(Box::new(vectorcraft_color::gradient::GradientPaint::new(Gradient::default())))); + assert!(!moves_alike(&Node::group(NodeId(1), vec![solid(), gradient]), 1)); + let image = + ImageObject { key: "a".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let image = Arc::new(Node::new(NodeId(4), NodeKind::Image(Box::new(image)))); + assert!(!moves_alike(&Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(5), vec![image.clone()]))]), 1)); + assert!(!outlines_only(&image)); + // A text object's slot, and a member with an opacity mask. + let mut slot = Node::group(NodeId(6), vec![]); + slot.name = Some(format!("{}0", super::super::document::TEXT_SLOT)); + assert!(!moves_alike(&Node::group(NodeId(1), vec![solid(), Arc::new(slot)]), 1)); + let mut masked = Node::clone(&solid()); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(7), vec![]), true))); + assert!(!moves_alike(&Node::group(NodeId(1), vec![solid(), Arc::new(masked)]), 1)); + // A member with an effect of its own. + let mut turned = Node::clone(&solid()); + turned.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.twist".into(), + params: serde_json::json!({"angle": 30.0}), + visible: true, + }); + assert!(!moves_alike(&Node::group(NodeId(1), vec![solid(), Arc::new(turned)]), 1)); + // A member painting a pattern, whose tiles stay where they are, and a clipping group. + let tiled = square(8, Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }); + assert!(!moves_alike(&Node::group(NodeId(1), vec![solid(), tiled]), 1)); + let mut clipped = Node::group(NodeId(9), vec![solid(), solid()]); + if let NodeKind::Group { clip, .. } = &mut clipped.kind { + *clip = true; + } + assert!(!moves_alike(&Node::group(NodeId(1), vec![Arc::new(clipped)]), 1)); + } + + #[test] + fn an_outline_moves_with_its_effect_only_on_outlines() { + // GUARDS: a top-level image or masked path took an effect that moves the outline. + use crate::import::ai::style::ArtStyle; + let effect = |id: &str, params| vectorcraft_doc::Effect { id: id.into(), params, visible: true }; + let style = |e| ArtStyle { effects: vec![e], ..ArtStyle::default() }; + let twist = style(effect("distort.twist", serde_json::json!({"angle": 30.0}))); + let path = square(2, Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); + assert!(moves_with(&path, &twist)); + let mut masked = Node::clone(&path); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(7), vec![]), true))); + assert!(!moves_with(&masked, &twist)); + let image = + ImageObject { key: "a".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let image = Node::new(NodeId(4), NodeKind::Image(Box::new(image))); + let distort = style(effect("distort.freeDistort", serde_json::json!({"corners": [[0.1, 0.0], [1.1, 0.0], [1.0, 1.0], [0.0, 1.0]]}))); + assert!(!moves_with(&image, &distort)); + assert!(moves_with(&path, &distort)); + } +} diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 8c6cd1f3d..0e1a52a6f 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -138,7 +138,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 and the Path effects (Offset Path, Outline Object, Outline Stroke) on the object + /// 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 /// (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, @@ -157,6 +158,13 @@ pub(super) struct ArtStyle { pub pathfinder: bool, } +impl ArtStyle { + /// Do its effects move the object's outline (Transform, Twist, Free Distort)? + pub fn moves_outline(&self) -> bool { + self.effects.iter().any(|e| matches!(e.id.as_str(), "distort.transform" | "distort.twist" | "distort.freeDistort")) + } +} + #[derive(Clone, Debug, PartialEq)] pub(super) enum StyleItem { Fill(Paint), @@ -1230,6 +1238,52 @@ impl Builder<'_> { } } +/// Is `e`, on the whole object, an effect any object is read with? Of the Distort & Transform +/// effects: Transform without copies (Random is never read), Twist, a Tweak that moves nothing, +/// Pucker & Bloat at 0, and Free Distort when its corners make a parallelogram (an affine map). +/// Zig Zag is left to type. Which objects Transform, Twist and Free Distort are read on is +/// `read::ops`' `moves_with`. +fn any_object_takes(e: &Effect) -> bool { + match e.id.as_str() { + "stylize.dropShadow" + | "stylize.innerGlow" + | "stylize.outerGlow" + | "stylize.feather" + | "stylize.roundCorners" + | "stylize.scribble" + | "convertToShape.rectangle" + | "convertToShape.roundedRectangle" + | "convertToShape.ellipse" + | "path.offsetPath" + | "path.outlineObject" + | "path.outlineStroke" + | "distort.twist" + | "distort.tweak" => true, + // Copies are drawn as one path, its fills under all its strokes, where the file paints each + // copy's fill and stroke in turn. + "distort.transform" => e.params["copies"].as_i64() == Some(0), + "distort.puckerBloat" => e.params["amount"].as_f64() == Some(0.0), + "distort.freeDistort" => affine(&e.params), + id => is_pathfinder(id), + } +} + +/// 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 { + let Some(c) = p["corners"].as_array() else { return false }; + let at = |i: usize, axis: usize| c.get(i).and_then(|c| c.get(axis)).and_then(serde_json::Value::as_f64); + (0..2).all(|axis| match (at(0, axis), at(1, axis), at(2, axis), at(3, axis)) { + (Some(tl), Some(tr), Some(br), Some(bl)) => { + // The corners are fractions of the object's box, divided out of the file's rounded + // decimals; the tolerance is relative to how far apart they lie, not where. + let (lo, hi) = [tl, tr, br, bl].iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + ((tl + br) - (tr + bl)).abs() <= 1e-5 * (hi - lo).max(1.0) + } + _ => false, + }) +} + fn style(filters: &[Filter]) -> ArtStyle { let mut st = ArtStyle { plain: true, ..ArtStyle::default() }; let Some(root) = filters.iter().position(|f| f.parent.is_none()) else { return st }; @@ -1274,23 +1328,10 @@ fn style(filters: &[Filter]) -> ArtStyle { && 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(|e| { - matches!( - e.id.as_str(), - "stylize.dropShadow" - | "stylize.innerGlow" - | "stylize.outerGlow" - | "stylize.feather" - | "stylize.roundCorners" - | "stylize.scribble" - | "convertToShape.rectangle" - | "convertToShape.roundedRectangle" - | "convertToShape.ellipse" - | "path.offsetPath" - | "path.outlineObject" - | "path.outlineStroke" - ) || is_pathfinder(&e.id) - }) + && st.effects.iter().all(any_object_takes) + // 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) // Pathfinder effects on a group with no fills or strokes of its own (`styled` checks the group). && (!st.pathfinder || st.items.is_empty()); st diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 2815f589c..4b6cc1502 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -891,6 +891,48 @@ fn pucker_and_twist_keep_their_sense() { assert_eq!(read_effect("Adobe Twirl", "4000 /Real (angle) ,\n"), None); } +#[test] +fn any_object_takes_the_distort_effects_that_draw_as_its_drawn_look() { + // GUARDS: `simple` left out every Distort & Transform effect, so only type took them. + let simple = |name: &str, keys: &str| { + let st = distort_effect_style(name, keys); + assert!(st.plain, "{name}: {st:?}"); + st.simple + }; + assert!(simple("Adobe Twirl", "45 /Real (angle) ,\n")); + assert!(simple("Adobe Transform", &format!("0.5 /Real (scaleH_Factor) ,\n{SWITCHES}"))); + assert!(simple("Adobe Scribble and Tweak", "")); + assert!(simple("Adobe Punk and Bloat", "")); + assert!(simple("Adobe Free Distort", "")); + // A slant: the bottom edge moved as far as the top one. + let src = [[0.0, 0.0], [1.0, 0.0], [0.0, -1.0], [1.0, -1.0]]; + assert!(simple("Adobe Free Distort", &corners(src, [[0.2, 0.0], [1.2, 0.0], [0.0, -1.0], [1.0, -1.0]]))); + assert!(!simple("Adobe Free Distort", &corners(src, [[0.2, 0.0], [1.2, 0.1], [0.0, -1.0], [1.0, -1.0]]))); + assert!(!simple("Adobe Punk and Bloat", "-35 /Real (d_factor) ,\n")); + assert!(!simple("Adobe Zigzag", "")); + assert!(!simple("Adobe Transform", &format!("1 /Int (numCopies) ,\n{SWITCHES}"))); + // With another effect, only type takes it. + let chain = [basic("Adobe Twirl", "45 /Real (angle) ,\n"), basic("Adobe Offset Path", "4 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n")]; + let data = format!("/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &chain)); + let st = art_styles(data.as_bytes()).remove("Fx").unwrap_or_default(); + assert!(st.plain && !st.simple, "{st:?}"); +} + +#[test] +fn affine_corners_are_a_parallelogram_however_far_they_reach() { + assert!(affine(&json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}))); + assert!(affine(&json!({"corners": [[180.0, -310.0], [320.0, -300.0], [330.0, -250.0], [190.0, -260.0]]}))); + assert!(!affine(&json!({"corners": [[180.0, -310.0], [320.0, -300.0], [330.0, -250.01], [190.0, -260.0]]}))); + // Far from the box, a small shape is held to its own size. + assert!(!affine(&json!({"corners": [[9999.0, 0.0], [10000.0, 0.0], [10000.05, 1.0], [9999.0, 1.0]]}))); + // GUARDS: a slant written in rounded decimals, in a frame of 33.333 pt, missed by a hair. + let src = [[0.0, 0.0], [33.333, 0.0], [0.0, -33.333], [33.333, -33.333]]; + let slant = distort_effect_style("Adobe Free Distort", &corners(src, [[6.6667, 0.0], [40.0, 0.0], [0.0, -33.333], [33.333, -33.333]])); + assert!(slant.plain && slant.simple, "{slant:?}"); + assert!(!affine(&json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]}))); + assert!(!affine(&json!({}))); +} + #[test] fn roughen_keeps_the_drawn_look() { // Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 2d92daae5..87cda22b3 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4759,6 +4759,206 @@ fn an_object_with_path_effects_comes_in_as_itself_with_them_live() { assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// The paint of a blue fill in a style. +const BLUE_FILL: Option<&str> = Some("0 0 0 0 0 0 1 Xa\n"); + +/// An art style section with style `Bent`: effect `name` with dictionary `keys` on the whole +/// object, over a fill painting with `paint`, or over a fill and stroke that paint nothing. +fn distort_styles(name: &str, keys: &str, paint: Option<&str>) -> String { + let (paints, color) = match paint { + Some(p) => ("0 1", p), + None => ("0 0", ""), + }; + format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(Bent) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n({name}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 1 0 0 0 Xy\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{color}{paints} Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + ;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ) +} + +/// Free Distort's 16 entries for a 40 × 30 pt object whose source frame is its own box, with its +/// destination corners `dst` (top left, top right, bottom left, bottom right, art space). +fn free_distort_keys(dst: [[f64; 2]; 4]) -> String { + let src = [[20.0, 50.0], [60.0, 50.0], [20.0, 20.0], [60.0, 20.0]]; + let mut out = String::new(); + for (which, c) in [("src", src), ("dst", dst)] { + for (i, [h, v]) in c.into_iter().enumerate() { + out += &format!("{h} /Real ({which}{i}h) ,\n{v} /Real ({which}{i}v) ,\n"); + } + } + out +} + +#[test] +fn an_object_with_a_distort_effect_that_draws_alike_comes_in_as_itself_with_it_live() { + // GUARDS: only type took Distort & Transform effects; a path with Transform, Twist, a still Tweak, + // Pucker & Bloat at 0 or an affine Free Distort came in as its drawn look. + let hidden_rect = rect_path(20.0, 20.0, 60.0, 50.0).replace('\n', "\n%_"); + let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0)); + // A slanted box: its corners make a parallelogram. + let slanted = free_distort_keys([[25.0, 50.0], [65.0, 50.0], [20.0, 20.0], [60.0, 20.0]]); + for (name, keys, id) in [ + ("Adobe Transform", "5 /Real (moveH_Pts) ,\n0.5 /Real (rotate_Radians) ,\n0 /Bool (transformPatterns) ,\n", "distort.transform"), + ("Adobe Twirl", "45 /Real (angle) ,\n", "distort.twist"), + ("Adobe Scribble and Tweak", "30 /Real (horz) ,\n0 /Bool (anch) ,\n0 /Bool (in) ,\n0 /Bool (out) ,\n", "distort.tweak"), + ("Adobe Punk and Bloat", "0 /Real (d_factor) ,\n", "distort.puckerBloat"), + ("Adobe Free Distort", slanted.as_str(), "distort.freeDistort"), + ("Adobe Free Distort", "", "distort.freeDistort"), + ] { + let r = read_ok(&format!("{}{}", distort_styles(name, keys, BLUE_FILL), layer(&art_))); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{name}: {:?}", r.warnings); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{name}: {a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{name}: the object, not its look: {a:#?}"); + let b = a[0].geometric_bounds().unwrap(); + assert_eq!((b.width(), b.height()), (40.0, 30.0), "{name}: the object keeps its own path"); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)), "{name}"); + let ids: Vec<_> = a[0].appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, [id], "{name}"); + } +} + +#[test] +fn an_object_with_a_distort_effect_drawn_another_way_keeps_its_drawn_look() { + // Pucker & Bloat and Zig Zag draw other outlines than the file's, a Free Distort whose corners + // aren't a parallelogram bends the curves differently, and Transform's copies paint as one path. + let hidden_rect = rect_path(20.0, 20.0, 60.0, 50.0).replace('\n', "\n%_"); + let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0)); + let bent = free_distort_keys([[25.0, 50.0], [65.0, 52.0], [20.0, 20.0], [60.0, 20.0]]); + for (name, keys) in [ + ("Adobe Punk and Bloat", "-50 /Real (d_factor) ,\n"), + ("Adobe Zigzag", "3 /Real (amount) ,\n1 /Real (absoluteness) ,\n"), + ("Adobe Free Distort", bent.as_str()), + ("Adobe Scribble and Tweak", "30 /Real (horz) ,\n"), + ("Adobe Transform", "1 /Bool (randomize) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"), + ("Adobe Transform", "5 /Real (moveH_Pts) ,\n2 /Int (numCopies) ,\n0 /Bool (transformPatterns) ,\n"), + ] { + let r = read_ok(&format!("{}{}", distort_styles(name, keys, BLUE_FILL), layer(&art_))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{name}: {a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{name}: the drawn look: {a:#?}"); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{name}: {:?}", r.warnings); + } +} + +/// The gradient `G`, white to black, defined for a document. +const GRADIENT_G: &str = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n%_1 0 50 0 Bs\n%_0 0 50 100 Bs\nBD\n%AI5_EndGradient\n"; + +#[test] +fn an_object_whose_outline_moves_away_from_its_gradient_keeps_its_drawn_look() { + // GUARDS: a gradient stays where it is placed while Transform, Twist or Free Distort moves the + // outline, so the live object lost the gradient its drawn look shows. + let gradient = Some("Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n"); + let hidden_rect = rect_path(20.0, 20.0, 60.0, 50.0).replace('\n', "\n%_"); + let object = format!( + "0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_1 (G) 20 35 0 40 1 0 0 1 0 0 Bg\n%_{hidden_rect}f\n%_0 BB\n1 (Bent) XW\n", + rect_path(16.0, 16.0, 64.0, 54.0) + ); + let hidden = |color: &str, x: f64| format!("{color}{}f\n", rect_path(x, 0.0, x + 10.0, 10.0)).replace('\n', "\n%_"); + let shaded = format!("Bb\n1 (G) 5 5 0 10 1 0 0 1 0 0 Bg\n{}f\n0 BB\n", rect_path(5.0, 0.0, 15.0, 10.0)).replace('\n', "\n%_"); + let group = format!("0 Ae\nu\n{RED}{}f\nU\n%_0 Ae\n%_u\n%_{}{shaded}U\n1 (Bent) XW\n", rect_path(0.0, 0.0, 15.0, 10.0), hidden(RED, 0.0)); + let slanted = free_distort_keys([[25.0, 50.0], [65.0, 50.0], [20.0, 20.0], [60.0, 20.0]]); + let moves = [ + ("Adobe Transform", "5 /Real (moveH_Pts) ,\n0 /Bool (transformPatterns) ,\n"), + ("Adobe Twirl", "45 /Real (angle) ,\n"), + ("Adobe Free Distort", slanted.as_str()), + ]; + for (name, keys) in moves { + // A gradient in the style, and one on a member of a group. + for (paint, art_) in [(gradient, &object), (None, &group)] { + let r = read_ok(&format!("{GRADIENT_G}{}{}", distort_styles(name, keys, paint), layer(art_))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{name}: {a:#?}"); + assert!(a[0].appearance.effects.is_empty(), "{name}: the drawn look: {a:#?}"); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{name}: {:?}", r.warnings); + } + } + // An effect that leaves the outline where it is keeps the gradient under it. + let r = read_ok(&format!("{GRADIENT_G}{}{}", distort_styles("Adobe Punk and Bloat", "", gradient), layer(&object))); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{a:#?}"); + assert!(matches!(fill(&a[0]), Paint::Gradient(_)), "{:?}", fill(&a[0])); +} + +/// A group with style `Bent`, its drawn look one path, holding a red and a blue square. +fn distort_group() -> String { + let hidden = |color: &str, x: f64| format!("{color}{}f\n", rect_path(x, 0.0, x + 10.0, 10.0)).replace('\n', "\n%_"); + format!( + "0 Ae\nu\n{RED}{}f\nU\n%_0 Ae\n%_u\n%_{}{}U\n1 (Bent) XW\n", + rect_path(0.0, 0.0, 15.0, 10.0), + hidden(RED, 0.0), + hidden("0 0 1 0 0 0 1 Xa\n", 5.0) + ) +} + +#[test] +fn a_group_with_a_transform_keeps_its_members_and_the_effect() { + // GUARDS: the group came in as its drawn look, one path with no effect. + let keys = "5 /Real (moveH_Pts) ,\n0 /Bool (transformPatterns) ,\n"; + let r = read_ok(&format!("{}{}", distort_styles("Adobe Transform", keys, None), layer(&distort_group()))); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let members = a[0].children().cloned().unwrap_or_default(); + assert_eq!(members.len(), 2, "the squares, not the look: {a:#?}"); + let [e] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance) }; + assert_eq!((e.id.as_str(), e.params["moveH"].as_f64()), ("distort.transform", Some(5.0))); + assert!(a[0].appearance.items.is_empty(), "{:?}", a[0].appearance); +} + +/// `styles` (from [`distort_styles`]) with a red stroke 2 pt wide in place of the stroke that +/// paints nothing. +fn with_red_stroke(styles: &str) -> String { + styles.replace("/StrokeStyle : 0 1 0 0 0 Xy\n0 0 Xd", "/StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 1 0 0 0 Xy\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd") +} + +#[test] +fn twist_and_a_scaling_transform_are_read_only_where_their_outline_is_known() { + // GUARDS: Twist was read on curves, strokes and groups, and Transform scaling a stroked object, + // where the outline VectorCraft draws hasn't been checked. + let object = |hidden: &str| { + format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}f\n%_0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_")) + }; + let square = object(&rect_path(20.0, 20.0, 60.0, 50.0)); + let curved = object("20 35 m\n20 45 60 45 60 35 c\n60 25 20 25 20 35 c\n"); + let twist = distort_styles("Adobe Twirl", "45 /Real (angle) ,\n", BLUE_FILL); + let scale = "0.5 /Real (scaleH_Factor) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"; + let corner = distort_styles("Adobe Transform", &format!("0 /Int (pinPoint) ,\n{scale}"), BLUE_FILL); + let center = distort_styles("Adobe Transform", scale, BLUE_FILL); + let moved = distort_styles("Adobe Transform", "0 /Int (pinPoint) ,\n5 /Real (moveH_Pts) ,\n0 /Bool (transformPatterns) ,\n", BLUE_FILL); + let read = |styles: &str, art_: &str| read_ok(&format!("{styles}{}", layer(art_))); + for (what, r) in [ + ("a twisted curve", read(&twist, &curved)), + ("a twisted stroke", read(&with_red_stroke(&twist), &square)), + ("a twisted group", read(&distort_styles("Adobe Twirl", "45 /Real (angle) ,\n", None), &distort_group())), + ("a stroke scaled from a corner", read(&with_red_stroke(&corner), &square)), + ("a stroke scaled from the center", read(&with_red_stroke(¢er), &square)), + ] { + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{what}: {a:#?}"); + assert!(a[0].appearance.effects.is_empty(), "{what}: the drawn look: {a:#?}"); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings); + } + for (what, r) in [ + ("a twisted square", read(&twist, &square)), + ("a stroke moved from a corner", read(&with_red_stroke(&moved), &square)), + ("a fill scaled from a corner", read(&corner, &square)), + ("a fill scaled from the center", read(¢er, &square)), + ] { + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{what}: {a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }) && a[0].appearance.effects.len() == 1, "{what}: {a:#?}"); + } + let r = read(&twist, &square); + let a = art(&r.doc); + let [e] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance) }; + assert_eq!((e.id.as_str(), e.params["angle"].as_f64()), ("distort.twist", Some(45.0))); +} + #[test] fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() { // GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b7147fee8..308b7b5ea 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -150,7 +150,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 (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"), 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 or Convert to Shape comes in as itself with them and their saved settings; one with more, other effects, 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; 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, 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 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"), 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; 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, 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 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 321f737ef..5857256b2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1208,6 +1208,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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 `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. Pattern brushes and brushes that can't be read are left out with a note; brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`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, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.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 PDF content of a `.ai` has a page for each artboard, the layers are compared with every page on its artboard (until the pages compared take 8,388,608 pixels at the comparison's scale, about 67 letter-size pages, or number 128): art that only the layers or only a later page draws is handled as art that differs on the first page is, and a text object that a later page doesn't draw is left out unless its layer doesn't print (`crates/eps/src/import/tests_native.rs`). 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`).