mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 10:02:38 +00:00
Merge pull request #5142 from dotCooCoo/fix/ai-os10-shape
.ai import: Convert to Shape opens live on paths and type
This commit is contained in:
@@ -3439,6 +3439,18 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"/Def ;",
|
||||
"/Execution ;",
|
||||
"[5 5] 1e308 d",
|
||||
"(Adobe Shape Effects) 1 0 /Filter ,",
|
||||
"0 /Visible ,",
|
||||
"(Rectangle) /UnicodeString (DisplayString) ,",
|
||||
"2 /Int (Shape) ,",
|
||||
"1e999 /Int (Shape) ,",
|
||||
"1 /Real (Absolute) ,",
|
||||
"-1e308 /Real (RelWidth) ,",
|
||||
"1e5 /Real (RelHeight) ,",
|
||||
"NaN /Real (AbsWidth) ,",
|
||||
"0 /Real (AbsHeight) ,",
|
||||
"-9 /Real (CornerRadius) ,",
|
||||
"1 /Bool (Absolute) ,",
|
||||
"-0.5 /Real ,",
|
||||
"1.5 /Real ,",
|
||||
"-2 /Real ,",
|
||||
@@ -3470,6 +3482,50 @@ fn deflated(data: &[u8]) -> Vec<u8> {
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Art styles with Convert to Shape whose dictionary holds any of its entries, and unknown ones,
|
||||
/// as any kind, with any value (huge, tiny, not a number, a string), repeated or left out, shown
|
||||
/// or hidden, on a path, a group and type, on the whole object or on its fill.
|
||||
#[test]
|
||||
fn ai_convert_to_shape_never_panics(
|
||||
entries in prop::collection::vec((
|
||||
prop::sample::select(vec![
|
||||
"Shape", "Absolute", "AbsWidth", "AbsHeight", "RelWidth", "RelHeight", "CornerRadius", "DisplayString", "futureKey",
|
||||
]),
|
||||
prop::sample::select(vec!["Int", "Real", "Bool", "UnicodeString", "String"]),
|
||||
prop_oneof![
|
||||
(-1e4f64..1e4).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..16),
|
||||
visible in any::<bool>(),
|
||||
on_fill in any::<bool>(),
|
||||
) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let keys: String = entries.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect();
|
||||
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 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 effect = basic("Adobe Shape Effects", &keys).replacen("1 /Visible", if visible { "1 /Visible" } else { "0 /Visible" }, 1);
|
||||
let bare = compound("Chain Style Filter", &[effect.clone(), basic("Conduit Filter", "")]);
|
||||
let root = if on_fill {
|
||||
compound("Chain Style Filter", &[compound("Stack Style Filter", &[compound("Chain Style Filter", &[fill, effect]), basic("Conduit Filter", "")])])
|
||||
} else {
|
||||
compound("Chain Style Filter", &[effect, compound("Stack Style Filter", &[fill, basic("Conduit Filter", "")])])
|
||||
};
|
||||
let styles = format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{root}/KnownStyle :\n(Bare) /Name ,\n/ActiveStyle :\n{bare}%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 (Bare) 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("Convert to Shape 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("Convert to Shape art style in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
/// Hostile editing data in Illustrator EPS and `.ai` files: operators in any order with any
|
||||
/// operands, sections and dictionaries left open, images of any size.
|
||||
#[test]
|
||||
|
||||
@@ -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.
|
||||
@@ -609,6 +610,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 +712,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 +799,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 +870,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1076,6 +1076,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
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`).
|
||||
A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).
|
||||
In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points.
|
||||
Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).
|
||||
`.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
|
||||
Reference in New Issue
Block a user