diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 7c9f649c7..b67279bbb 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -103,6 +103,23 @@ fn shape_modes_follow_the_pathfinder_panel() { assert_eq!(div.len(), 3); } +/// Divide and Outline draw no piece of a member without fill or stroke, the same as when such +/// pieces are left out. +#[test] +fn divide_and_outline_draw_nothing_for_unpainted_members() { + let unpainted = + Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), Appearance::basic(Paint::None, Paint::None, 0.0))); + for effect in ["pathfinder.divide", "pathfinder.outline"] { + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), unpainted.clone()]); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + let out = pathfinder_children(&g, None).unwrap(); + assert!(out.iter().any(|n| n.id == NodeId(1)) && out.iter().any(|n| n.id == NodeId(2)), "{effect}: {out:#?}"); + for n in out.iter().filter(|n| n.id == NodeId(2)) { + assert!(n.appearance.items.iter().all(|i| i.paint().is_none()), "{effect}: {n:#?}"); + } + } +} + /// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either /// side of x = 100. fn type_h() -> Node { diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d3f0c8cc8..5b858b5f7 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3940,6 +3940,10 @@ fn arb_ai_token() -> impl Strategy { "(Fill Style Filter) 0 0 /Filter ,", "(Adobe Outer Glow) 1 0 /Filter ,", "(Adobe Drop Shadow) 1 0 /Filter ,", + "(Adobe Pathfinder) 1 0 /Filter ,", + "0 /Int (Command) ,", + "0.5 /Real (Mix) ,", + "(Op) /UnicodeString (DisplayString) ,", "(Adobe Transform) 1 0 /Filter ,", "(Adobe Free Distort) 1 0 /Filter ,", "(Adobe Punk and Bloat) 1 0 /Filter ,", @@ -4150,6 +4154,53 @@ proptest! { survive("hostile editing data in an EPS of its art's box", || vectorcraft_engine::cmd::fileio::load("x.eps", &boxed).ok().map(|l| l.doc))?; } + /// A group with an Effect › Pathfinder style whose dictionary is hostile: any `Command`, keys of + /// any type and value (missing, repeated, unknown, not finite), shown or hidden, the style cut + /// short anywhere; in editing data, an EPS and a `.ai`. A `Command` other than 0 never becomes + /// a live effect. + #[test] + fn ai_pathfinder_styles_never_panic( + command in prop_oneof![(-3i64..16).prop_map(|c| c.to_string()), arb_num()], + keys in prop::collection::vec( + ( + prop::sample::select(vec!["Mix", "Precision", "RemovePoints", "ExtractUnpainted", "ConvertCustom", "TrapTint", "TrapThickness", "Command", "Extra"]), + prop::sample::select(vec!["Int", "Real", "Bool", "String"]), + arb_num(), + ), + 0..16, + ), + hidden in any::(), + cut in 0usize..4_000, + ) { + use vectorcraft_testkit::ai; + let dict: String = keys.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect(); + let styles = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(PF) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Pathfinder) 1 0 /Filter ,\n{} /Visible ,\n/Dictionary : /NotRecorded ,\n{command} /Int (Command) ,\n{dict}; /Dict ;\n /Part ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n\ + %AI9_EndArtStyles\n", + u8::from(!hidden) + ); + let styles: String = styles.chars().take(cut.max(1)).collect(); + let group = "0 Ae\nu\n1 0 0 1 0 0 Xa\n10 10 m\n10 60 L\n80 60 L\n80 10 L\nf\nU\n\ + %_0 Ae\n%_u\n%_1 0 0 1 0 0 Xa\n%_10 10 m\n%_10 50 L\n%_50 50 L\n%_50 10 L\n%_f\n%_0 0 1 0 0 0 1 Xa\n%_40 20 m\n%_40 60 L\n%_80 60 L\n%_80 20 L\n%_f\n%_U\n1 (PF) XW\n"; + let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", group))); + let live = command.parse::().is_ok_and(|c| c == 0.0); + let check = |d: Option| { + if let Some(d) = &d + && !live + { + d.walk(|n| assert!(!n.appearance.effects.iter().any(|e| e.id.starts_with("pathfinder.")), "Command {command}: {:?}", n.appearance.effects)); + } + d + }; + survive("hostile Pathfinder style", || check(Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0)))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("hostile Pathfinder style in an EPS", || check(vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc)))?; + let file = ai::ai(data.as_bytes(), ai::page_pdf()); + survive("hostile Pathfinder style in a .ai", || check(vectorcraft_engine::cmd::fileio::load("x.ai", &file).ok().map(|l| l.doc)))?; + } + /// Art styles with Offset Path, Outline Object and Outline Stroke, in any order, shown or hidden, /// whose dictionaries hold any of Offset Path's entries and unknown ones, as any kind, with any /// value (huge, tiny, not a number, a string), repeated or left out, on the whole object, a fill diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index f5ce51384..de12631eb 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -776,7 +776,8 @@ fn placed_paints(look: &Node, out: &mut HashMap, depth: usize) { 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, or asks a group for fills or strokes. +/// this reads, asks a group for fills or strokes, or has Pathfinder effects on anything but a group +/// with no fills or strokes. 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. @@ -784,6 +785,8 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p if !st.plain || (!slot && !st.simple) || (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, .. }))) { return None; } diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index db143d9f6..13abd50d2 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -35,13 +35,15 @@ //! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills //! with a pattern or gradient too), each with its own transparency and effects, and the effects of //! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and -//! effects; others keep their drawn look. +//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the +//! object's chain) are read on a group with no fills or strokes of its own, which keeps its members. use std::collections::HashMap; use serde_json::json; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile}; +use vectorcraft_effects::is_pathfinder; use super::lex::{Lexer, Tok}; use super::obj::V; @@ -68,6 +70,7 @@ const ZIG_ZAG: &str = "Adobe Zigzag"; // brand-ok: the filter's name in the file const GAUSSIAN: &str = "Adobe PSL Gaussian Blur"; // brand-ok: the filter's name in the files const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl"; const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files +const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files const CHAIN: &str = "Chain Style Filter"; const STACK: &str = "Stack Style Filter"; @@ -101,6 +104,28 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> { id.strip_prefix("warp.") } +/// The keys of an Effect › Pathfinder › Add filter's dictionary besides `DisplayString`, each with +/// the kind and the one value read: `Command` 0 is Add, and the rest are its Pathfinder Options, +/// Soft Mix and Trap settings +/// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137). +/// `Precision` is in microns, as the SDK's `AIOptions` gives it +/// (https://ai-sdk-2020.84.nz/html/struct_a_i_options.html). +const PATHFINDER_ADD: [(&str, Kind, f64); 13] = [ + ("Command", Kind::Int, 0.0), + ("ConvertCustom", Kind::Bool, 1.0), + ("ExtractUnpainted", Kind::Bool, 1.0), + ("Mix", Kind::Real, 0.5), + ("Precision", Kind::Real, 10.0), + ("RemovePoints", Kind::Bool, 0.0), + ("TrapAspect", Kind::Real, 1.0), + ("TrapConvertCustom", Kind::Bool, 0.0), + ("TrapMaxTint", Kind::Real, 1.0), + ("TrapReverse", Kind::Bool, 0.0), + ("TrapThickness", Kind::Real, 0.25), + ("TrapTint", Kind::Real, 0.4), + ("TrapTintTolerance", Kind::Real, 0.05), +]; + /// A plain style's paint, as the object comes in. #[derive(Clone, Debug, Default, PartialEq)] pub(super) struct ArtStyle { @@ -108,7 +133,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: what any object is read with. Type takes the other plain styles too (its drawn look + /// Feather, Round Corners, Scribble, Convert to Shape and the Path effects (Offset Path, Outline Object, Outline Stroke) 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, /// Its fills and strokes, bottom first. @@ -122,6 +148,8 @@ pub(super) struct ArtStyle { /// The object's own opacity, blend mode, isolation and knockout (its blend filter), when the /// style has them. pub transparency: Option<(f32, BlendMode, bool, Knockout)>, + /// [`Self::effects`] holds Effect › Pathfinder effects, which only a group is read with. + pub pathfinder: bool, } #[derive(Clone, Debug, PartialEq)] @@ -187,6 +215,8 @@ struct Filter { params: HashMap, /// How each key of its dictionary is written. kinds: HashMap, + /// A key of its dictionary is written more than once. + repeated: bool, /// Its dictionary's arrays (`/Array : v /Real , … ; (key) ,`), by key. arrays: HashMap>, fill: Option, @@ -218,6 +248,7 @@ impl Default for Filter { compound: false, params: HashMap::new(), kinds: HashMap::new(), + repeated: false, arrays: HashMap::new(), fill: None, stroke: None, @@ -360,7 +391,8 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { } b"Array" => dict_array = Some(vec![]), b"Int" | b"Real" | b"Bool" => { - let value = operands.iter().rev().find_map(V::num); + // The number right before the type: an earlier one isn't this value. + let value = operands.last().and_then(V::num); operands.clear(); if let Some(a) = dict_array.as_mut() { a.extend(value); @@ -380,7 +412,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { Some(value) => f.params.insert(key.clone(), value), None => f.params.remove(&key), }; - f.kinds.insert(key, kind); + f.repeated |= f.kinds.insert(key, kind).is_some(); } continue; } @@ -419,7 +451,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { None => entry.map(|k| (k, Kind::Other)), }; if let (Some((key, kind)), Some(f)) = (key, filters.last_mut()) { - f.kinds.insert(key, kind); + f.repeated |= f.kinds.insert(key, kind).is_some(); } } if let Some(f) = filters.last_mut() { @@ -991,11 +1023,26 @@ fn effect(f: &Filter, under: Under) -> Option { // Its settings are in a Photoshop plug-in's binary block: the effect's defaults stand in. BAS_RELIEF => ("sketch.basRelief", json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})), SHAPE_EFFECTS => return shape_effect(f), + PATHFINDER => return pathfinder_effect(f), _ => return None, }; Some(Effect { id: id.into(), params, visible: f.visible }) } +/// Effect › Pathfinder › Add, when every key of [`PATHFINDER_ADD`] is there with its kind and value +/// and the only other key is the `DisplayString` text, each written once. `None` for anything else, +/// which keeps the drawn look. +fn pathfinder_effect(f: &Filter) -> Option { + let known = f.kinds.iter().all(|(key, kind)| (key == "DisplayString" && *kind == Kind::Other) || PATHFINDER_ADD.iter().any(|(k, ..)| k == key)); + if !known || f.repeated || !f.arrays.is_empty() || f.fill.is_some() || f.stroke.is_some() || f.gradient || f.pattern.is_some() { + return None; + } + if !PATHFINDER_ADD.iter().all(|(key, kind, value)| f.kinds.get(*key) == Some(kind) && f.params.get(*key) == Some(value)) { + return None; + } + Some(Effect { id: "pathfinder.add".into(), params: json!({}), visible: f.visible }) +} + /// The fill or stroke basic filter `f` paints, `Ok(None)` when it paints nothing, `Err` when it /// paints in a way this doesn't read. fn paint_item(f: &Filter) -> Result, ()> { @@ -1096,9 +1143,9 @@ impl Builder<'_> { } } // Convert to Shape on one fill or stroke isn't read yet: what it draws there is - // the drawn look's. + // the drawn look's. A fill's or stroke's own Pathfinder effect isn't read either. _ => match effect(c, under) { - Some(e) if !e.id.starts_with("convertToShape.") => look.effects.push(e), + Some(e) if !e.id.starts_with("convertToShape.") && !is_pathfinder(&e.id) => look.effects.push(e), _ => self.plain = false, }, } @@ -1136,6 +1183,7 @@ 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 }; let mut b = Builder { filters, plain: true }; + let chained = filters[root].compound && filters[root].name == CHAIN; let top = if filters[root].compound { b.children(root) } else { vec![root] }; for id in top { let f = &filters[id]; @@ -1155,12 +1203,19 @@ fn style(filters: &[Filter]) -> ArtStyle { st.transparency = Some((opacity, super::read::blend_mode(mode), isolate, knockout)); } } + // The object's Pathfinder effects are read only in its own chain. _ => match effect(f, ANYTHING) { - Some(e) => st.effects.push(e), - None => b.plain = false, + Some(e) if chained || !is_pathfinder(&e.id) => st.effects.push(e), + _ => b.plain = false, }, } } + st.pathfinder = st.effects.iter().any(|e| is_pathfinder(&e.id)); + // A group's Pathfinder effects are drawn before its other effects: one listed after another + // effect keeps the drawn look. + if st.effects.iter().skip_while(|e| is_pathfinder(&e.id)).any(|e| is_pathfinder(&e.id)) { + b.plain = false; + } st.plain = b.plain; let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count(); st.simple = st.plain @@ -1183,8 +1238,10 @@ fn style(filters: &[Filter]) -> ArtStyle { | "path.offsetPath" | "path.outlineObject" | "path.outlineStroke" - ) - }); + ) || is_pathfinder(&e.id) + }) + // 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 444f5c4fd..2815f589c 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -471,6 +471,58 @@ fn an_effect_not_read_here_keeps_the_drawn_look() { assert!(!art_styles(data.as_bytes())["Odd"].plain); } +/// The dictionary of an Effect › Pathfinder filter with `Command` `command` and `Mix` `mix`, and +/// the other options at the values the effect reads. +fn pathfinder_keys(command: &str, mix: &str) -> String { + format!( + "0 /Bool (RemovePoints) ,\n{mix} /Real (Mix) ,\n1 /Bool (ConvertCustom) ,\n{command} /Int (Command) ,\n1 /Real (TrapMaxTint) ,\n\ + 1 /Real (TrapAspect) ,\n0.4 /Real (TrapTint) ,\n0.25 /Real (TrapThickness) ,\n0 /Bool (TrapReverse) ,\n10 /Real (Precision) ,\n\ + (Op) /UnicodeString (DisplayString) ,\n0 /Bool (TrapConvertCustom) ,\n0.05 /Real (TrapTintTolerance) ,\n1 /Bool (ExtractUnpainted) ,\n" + ) +} + +/// The style `Group`: the object's chain holding `effects`, a stack whose fill and stroke paint +/// nothing, and the object's transparency. +fn group_style(effects: &[String]) -> ArtStyle { + let stack = compound( + "Stack Style Filter", + &[ + basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n"), + basic("Conduit Filter", ""), + basic( + "Fill Style Filter", + "/FillStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 0 Xd\n/Def ;\n (FillStyle) ,\n0 /Bool (UseEvenOdd) ,\n", + ), + ], + ); + let mut kids = effects.to_vec(); + kids.extend([stack, blend(0, 1.0)]); + let data = format!("/KnownStyle :\n(Group) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &kids)); + art_styles(data.as_bytes()).remove("Group").unwrap_or_default() +} + +fn pathfinder(keys: &str) -> String { + basic("Adobe Pathfinder", keys) +} + +#[test] +fn pathfinder_add_is_read_as_its_effect() { + // GUARDS: an `Adobe Pathfinder` filter made the style not plain, so a group with it came in as its drawn look. + let st = group_style(&[pathfinder(&pathfinder_keys("0", "0.5"))]); + assert!(st.plain && st.simple && st.pathfinder, "{st:?}"); + assert!(st.items.is_empty(), "{st:?}"); + let [e] = st.effects.as_slice() else { panic!("{st:?}") }; + assert_eq!((e.id.as_str(), &e.params, e.visible), ("pathfinder.add", &json!({}), true)); +} + +#[test] +fn pathfinder_commands_other_than_add_keep_the_drawn_look() { + for command in 1..=12 { + let st = group_style(&[pathfinder(&pathfinder_keys(&command.to_string(), "0.5"))]); + assert!(!st.plain && !st.pathfinder && st.effects.is_empty(), "{command}: {st:?}"); + } +} + fn stroke(paint: &str) -> String { basic("Stroke Style Filter", &format!("/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 2 w 10 M []0 d0 XR\n{paint}\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n")) } @@ -519,6 +571,92 @@ fn offset_path_on_the_object_is_read_with_its_settings() { } } +#[test] +fn a_hidden_pathfinder_effect_comes_in_hidden() { + let st = group_style(&[pathfinder(&pathfinder_keys("0", "0.5")).replacen("1 /Visible", "0 /Visible", 1)]); + assert!(st.simple, "{st:?}"); + let [e] = st.effects.as_slice() else { panic!("{st:?}") }; + assert_eq!((e.id.as_str(), e.visible), ("pathfinder.add", false)); +} + +#[test] +fn pathfinder_effects_keep_their_order_among_the_objects_effects() { + let offset = basic("Adobe Offset Path", "3 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n"); + let st = group_style(&[pathfinder(&pathfinder_keys("0", "0.5")), offset.clone()]); + assert!(st.plain, "{st:?}"); + assert_eq!(ids(&st.effects), ["pathfinder.add", "path.offsetPath"]); + // GUARDS: an effect before the Pathfinder effect was read too, and the group drew the Pathfinder + // effect first, which isn't what the file draws. + let st = group_style(&[offset, pathfinder(&pathfinder_keys("0", "0.5"))]); + assert!(!st.plain, "{st:?}"); +} + +#[test] +fn pathfinder_settings_this_does_not_read_keep_the_drawn_look() { + let add = pathfinder_keys("0", "0.5"); + let cases = [ + ("no Command", add.replace("0 /Int (Command) ,\n", "")), + ("Trap", add.replace("0 /Int (Command)", "12 /Int (Command)")), + ("unknown Command", add.replace("0 /Int (Command)", "40 /Int (Command)")), + ("negative Command", add.replace("0 /Int (Command)", "-1 /Int (Command)")), + ("fractional Command", add.replace("0 /Int (Command)", "2.5 /Int (Command)")), + ("huge Command", add.replace("0 /Int (Command)", "1e999 /Int (Command)")), + ("Command as a real", add.replace("0 /Int (Command)", "0 /Real (Command)")), + ("Command without a number", add.replace("0 /Int (Command)", "(zero) /Int (Command)")), + // A number before the string isn't its value. + ("Command with a string after a number", add.replace("0 /Int (Command)", "3 (zero) /Int (Command)")), + ("Command twice", add.clone() + "1 /Int (Command) ,\n"), + // GUARDS: the last of a repeated key was read, so a Trap command followed by Add was read as Add. + ("Command twice, Add last", add.replace("0 /Int (Command)", "12 /Int (Command) ,\n0 /Int (Command)")), + ("Command twice, both Add", add.replace("0 /Int (Command)", "0 /Int (Command) ,\n0 /Int (Command)")), + ("an option twice, the read value last", add.replace("10 /Real (Precision)", "5 /Real (Precision) ,\n10 /Real (Precision)")), + ("unknown key", add.clone() + "1 /Int (Extra) ,\n"), + ("unknown string", add.clone() + "(x) /String (Extra) ,\n"), + ("an array", add.clone() + "/Array :\n1 /Real ,\n; (Extra) ,\n"), + ("many strings", add.clone() + &(0..40).map(|i| format!("(x) /String (DisplayString{i}) ,\n")).collect::()), + ("missing option", add.replace("0.25 /Real (TrapThickness) ,\n", "")), + ("missing Mix", add.replace("0.5 /Real (Mix) ,\n", "")), + ("option of the wrong type", add.replace("10 /Real (Precision)", "10 /Int (Precision)")), + ("other Precision", add.replace("10 /Real (Precision)", "5 /Real (Precision)")), + ("huge Precision", add.replace("10 /Real (Precision)", "1e999 /Real (Precision)")), + ("Remove Redundant Points", add.replace("0 /Bool (RemovePoints)", "1 /Bool (RemovePoints)")), + ("unpainted art kept", add.replace("1 /Bool (ExtractUnpainted)", "0 /Bool (ExtractUnpainted)")), + ("other trap tint", add.replace("0.4 /Real (TrapTint)", "0.25 /Real (TrapTint)")), + ("Mix on Add", add.replace("0.5 /Real (Mix)", "0.3 /Real (Mix)")), + ("Mix as a bool", add.replace("0.5 /Real (Mix)", "1 /Bool (Mix)")), + ]; + for (case, keys) in cases { + let st = group_style(&[pathfinder(&keys)]); + assert!(!st.plain && !st.pathfinder && st.effects.is_empty(), "{case}: {st:?}"); + } +} + +#[test] +fn a_pathfinder_effect_outside_the_objects_chain_keeps_the_drawn_look() { + let pf = pathfinder(&pathfinder_keys("0", "0.5")); + // On a fill of its own. + let item = compound("Chain Style Filter", &[pf.clone(), fill("0 0 0 1 0 0 0 Xa")]); + let data = + format!("/KnownStyle :\n(Item) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); + let st = &art_styles(data.as_bytes())["Item"]; + assert!(!st.plain, "{st:?}"); + // In a style that isn't an object's chain. + let data = format!("/KnownStyle :\n(Bare) /Name ,\n/ActiveStyle :\n{pf}"); + let st = &art_styles(data.as_bytes())["Bare"]; + assert!(!st.plain && st.effects.is_empty(), "{st:?}"); +} + +#[test] +fn a_pathfinder_effect_with_fills_of_its_own_keeps_the_drawn_look() { + let stack = compound("Stack Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Conduit Filter", "")]); + let data = format!( + "/KnownStyle :\n(Filled) /Name ,\n/ActiveStyle :\n{}", + compound("Chain Style Filter", &[pathfinder(&pathfinder_keys("0", "0.5")), stack]) + ); + let st = &art_styles(data.as_bytes())["Filled"]; + assert!(st.pathfinder && !st.simple, "{st:?}"); +} + #[test] fn outline_object_and_outline_stroke_are_read_with_no_settings() { // GUARDS: `Adobe Outline Type` and `Adobe Outline Stroke` weren't read, so the style kept its drawn look. diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 9ca955eb9..6b1e8622d 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3248,6 +3248,80 @@ fn an_object_with_a_feather_style_comes_in_with_a_live_effect() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// An art style section with style `PF`: an Effect › Pathfinder filter with `Command` `command` +/// and `Mix` `mix` in the object's chain, over a fill and stroke that paint nothing. +fn pathfinder_styles(command: u32, mix: f64) -> String { + format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(PF) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Pathfinder) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + 0 /Bool (RemovePoints) ,\n{mix} /Real (Mix) ,\n1 /Bool (ConvertCustom) ,\n{command} /Int (Command) ,\n1 /Real (TrapMaxTint) ,\n\ + 1 /Real (TrapAspect) ,\n0.4 /Real (TrapTint) ,\n0.25 /Real (TrapThickness) ,\n0 /Bool (TrapReverse) ,\n10 /Real (Precision) ,\n\ + (Op) /UnicodeString (DisplayString) ,\n0 /Bool (TrapConvertCustom) ,\n0.05 /Real (TrapTintTolerance) ,\n1 /Bool (ExtractUnpainted) ,\n\ + ; /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 1 0 0 0 Xy\n0 0 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + ;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ) +} + +/// A group of a red and a blue square with style `PF`: its drawn look (one path of their area), +/// then the group on `%_` lines. +fn pathfinder_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 (PF) 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_pathfinder_effect_keeps_its_members_and_the_effect() { + // GUARDS: the style was not read, and the group came in as its drawn look: one path, with no effect. + let r = read_ok(&format!("{}{}", pathfinder_styles(0, 0.5), layer(&pathfinder_group()))); + 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:#?}"); + assert!(members.iter().all(|m| matches!(m.kind, NodeKind::Path { .. })), "{members:#?}"); + assert_eq!((fill(&members[0]).color(), fill(&members[1]).color()), (Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0)))); + let effects: Vec<_> = a[0].appearance.effects.iter().map(|e| (e.id.as_str(), e.visible)).collect(); + assert_eq!(effects, [("pathfinder.add", true)]); + assert!(a[0].appearance.items.is_empty(), "{:?}", a[0].appearance); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + +#[test] +fn a_pathfinder_style_this_does_not_read_keeps_the_drawn_look() { + // Another command (Intersect, Trap), and a Precision other than the one read. + let other = pathfinder_styles(0, 0.5).replace("10 /Real (Precision)", "5 /Real (Precision)"); + for styles in [pathfinder_styles(1, 0.5), pathfinder_styles(12, 0.5), other] { + let r = read_ok(&format!("{styles}{}", layer(&pathfinder_group()))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let look = a[0].children().cloned().unwrap_or_default(); + assert_eq!(look.len(), 1, "the look's one path: {a:#?}"); + assert!(a[0].appearance.effects.is_empty(), "{:?}", a[0].appearance); + } +} + +#[test] +fn a_path_with_a_pathfinder_effect_keeps_its_drawn_look() { + // Effect › Pathfinder doesn't apply to a lone path: its style stays the drawn look's. + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (PF) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let r = read_ok(&format!("{}{}", pathfinder_styles(0, 0.5), layer(&art_))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the look, not the path: {a:#?}"); + let mut effects = 0; + r.doc.walk(|n| effects += n.appearance.effects.len()); + assert_eq!(effects, 0); +} + #[test] fn an_object_with_convert_to_shape_comes_in_as_itself_with_the_effect_live() { // GUARDS: `Adobe Shape Effects` wasn't read, so the object came in as its drawn look. diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 29a45ae19..daeea4826 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3459,6 +3459,59 @@ fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() { check(&r.document, &r.warnings); } +/// An art style section with style `Add`: Effect › Pathfinder › Add in the object's chain, over a +/// fill and stroke that paint nothing. +fn pathfinder_add_style() -> String { + let basic = |name: &str, body: &str| { + format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") + }; + let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); + let add = basic( + "Adobe Pathfinder", + "0 /Bool (RemovePoints) ,\n0.5 /Real (Mix) ,\n1 /Bool (ConvertCustom) ,\n0 /Int (Command) ,\n1 /Real (TrapMaxTint) ,\n1 /Real (TrapAspect) ,\n\ + 0.4 /Real (TrapTint) ,\n0.25 /Real (TrapThickness) ,\n0 /Bool (TrapReverse) ,\n10 /Real (Precision) ,\n0 /Bool (TrapConvertCustom) ,\n\ + 0.05 /Real (TrapTintTolerance) ,\n1 /Bool (ExtractUnpainted) ,\n", + ); + let stack = compound( + "Stack Style Filter", + &[ + basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n"), + basic("Conduit Filter", ""), + basic("Fill Style Filter", "/FillStyle : 0 1 0 0 0 Xy\n0 0 Xd\n/Def ;\n (FillStyle) ,\n"), + ], + ); + format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Add) /Name ,\n/ActiveStyle :\n{}%AI9_EndArtStyles\n", compound("Chain Style Filter", &[add, stack])) +} + +#[test] +fn type_in_a_group_with_a_pathfinder_effect_stays_type() { + // GUARDS: the group's style was not read, and the group came in as its drawn look, the type's + // outlines and the square, which can't be retyped. + let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + // The look: the square, and the area round where the type lies (its anchor is at (58.5, 61.5)), + // in the square's yellow. + let drawing = "0 0 1 0 k\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; + let look = format!("u\n{}\n{drawing}\nU\n9 () XW\n", square(10, 10)); + let own: String = format!("0 Ae\nu\n{}{}\nU\n", text_object(0), square(10, 10)).lines().map(|l| format!("%_{l}\n")).collect(); + let art = native_with_text(&(pathfinder_add_style() + &layer("Art", true, &format!("{look}{own}1 (Add) XW\n"))), &doc); + let check = |d: &Document, notes: &[String]| { + assert_eq!(kinds(&d.layers[0]), ["group[2]"], "{notes:?}"); + let group = &d.layers[0].children().unwrap()[0]; + let mut kinds = kinds(group); + kinds.sort_unstable(); + assert_eq!(kinds, ["path", "text"], "{notes:?}"); + assert_eq!(text_of(group).iter().map(|t| t.plain_text()).collect::>(), ["Hi"]); + let effects: Vec<_> = group.appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(effects, ["pathfinder.add"], "{notes:?}"); + assert!(!notes.iter().any(|w| w.contains("drawn look")), "{notes:?}"); + }; + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + check(&d, ¬es); + let page = format!("{}\n0 0 1 0 setcmykcolor 54 56 moveto 100 56 lineto 100 80 lineto 54 80 lineto closepath fill", page_square(10, 10)); + let r = import(&eps(&page, &art)).unwrap(); + check(&r.document, &r.warnings); +} + /// An art style section with style `Bent`: a blue fill over the characters, and effect `name`, its /// dictionary holding `keys`, on the whole object. fn whole_object_style(name: &str, keys: &str) -> String { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bccb00768..15d40489a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1113,6 +1113,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Legacy `.ai` saves (version 7) write type with rotated characters (Character Rotation, Touch Type turns) as its outlines, with a warning that it is no longer editable text (unless `outlineText` already asks for every type object as outlines): a version 7 text object has no character rotation ([Adobe Illustrator File Format Specification 7.0](https://www.nationalarchives.gov.uk/PRONOM/fmt/423), §15), and the outlines keep each character's rotation, baseline shift and scale (`legacy_ai::tests::type_with_rotated_characters_is_outlines_that_keep_the_rotation`). A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304). 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`). + A `.ai` opened from its editing data keeps Effect › Pathfinder on a group with no fills or strokes of its own as a live effect, with the group's members editable: Add, shown or hidden, in its order among the group's effects. The other operations, Pathfinder Options values other than the ones the reader accepts, and the effect on a path, on type, or on a fill or stroke open as the drawn look (`crates/eps/src/import/ai/style.rs`, `pathfinder_effect`). 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 readable variable library opens with its variable definitions, data-set rows, text, visibility and linked-file values, active row and lock in the Variables panel, whether its layers are read or it comes in as its PDF part; graph values and malformed entries are skipped with a note, and objects aren't bound yet (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data keeps each artboard's center mark, cross hairs, video safe areas and video ruler pixel aspect ratio, and the file's Document Raster Effects Settings; the author comes from the header's `%%For` (the editing data's, else an EPS's own) when the file's File Info gives none, the header's custom colors that no swatch has come in as spot swatches, a named color the file defines takes the place of a default swatch of that name when the file has no palette, and an older file's `%%TemplateBox` places its artboard. A value of another type or out of range keeps its default (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).