Merge pull request #5148 from dotCooCoo/fix/ai-os10-path

.ai import: Offset Path, Outline Object and Outline Stroke open live
This commit is contained in:
BFlat
2026-11-02 14:49:47 +07:00
committed by GitHub
7 changed files with 291 additions and 14 deletions
+64
View File
@@ -3666,6 +3666,17 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"/FillStyle : 0 O\n0 0 0 0 1 0 0 Xa\n0 1 Xd\n/Def ;\n (sclr) ,",
"/FillStyle : 0 O\nBb\n2 (G) 0 0 0 1 1 0 0 1 0 0 Bg\n0 BB\n/Def ;\n (gclr) ,",
"(sclr) ,",
"(Adobe Offset Path) 1 0 /Filter ,",
"(Adobe Outline Type) 1 0 /Filter ,",
"(Adobe Outline Stroke) 1 0 /Filter ,",
"(Chain Style Filter) 0 0 /Filter ,",
"-1e308 /Real (ofst) ,",
"1e999 /Real (ofst) ,",
"2 /Int (jntp) ,",
"7 /Int (jntp) ,",
"NaN /Real (mlim) ,",
"(x) /UnicodeString (mlim) ,",
"0 /Visible ,",
"1 /Visible ,",
"2 /FillOrStroke ,",
"1 /Bool (BSDashAdj) ,",
@@ -3819,6 +3830,59 @@ 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))?;
}
/// 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
/// or a stroke, of a path, a group and type.
#[test]
fn ai_path_effects_never_panic(
effects in prop::collection::vec((
prop::sample::select(vec!["Adobe Offset Path", "Adobe Outline Type", "Adobe Outline Stroke"]),
prop::collection::vec((
prop::sample::select(vec!["ofst", "jntp", "mlim", "width", "DisplayString"]),
prop::sample::select(vec!["Int", "Real", "Bool", "UnicodeString"]),
prop_oneof![
(-2e5f64..2e5).prop_map(|v| v.to_string()),
prop::sample::select(vec!["0", "1", "2", "3", "-1", "0.5", "1e-300", "1e308", "-1e308", "1e999", "NaN", "(x)", ""]).prop_map(str::to_string),
],
), 0..6),
any::<bool>(),
), 0..5),
target in 0..3usize,
) {
use vectorcraft_testkit::ai;
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 filters: Vec<String> = effects
.iter()
.map(|(name, entries, visible)| {
let keys: String = entries.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect();
basic(name, &keys).replacen("1 /Visible", if *visible { "1 /Visible" } else { "0 /Visible" }, 1)
})
.collect();
let fill = basic("Fill Style Filter", "/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n");
let stroke = basic("Stroke Style Filter", "/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 4 w 10 M []0 d0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n");
let conduit = basic("Conduit Filter", "");
let chain = |item: String| compound("Chain Style Filter", &[filters.concat(), item, filters.concat()]);
let root = match target {
0 => compound("Chain Style Filter", &[filters.concat(), compound("Stack Style Filter", &[stroke, fill, conduit])]),
1 => compound("Chain Style Filter", &[compound("Stack Style Filter", &[stroke, chain(fill), conduit])]),
_ => compound("Chain Style Filter", &[compound("Stack Style Filter", &[chain(stroke), fill, conduit])]),
};
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 art = format!(
"0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{hidden}f\n1 (Fx) XW\n0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_0 Ae\n%_u\n%_{hidden}f\n%_U\n1 (Fx) XW\nu\n/AI11Text :\n1 /FreeUndo ,\n;\n0 0 1 0 k\n{}f\nU\n1 (Fx) XW\n",
ai::rect(20.0, 20.0, 80.0, 60.0),
ai::rect(20.0, 20.0, 80.0, 60.0),
ai::rect(100.0, 20.0, 180.0, 60.0)
);
let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", &art)));
survive("Path effect art style", || 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("Path effect art style in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
}
/// Layer options (`Lb`) of any number of operands with any values, and guide paths (`(N) *`,
/// `(S) *`) of any points, among them ones across the whole canvas or from edge to edge of
/// artboards that overlap or are the same, on layers and sublayers.
+47 -10
View File
@@ -56,6 +56,9 @@ const SCRIBBLE: &str = "Adobe Scribble Fill"; // brand-ok: the filter's name in
const EXPAND_VERSION: &str = "Adobe Effect Expand Before Version"; // brand-ok: the key's name in the files
const DEFORM: &str = "Adobe Deform"; // brand-ok: the filter's name in the files
const OFFSET: &str = "Adobe Offset Path"; // brand-ok: the filter's name in the files
/// Effect › Path › Outline Object.
const OUTLINE_TYPE: &str = "Adobe Outline Type"; // brand-ok: the filter's name in the files
const OUTLINE_STROKE: &str = "Adobe Outline Stroke"; // brand-ok: the filter's name in the files
const TRANSFORM: &str = "Adobe Transform"; // brand-ok: the filter's name in the files
const GAUSSIAN: &str = "Adobe PSL Gaussian Blur"; // brand-ok: the filter's name in the files
const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl";
@@ -100,7 +103,7 @@ 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 and Convert to Shape 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: 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.
@@ -659,6 +662,45 @@ fn shape_effect(f: &Filter) -> Option<Effect> {
Some(Effect { id: id.into(), params, visible: f.visible })
}
/// The largest offset Offset Path reads, in points (the effect's own limit).
const MAX_OFFSET: f64 = 1e5;
/// Effect › Path: Offset Path with its Offset, Joins and Miter Limit
/// (https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/offset-duplicate-objects.html),
/// Outline Object and Outline Stroke, which have no settings. Offset Path's keys are LiveEffect
/// XML's: `ofst` in points, `jntp` 0 round, 1 bevel or 2 miter, and `mlim`
/// (https://community.adobe.com/questions-652/illustrator-javascript-count-area-with-offset-782386).
/// `None`: a missing, unknown or out-of-range setting, or one of another kind, which keeps the
/// drawn look; there is no default to stand in for a missing one.
fn path_effect(f: &Filter) -> Option<Effect> {
if !f.arrays.is_empty() || f.fill.is_some() || f.stroke.is_some() || f.gradient || f.pattern.is_some() {
return None;
}
let (id, params) = match f.name.as_str() {
OFFSET => {
const KEYS: [(&str, Kind); 3] = [("ofst", Kind::Real), ("jntp", Kind::Int), ("mlim", Kind::Real)];
if f.kinds.len() != KEYS.len() || KEYS.iter().any(|(key, kind)| f.kinds.get(*key) != Some(kind)) {
return None;
}
let value = |key: &str| f.params.get(key).copied().filter(|v| v.is_finite());
let offset = value("ofst").filter(|v| v.abs() <= MAX_OFFSET)?;
let miter = value("mlim").filter(|v| (1.0..=500.0).contains(v))?;
let joins = match value("jntp")? {
0.0 => "round",
1.0 => "bevel",
2.0 => "miter",
_ => return None,
};
("path.offsetPath", json!({"offset": offset, "joins": joins, "miterLimit": miter}))
}
// Outline Stroke without a `width`: the stroke it is on, or the object's, gives it.
OUTLINE_STROKE if f.kinds.is_empty() => ("path.outlineStroke", json!({})),
OUTLINE_TYPE if f.kinds.is_empty() => ("path.outlineObject", json!({})),
_ => return None,
};
Some(Effect { id: id.into(), params, visible: f.visible })
}
/// The live effect filter `f` is, when it is one read here.
fn effect(f: &Filter) -> Option<Effect> {
if let Some(stylized) = stylize(f) {
@@ -684,15 +726,7 @@ fn effect(f: &Filter) -> Option<Effect> {
}),
)
}
// `jntp`: 0 round, 1 bevel, 2 miter (the joins LiveEffect XML writes in public scripts).
OFFSET => (
"path.offsetPath",
json!({
"offset": param("ofst").unwrap_or(10.0).clamp(-1e5, 1e5),
"joins": match param("jntp").map(|v| v as i64) { Some(0) => "round", Some(1) => "bevel", _ => "miter" },
"miterLimit": param("mlim").unwrap_or(4.0).clamp(1.0, 500.0),
}),
),
OFFSET | OUTLINE_TYPE | OUTLINE_STROKE => return path_effect(f),
// Art space has y up: a move up is a negative `moveV` in the document.
TRANSFORM => (
"distort.transform",
@@ -879,6 +913,9 @@ fn style(filters: &[Filter]) -> ArtStyle {
| "convertToShape.rectangle"
| "convertToShape.roundedRectangle"
| "convertToShape.ellipse"
| "path.offsetPath"
| "path.outlineObject"
| "path.outlineStroke"
)
});
st
+124 -2
View File
@@ -458,6 +458,128 @@ fn an_effect_not_read_here_keeps_the_drawn_look() {
assert!(!art_styles(data.as_bytes())["Odd"].plain);
}
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"))
}
fn offset(ofst: f64, jntp: i64, mlim: f64) -> String {
basic("Adobe Offset Path", &format!("{ofst} /Real (ofst) ,\n{mlim} /Real (mlim) ,\n{jntp} /Int (jntp) ,\n"))
}
/// Style `Fx`: `effects` on the whole object over a stack of a fill (with `on_fill` before its
/// fill style, `after_fill` after it) and a stroke (with `on_stroke` and `after_stroke`).
fn path_style(effects: &[String], on_fill: &[String], after_fill: &[String], on_stroke: &[String], after_stroke: &[String]) -> ArtStyle {
let item = |before: &[String], paint: String, after: &[String]| {
if before.is_empty() && after.is_empty() {
return paint;
}
compound("Chain Style Filter", &[before.concat(), paint, after.concat()])
};
let stack = compound(
"Stack Style Filter",
&[
item(on_stroke, stroke("0 0 0 1 0 0 0 XA"), after_stroke),
item(on_fill, fill("0 0 0 0 1 0 0 Xa"), after_fill),
basic("Conduit Filter", ""),
],
);
let root = compound("Chain Style Filter", &[effects.concat(), stack]);
let data = format!("/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{root}");
art_styles(data.as_bytes()).remove("Fx").unwrap()
}
fn ids(effects: &[Effect]) -> Vec<&str> {
effects.iter().map(|e| e.id.as_str()).collect()
}
#[test]
fn offset_path_on_the_object_is_read_with_its_settings() {
// GUARDS: an object with Offset Path kept its drawn look, and the effect was read only on type.
let st = path_style(&[offset(4.0, 1, 6.0)], &[], &[], &[], &[]);
assert!(st.plain && st.simple, "{st:?}");
let [e] = st.effects.as_slice() else { panic!("{:?}", st.effects) };
assert_eq!((e.id.as_str(), &e.params), ("path.offsetPath", &json!({"offset": 4.0, "joins": "bevel", "miterLimit": 6.0})));
assert!(e.visible);
for (jntp, joins) in [(0, "round"), (2, "miter")] {
let st = path_style(&[offset(-5.0, jntp, 7.0)], &[], &[], &[], &[]);
assert_eq!((&st.effects[0].params["joins"], &st.effects[0].params["offset"]), (&json!(joins), &json!(-5.0)));
}
}
#[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.
let outline = basic("Adobe Outline Type", "");
let outline_stroke = basic("Adobe Outline Stroke", "");
let st = path_style(&[outline.clone(), outline_stroke.clone()], &[], &[], &[], &[]);
assert!(st.plain && st.simple, "{st:?}");
assert_eq!(ids(&st.effects), ["path.outlineObject", "path.outlineStroke"]);
// No `width`: the stroke gives it.
assert!(st.effects.iter().all(|e| e.params == json!({})), "{:?}", st.effects);
// On a fill and on a stroke: Outline Object before the item's own style, Outline Stroke after it.
let (before, after) = (std::slice::from_ref(&outline), std::slice::from_ref(&outline_stroke));
let st = path_style(&[], before, after, before, after);
assert!(st.plain && !st.simple, "{st:?}");
// Bottom first: the fill, then the stroke.
assert!(matches!(st.items.as_slice(), [StyleItem::Fill(_), StyleItem::Stroke { .. }]), "{:?}", st.items);
for look in &st.looks {
assert_eq!(ids(&look.effects), ["path.outlineObject", "path.outlineStroke"]);
}
}
#[test]
fn path_effects_keep_their_order_and_visibility() {
let hidden = offset(3.0, 0, 4.0).replacen("1 /Visible", "0 /Visible", 1);
let st = path_style(&[hidden, offset(6.0, 1, 4.0)], &[], &[], &[], &[]);
assert!(st.plain && st.simple, "{st:?}");
let offsets: Vec<_> = st.effects.iter().map(|e| (e.params["offset"].as_f64(), e.visible)).collect();
assert_eq!(offsets, [(Some(3.0), false), (Some(6.0), true)]);
// On a fill and a stroke, with another effect after it.
let transform = basic("Adobe Transform", "2 /Int (numCopies) ,\n");
let after = std::slice::from_ref(&transform);
let st = path_style(&[], &[offset(2.0, 2, 4.0)], after, &[offset(-1.0, 0, 4.0)], after);
assert!(st.plain, "{st:?}");
assert_eq!(ids(&st.looks[0].effects), ["path.offsetPath", "distort.transform"]);
assert_eq!(ids(&st.looks[1].effects), ["path.offsetPath", "distort.transform"]);
assert_eq!((st.looks[0].effects[0].params["offset"].as_f64(), st.looks[1].effects[0].params["offset"].as_f64()), (Some(2.0), Some(-1.0)));
}
#[test]
fn offset_path_with_settings_not_read_keeps_the_drawn_look() {
// GUARDS: a missing setting was read as 10 pt, miter or 4, and an out-of-range one was clamped.
let complete = "4 /Real (ofst) ,\n1 /Int (jntp) ,\n6 /Real (mlim) ,\n";
let bodies = [
String::new(),
"4 /Real (ofst) ,\n".into(),
"1 /Int (jntp) ,\n".into(),
"4 /Real (ofst) ,\n1 /Int (jntp) ,\n".into(),
format!("{complete}1 /Int (later) ,\n"),
format!("{complete}(x) /UnicodeString (DisplayString) ,\n"),
complete.replace("/Int (jntp)", "/Real (jntp)"),
complete.replace("4 /Real (ofst)", "(4) /Real (ofst)"),
complete.replace("4 /Real (ofst)", "1e999 /Real (ofst)"),
complete.replace("4 /Real (ofst)", "100001 /Real (ofst)"),
complete.replace("6 /Real (mlim)", "0.5 /Real (mlim)"),
complete.replace("6 /Real (mlim)", "501 /Real (mlim)"),
complete.replace("1 /Int (jntp)", "3 /Int (jntp)"),
complete.replace("1 /Int (jntp)", "-1 /Int (jntp)"),
complete.replace("1 /Int (jntp)", "0.5 /Int (jntp)"),
];
for body in &bodies {
let filter = basic("Adobe Offset Path", body);
let one = std::slice::from_ref(&filter);
for st in [path_style(one, &[], &[], &[], &[]), path_style(&[], one, &[], &[], &[])] {
assert!(!st.plain, "{body:?}: {st:?}");
}
}
assert!(path_style(&[basic("Adobe Offset Path", complete)], &[], &[], &[], &[]).plain);
// The outline effects have no settings.
for name in ["Adobe Outline Type", "Adobe Outline Stroke"] {
assert!(!path_style(&[basic(name, "1 /Real (width) ,\n")], &[], &[], &[], &[]).plain, "{name}");
assert!(!path_style(&[basic(name, "/Array : 1 /Real , ; (width) ,\n")], &[], &[], &[], &[]).plain, "{name}");
}
}
#[test]
fn a_hidden_fill_comes_in_hidden() {
let hidden = fill("0 0 0 1 0 0 0 Xa").replacen("1 /Visible", "0 /Visible", 1);
@@ -555,8 +677,8 @@ fn convert_to_shape_keeps_its_place_among_effects_and_its_visibility() {
assert!(st.plain, "{st:?}");
let ids: Vec<_> = st.effects.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["convertToShape.rectangle", "path.offsetPath"]);
// Offset Path isn't read on objects other than type yet.
assert!(!st.simple);
// Offset Path is read on any object too.
assert!(st.simple);
// A hidden one stays, hidden, with its settings.
let hidden = shape(&format!("1 /Int (Shape) ,\n0 /Real (Absolute) ,\n{SIZES}")).replacen("1 /Visible", "0 /Visible", 1);
let st = shape_style(&[hidden]);
+44
View File
@@ -2405,6 +2405,50 @@ fn an_object_with_convert_to_shape_comes_in_as_itself_with_the_effect_live() {
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
#[test]
fn an_object_with_path_effects_comes_in_as_itself_with_them_live() {
// GUARDS: an object with Offset Path, Outline Object or Outline Stroke came in as its drawn look.
let styles = |offset: &str| {
format!(
"%AI9_BeginArtStyles\n\
/KnownStyle :\n(Rim) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Adobe Offset Path) 1 0 /Filter ,\n1 /Visible ,\n(OffsetPath.aip) /PluginFileName ,\n(Offset Path) /Title ,\n\
/Dictionary : /NotRecorded ,\n{offset}; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Adobe Outline Type) 1 0 /Filter ,\n0 /Visible ,\n(ArtConverters.aip) /PluginFileName ,\n(Outline Object) /Title ,\n\
/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Adobe Outline Stroke) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n\
%AI9_EndArtStyles\n"
)
};
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 (Rim) XW\n", rect_path(16.0, 16.0, 64.0, 54.0));
let offset = "4 /Real (ofst) ,\n6 /Real (mlim) ,\n1 /Int (jntp) ,\n";
let r = read_ok(&format!("{}{}", styles(offset), layer(&art_)));
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}");
let b = a[0].geometric_bounds().unwrap();
assert_eq!((b.width(), b.height()), (40.0, 30.0), "the object keeps its own path");
let effects: Vec<_> = a[0].appearance.effects.iter().map(|e| (e.id.as_str(), e.params.clone(), e.visible)).collect();
assert_eq!(
effects,
[
("path.offsetPath", serde_json::json!({"offset": 4.0, "joins": "bevel", "miterLimit": 6.0}), true),
("path.outlineObject", serde_json::json!({}), false),
("path.outlineStroke", serde_json::json!({}), true),
]
);
// An Offset Path without its Miter Limit: the drawn look.
let r = read_ok(&format!("{}{}", styles("4 /Real (ofst) ,\n1 /Int (jntp) ,\n"), layer(&art_)));
let a = art(&r.doc);
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
#[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.
+7 -2
View File
@@ -3304,14 +3304,18 @@ fn comic_style() -> String {
let rim = compound(
"Chain Style Filter",
&[
basic("Adobe Offset Path", "3 /Real (ofst) ,\n0 /Int (jntp) ,\n"),
basic("Adobe Offset Path", "3 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n"),
fill("0 0 0 0 1 1 1 Xa"),
basic("Adobe Transform", "6 /Int (numCopies) ,\n1 /Real (moveH_Pts) ,\n"),
],
);
let red = compound(
"Chain Style Filter",
&[fill("0 1 1 0 1 0 0 Xa"), basic("Blend Style Filter", "/BlendStyle : 1 0.5 0 0 0 Xy\n/Def ;\n (BlendStyle) ,\n")],
&[
basic("Adobe Outline Type", ""),
fill("0 1 1 0 1 0 0 Xa"),
basic("Blend Style Filter", "/BlendStyle : 1 0.5 0 0 0 Xy\n/Def ;\n (BlendStyle) ,\n"),
],
);
let stack = compound("Stack Style Filter", &[red, rim, basic("Conduit Filter", "")]);
format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Comic) /Name ,\n/ActiveStyle :\n{}%AI9_EndArtStyles\n", compound("Chain Style Filter", &[stack]))
@@ -3342,6 +3346,7 @@ fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() {
let [AppearanceItem::Fill(rim), AppearanceItem::Fill(red)] = a.items.as_slice() else { panic!("{a:?}") };
assert_eq!(rim.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["path.offsetPath", "distort.transform"]);
assert_eq!((red.blend, red.opacity), (BlendMode::Multiply, 0.5));
assert_eq!(red.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["path.outlineObject"]);
assert!(!notes.iter().any(|w| w.contains("drawn look")), "{notes:?}");
};
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
File diff suppressed because one or more lines are too long
+1
View File
@@ -1082,6 +1082,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
A `.ai` or PDF whose editing data holds a variable library opens with a warning that its variables and data sets weren't read, with how many of each it has, whether its layers are read or it comes in as its PDF part (`crates/engine/src/cmd/fileio/tests_aiimport.rs`).
A `.ai` 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`).
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`).
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`).
A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482).
When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`).
A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).