mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 17:40:14 -06:00
.ai import: Effect › Pathfinder on a group opens as a live effect
Squashed from #5150 onto main.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -3940,6 +3940,10 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"(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::<bool>(),
|
||||
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::<f64>().is_ok_and(|c| c == 0.0);
|
||||
let check = |d: Option<Document>| {
|
||||
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
|
||||
|
||||
@@ -776,7 +776,8 @@ fn placed_paints(look: &Node, out: &mut HashMap<String, Paint>, 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<Appearance> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<String, f64>,
|
||||
/// How each key of its dictionary is written.
|
||||
kinds: HashMap<String, Kind>,
|
||||
/// A key of its dictionary is written more than once.
|
||||
repeated: bool,
|
||||
/// Its dictionary's arrays (`/Array : v /Real , … ; (key) ,`), by key.
|
||||
arrays: HashMap<String, Vec<f64>>,
|
||||
fill: Option<Paint>,
|
||||
@@ -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<String, ArtStyle> {
|
||||
}
|
||||
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<String, ArtStyle> {
|
||||
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<String, ArtStyle> {
|
||||
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<Effect> {
|
||||
// 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<Effect> {
|
||||
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<Option<StyleItem>, ()> {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<String>()),
|
||||
("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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::<Vec<_>>(), ["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 {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user