.ai import: Convert to Shape opens live on paths and type

Squashed from #5142 onto main.
This commit is contained in:
dotCooCoo
2026-11-02 12:03:54 +07:00
committed by bflatastic
parent 664fe8a12b
commit e130cf3e0c
7 changed files with 341 additions and 4 deletions
+70 -4
View File
@@ -59,6 +59,7 @@ const OFFSET: &str = "Adobe Offset Path"; // brand-ok: the filter's name in the
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";
const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files
const CHAIN: &str = "Chain Style Filter";
const STACK: &str = "Stack Style Filter";
@@ -99,7 +100,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 and Scribble on the object: what any object is read with. Type takes the other plain styles too (its drawn look
/// 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
/// 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.
@@ -326,6 +327,11 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
Tok::Word(w) => {
let entry = other_entry.take().flatten();
let op = std::str::from_utf8(w).unwrap_or_default();
if op == ","
&& let (Some(key), Some(f)) = (entry, filters.last_mut())
{
f.kinds.insert(key, Kind::Other);
}
if op == "[" {
array = Some(vec![]);
continue;
@@ -609,6 +615,55 @@ fn stylize(f: &Filter) -> Option<Option<(&'static str, serde_json::Value)>> {
})
}
/// The largest size or radius Convert to Shape reads, in points.
const MAX_SHAPE_SIZE: f64 = 1e5;
/// Convert to Shape: Rectangle, Rounded Rectangle or Ellipse, with an absolute size or one relative
/// to the object, and a corner radius
/// (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html).
/// The keys are LiveEffect XML's: `Shape` 0 Rectangle, 1 Rounded Rectangle, 2 Ellipse
/// (https://community.adobe.com/questions-652/effect-convert-to-shape-ellipse-in-javascript-761645),
/// `Absolute` 1 for an absolute `AbsWidth` × `AbsHeight`, else `RelWidth` and `RelHeight` added to
/// each side, and `CornerRadius`; a missing size is 18 pt a side or 36 pt, and a missing radius
/// 9 pt. `None`: a key, a kind or a value that isn't read, which keeps the drawn look.
fn shape_effect(f: &Filter) -> Option<Effect> {
const SIZES: [&str; 5] = ["AbsWidth", "AbsHeight", "RelWidth", "RelHeight", "CornerRadius"];
let known = f.kinds.iter().all(|(key, kind)| match key.as_str() {
"DisplayString" => *kind == Kind::Other,
"Shape" => *kind == Kind::Int,
key => (key == "Absolute" || SIZES.contains(&key)) && *kind == Kind::Real,
});
if !known || !f.arrays.is_empty() || f.fill.is_some() || f.stroke.is_some() || f.gradient || f.pattern.is_some() {
return None;
}
// A written size is a finite number from `min`; a missing one is `default`.
let size = |key: &str, min: f64, default: f64| match f.kinds.get(key) {
None => Some(default),
Some(_) => f.params.get(key).copied().filter(|v| v.is_finite() && (min..=MAX_SHAPE_SIZE).contains(v)),
};
let shape = f.params.get("Shape").copied().filter(|_| f.kinds.get("Shape") == Some(&Kind::Int))?;
let absolute = match f.params.get("Absolute").copied() {
Some(0.0) => false,
Some(1.0) => true,
_ => return None,
};
let (rel_w, rel_h) = (size("RelWidth", -MAX_SHAPE_SIZE, 18.0)?, size("RelHeight", -MAX_SHAPE_SIZE, 18.0)?);
let (abs_w, abs_h, radius) = (size("AbsWidth", 0.0, 36.0)?, size("AbsHeight", 0.0, 36.0)?, size("CornerRadius", 0.0, 9.0)?);
let id = match shape {
0.0 => "convertToShape.rectangle",
1.0 => "convertToShape.roundedRectangle",
// A relative Ellipse in the file is drawn round the enlarged box, and VectorCraft's inside
// it: it keeps its drawn look.
2.0 if absolute => "convertToShape.ellipse",
_ => return None,
};
let mut params = json!({"relative": !absolute, "extraW": 2.0 * rel_w, "extraH": 2.0 * rel_h, "width": abs_w, "height": abs_h});
if id == "convertToShape.roundedRectangle" {
params["radius"] = json!(radius);
}
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) {
@@ -662,6 +717,7 @@ fn effect(f: &Filter) -> Option<Effect> {
GAUSSIAN => ("blur.gaussian", json!({"radius": param("blur").unwrap_or(5.0).clamp(0.0, 1000.0)})),
// 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),
_ => return None,
};
Some(Effect { id: id.into(), params, visible: f.visible })
@@ -748,9 +804,11 @@ impl Builder<'_> {
(look.blend, look.opacity) = (super::read::blend_mode(mode), opacity);
}
}
// Convert to Shape on one fill or stroke isn't read yet: what it draws there is
// the drawn look's.
_ => match effect(c) {
Some(e) => look.effects.push(e),
None => self.plain = false,
Some(e) if !e.id.starts_with("convertToShape.") => look.effects.push(e),
_ => self.plain = false,
},
}
}
@@ -817,7 +875,15 @@ fn style(filters: &[Filter]) -> ArtStyle {
&& st.effects.iter().all(|e| {
matches!(
e.id.as_str(),
"stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "stylize.roundCorners" | "stylize.scribble"
"stylize.dropShadow"
| "stylize.innerGlow"
| "stylize.outerGlow"
| "stylize.feather"
| "stylize.roundCorners"
| "stylize.scribble"
| "convertToShape.rectangle"
| "convertToShape.roundedRectangle"
| "convertToShape.ellipse"
)
});
st
+136
View File
@@ -469,3 +469,139 @@ fn a_hidden_fill_comes_in_hidden() {
assert!(st.plain, "{st:?}");
assert_eq!(st.looks.iter().map(|l| l.visible).collect::<Vec<_>>(), [false, true]);
}
/// A Convert to Shape filter whose dictionary holds `keys`.
fn shape(keys: &str) -> String {
basic("Adobe Shape Effects", &format!("(Shape) /UnicodeString (DisplayString) ,\n{keys}"))
}
/// The style `Boxed`: a fill and a stroke, and `effects` on the whole object after them.
fn shape_style(effects: &[String]) -> ArtStyle {
let stack = compound(
"Stack Style Filter",
&[
basic("Stroke Style Filter", "/StrokeStyle : 0 R\n0 0 0 1 0 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n"),
fill("0 0 0 0 1 1 1 Xa"),
],
);
let mut kids = vec![stack];
kids.extend_from_slice(effects);
let data = format!("/KnownStyle :\n(Boxed) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &kids));
art_styles(data.as_bytes()).remove("Boxed").unwrap_or_default()
}
/// Every setting of a Relative or Absolute size, with a corner radius.
const SIZES: &str = "150 /Real (AbsWidth) ,\n40 /Real (AbsHeight) ,\n20 /Real (RelWidth) ,\n6 /Real (RelHeight) ,\n15 /Real (CornerRadius) ,\n";
#[test]
fn convert_to_shape_comes_in_live_with_its_settings() {
// GUARDS: `Adobe Shape Effects` wasn't read, so an object with it kept its drawn look.
for (keys, id, want) in [
(
format!("0 /Int (Shape) ,\n0 /Real (Absolute) ,\n{SIZES}"),
"convertToShape.rectangle",
json!({"relative": true, "extraW": 40.0, "extraH": 12.0, "width": 150.0, "height": 40.0}),
),
(
format!("1 /Int (Shape) ,\n0 /Real (Absolute) ,\n{SIZES}"),
"convertToShape.roundedRectangle",
json!({"relative": true, "extraW": 40.0, "extraH": 12.0, "width": 150.0, "height": 40.0, "radius": 15.0}),
),
(
format!("2 /Int (Shape) ,\n1 /Real (Absolute) ,\n{SIZES}"),
"convertToShape.ellipse",
json!({"relative": false, "extraW": 40.0, "extraH": 12.0, "width": 150.0, "height": 40.0}),
),
(
format!("0 /Int (Shape) ,\n1 /Real (Absolute) ,\n{SIZES}"),
"convertToShape.rectangle",
json!({"relative": false, "extraW": 40.0, "extraH": 12.0, "width": 150.0, "height": 40.0}),
),
// A relative size alone, and one that shrinks the shape: each is added to every side.
(
"0 /Int (Shape) ,\n0 /Real (Absolute) ,\n20 /Real (RelWidth) ,\n6 /Real (RelHeight) ,\n".to_string(),
"convertToShape.rectangle",
json!({"relative": true, "extraW": 40.0, "extraH": 12.0, "width": 36.0, "height": 36.0}),
),
(
"0 /Int (Shape) ,\n0 /Real (Absolute) ,\n-10 /Real (RelWidth) ,\n-4 /Real (RelHeight) ,\n".to_string(),
"convertToShape.rectangle",
json!({"relative": true, "extraW": -20.0, "extraH": -8.0, "width": 36.0, "height": 36.0}),
),
// Missing sizes: 18 pt a side, 36 pt, and a 9 pt radius.
(
"0 /Int (Shape) ,\n0 /Real (Absolute) ,\n20 /Real (RelWidth) ,\n".to_string(),
"convertToShape.rectangle",
json!({"relative": true, "extraW": 40.0, "extraH": 36.0, "width": 36.0, "height": 36.0}),
),
(
"1 /Int (Shape) ,\n1 /Real (Absolute) ,\n".to_string(),
"convertToShape.roundedRectangle",
json!({"relative": false, "extraW": 36.0, "extraH": 36.0, "width": 36.0, "height": 36.0, "radius": 9.0}),
),
] {
let st = shape_style(&[shape(&keys)]);
assert!(st.plain && st.simple, "{keys}: {st:?}");
let [e] = st.effects.as_slice() else { panic!("{keys}: {:?}", st.effects) };
assert_eq!((e.id.as_str(), &e.params, e.visible), (id, &want, true), "{keys}");
assert_eq!(st.items.len(), 2, "{keys}");
}
}
#[test]
fn convert_to_shape_keeps_its_place_among_effects_and_its_visibility() {
let offset = basic("Adobe Offset Path", "2 /Int (jntp) ,\n4 /Real (mlim) ,\n4 /Real (ofst) ,\n");
let st = shape_style(&[shape(&format!("0 /Int (Shape) ,\n0 /Real (Absolute) ,\n{SIZES}")), offset.clone()]);
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);
// 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]);
assert!(st.plain && st.simple, "{st:?}");
let [e] = st.effects.as_slice() else { panic!("{:?}", st.effects) };
assert_eq!(
(e.id.as_str(), e.visible, &e.params["radius"], &e.params["extraW"]),
("convertToShape.roundedRectangle", false, &json!(15.0), &json!(40.0))
);
}
#[test]
fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() {
let valid = format!("0 /Int (Shape) ,\n0 /Real (Absolute) ,\n{SIZES}");
assert!(shape_style(&[shape(&valid)]).plain);
for keys in [
// Another shape, a shape that isn't a whole number or an /Int, and a missing one.
valid.replace("0 /Int (Shape)", "3 /Int (Shape)"),
valid.replace("0 /Int (Shape)", "0.5 /Int (Shape)"),
valid.replace("0 /Int (Shape)", "0 /Real (Shape)"),
valid.replace("0 /Int (Shape) ,\n", ""),
// A mode other than 0 and 1, or a missing one.
valid.replace("0 /Real (Absolute)", "2 /Real (Absolute)"),
valid.replace("0 /Real (Absolute) ,\n", ""),
valid.replace("0 /Real (Absolute)", "0 /Int (Absolute)"),
// Sizes that aren't finite numbers, negative absolute sizes and radii, and huge ones.
valid.replace("20 /Real (RelWidth)", "NaN /Real (RelWidth)"),
valid.replace("6 /Real (RelHeight)", "1e999 /Real (RelHeight)"),
valid.replace("6 /Real (RelHeight)", "(x) /Real (RelHeight)"),
valid.replace("150 /Real (AbsWidth)", "-150 /Real (AbsWidth)"),
valid.replace("15 /Real (CornerRadius)", "-1 /Real (CornerRadius)"),
valid.replace("20 /Real (RelWidth)", "1e6 /Real (RelWidth)"),
valid.replace("40 /Real (AbsHeight)", "40 /Int (AbsHeight)"),
// A key that isn't read.
format!("{valid}1 /Real (Future) ,\n"),
format!("{valid}(x) /String (Future) ,\n"),
// A relative Ellipse.
valid.replace("0 /Int (Shape)", "2 /Int (Shape)"),
] {
let st = shape_style(&[shape(&keys)]);
assert!(!st.plain, "{keys}: {st:?}");
}
// On one fill: the drawn look, for type too.
let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), shape(&valid)]);
let data =
format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])]));
assert!(!art_styles(data.as_bytes())["Odd"].plain);
}
+39
View File
@@ -2128,6 +2128,45 @@ 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);
}
#[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.
let styles = |keys: &str| {
format!(
"%AI9_BeginArtStyles\n\
/KnownStyle :\n(Boxed) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Adobe Shape Effects) 1 0 /Filter ,\n1 /Visible ,\n(ShapeEffect.aip) /PluginFileName ,\n(Shape) /Title ,\n\
/Dictionary : /NotRecorded ,\n(Rectangle) /UnicodeString (DisplayString) ,\n{keys}; /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, 90.0, 60.0).replace('\n', "\n%_");
// The drawn look is the shape: 20 pt wider on each side and 6 pt taller.
let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Boxed) XW\n", rect_path(0.0, 14.0, 110.0, 66.0));
let keys = "0 /Int (Shape) ,\n150 /Real (AbsWidth) ,\n0 /Real (Absolute) ,\n40 /Real (AbsHeight) ,\n20 /Real (RelWidth) ,\n15 /Real (CornerRadius) ,\n6 /Real (RelHeight) ,\n";
let r = read_ok(&format!("{}{}", styles(keys), 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()), (70.0, 40.0), "the object keeps its own path");
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
let [e] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance.effects) };
assert_eq!(
(e.id.as_str(), e.params["relative"].as_bool(), e.params["extraW"].as_f64(), e.params["extraH"].as_f64()),
("convertToShape.rectangle", Some(true), Some(40.0), Some(12.0))
);
// A shape that isn't read: the drawn look.
let r = read_ok(&format!("{}{}", styles(&keys.replace("0 /Int (Shape)", "7 /Int (Shape)")), 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.
+38
View File
@@ -2956,6 +2956,44 @@ fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() {
check(&r.document, &r.warnings);
}
#[test]
fn type_with_convert_to_shape_comes_in_as_type_with_it_live() {
// GUARDS: `Adobe Shape Effects` wasn't read, and type with it came in without its art style,
// the style's drawing beside it as paths.
let shape = "/BasicFilter :\n(Adobe Shape Effects) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
(RoundedRectangle) /UnicodeString (DisplayString) ,\n1 /Int (Shape) ,\n0 /Real (Absolute) ,\n4 /Real (RelWidth) ,\n\
2 /Real (RelHeight) ,\n3 /Real (CornerRadius) ,\n; /Dict ;\n /Part ,\n";
let stack = "/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";
let styles = format!(
"%AI9_BeginArtStyles\n/KnownStyle :\n(Boxed) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
{shape}{stack};\n /Part ,\n%AI9_EndArtStyles\n"
);
let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
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/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{drawing}\nU\n9 () XW\n");
let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
let art = native_with_text(&(styles + &layer("Art", true, &format!("{look}{own}1 (Boxed) XW\n"))), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
let (mut texts, mut paths) = (vec![], 0);
d.walk(|n| match &n.kind {
NodeKind::Text(t) => texts.push((t.plain_text(), n.appearance.effects.clone())),
NodeKind::Path { .. } => paths += 1,
_ => {}
});
let [(text, effects)] = texts.as_slice() else { panic!("{texts:?} {notes:?}") };
assert_eq!((text.as_str(), paths), ("Bang", 0), "{notes:?}");
let [e] = effects.as_slice() else { panic!("{effects:?}") };
assert_eq!(
(e.id.as_str(), &e.params),
(
"convertToShape.roundedRectangle",
&serde_json::json!({"relative": true, "extraW": 8.0, "extraH": 4.0, "width": 36.0, "height": 36.0, "radius": 3.0})
)
);
}
#[test]
fn point_type_without_a_matrix_is_placed_by_its_layout() {
// GUARDS: Illustrator 24 leaves an unturned, unscaled point type's matrix out of its frame; such