.ai import: objects other than type keep some Distort & Transform effects live

An art style with a Distort & Transform effect opened every object but
type as its drawn look. Objects other than type now open as themselves
with these effects live:

- Any object takes a Tweak that moves nothing and Pucker & Bloat at 0,
  beside the effects it already took.
- A path, compound path or group takes Transform without copies, or a
  Free Distort whose corners make a parallelogram, when it is the only
  effect. Neither the style nor anything in the object may paint with a
  gradient or pattern. Everything in the object must be a path, compound
  path or group without a clip, an opacity mask or effects of its own.
  When Transform scales, rotates or reflects, nothing in the object may
  be stroked.
- An unstroked path or compound path whose segments are all straight
  takes Twist when it is the only effect.

Other objects keep the drawn look, as do Zig Zag, Pucker & Bloat of
another amount, a Free Distort with other corners and Transform copies.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-06 01:20:04 +07:00
committed by bflatastic
parent c07f0087ce
commit ddb64fceb6
7 changed files with 450 additions and 25 deletions
+12 -4
View File
@@ -4547,8 +4547,9 @@ proptest! {
/// Art styles with a Distort & Transform effect (Transform, Free Distort, Pucker & Bloat, Roughen,
/// Tweak, Twist, Zig Zag) 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, on a path and on type,
/// on the whole object or on its fill.
/// with any value (huge, tiny, not a number, a string), repeated or left out, on a path, on type and
/// on a group nested any number of levels around a curved path, stroked or not, on the whole object
/// or on its fill.
#[test]
fn ai_distort_effects_never_panic(
name in prop::sample::select(vec![
@@ -4575,6 +4576,8 @@ proptest! {
],
), 0..24),
on_fill in any::<bool>(),
depth in 0usize..300,
stroked in any::<bool>(),
) {
use vectorcraft_testkit::ai;
let keys: String = entries.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect();
@@ -4589,10 +4592,15 @@ proptest! {
};
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 paint = if stroked { "0 0 1 0 K\n2 w\nB\n" } else { "f\n" };
let curve = format!("0 0 1 0 k\n20 70 m\n20 90 80 90 80 70 c\n80 50 20 50 20 70 c\n{paint}");
let nested = format!("0 Ae\n{}{curve}{}", "u\n".repeat(depth + 1), "U\n".repeat(depth + 1)).replace('\n', "\n%_");
let art = format!(
"0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{hidden}f\n1 (Fx) XW\nu\n/AI11Text :\n1 /FreeUndo ,\n;\n0 0 1 0 k\n{}f\nU\n1 (Fx) XW\n",
"0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{hidden}f\n1 (Fx) XW\nu\n/AI11Text :\n1 /FreeUndo ,\n;\n0 0 1 0 k\n{}f\nU\n1 (Fx) XW\n\
0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{nested}1 (Fx) XW\n",
ai::rect(20.0, 20.0, 80.0, 60.0),
ai::rect(100.0, 20.0, 180.0, 60.0)
ai::rect(100.0, 20.0, 180.0, 60.0),
ai::rect(20.0, 50.0, 80.0, 90.0)
);
let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", &art)));
survive("distort art style", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
+135 -2
View File
@@ -895,12 +895,76 @@ fn placed_paints(look: &Node, out: &mut HashMap<String, Paint>, depth: usize) {
}
}
/// Is `n` a path, a compound path or a group without a clip, with no opacity mask, the objects
/// whose outline the effects that move it (Transform, Twist, Free Distort) are read on? Images,
/// type (and a text object's slot, filled with type placed where the page has it) and live objects
/// draw more than their outline, and a mask stays where it is.
fn outlines_only(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { clip: false, .. })
&& n.extras().mask.is_none()
&& slot_of(n.name.as_deref()).is_none()
}
/// Does `n`, inside an object whose outline an effect moves, move with it: is it and everything in
/// it [`outlines_only`], painting with no gradient or pattern (which stay where they are placed)
/// and with no effects of its own (drawn after the outer effect, not before)? Deeper than
/// [`MAX_DEPTH`] counts as no.
fn moves_alike(n: &Node, depth: usize) -> bool {
depth <= MAX_DEPTH
&& outlines_only(n)
&& n.appearance.effects.is_empty()
&& n.appearance.items.iter().all(|i| i.effects().is_empty() && !matches!(i.paint(), Paint::Gradient(_) | Paint::Pattern { .. }))
&& n.children().into_iter().flatten().all(|c| moves_alike(c, depth + 1))
}
/// Does `n` or anything in it (to [`MAX_DEPTH`]) have a stroke that paints?
fn strokes(n: &Node, depth: usize) -> bool {
depth > MAX_DEPTH
|| n.appearance.items.iter().any(|i| !i.is_fill() && !i.paint().is_none())
|| n.children().into_iter().flatten().any(|c| strokes(c, depth + 1))
}
/// Are all of path or compound path `n`'s segments straight, each anchor's handles on it?
fn straight(n: &Node) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.h_in == a.p && a.h_out == a.p),
NodeKind::Compound { children, .. } => children.iter().all(|c| matches!(c.kind, NodeKind::Path { .. }) && straight(c)),
_ => false,
}
}
/// Is `object` one that style `st`, whose effect moves the outline, is read on? It and everything
/// in it [`moves_alike`], and the style paints no gradient. Twist is read only on an unstroked path
/// or compound path with straight segments, and Transform that scales, turns or reflects only when
/// nothing is stroked (its reference point lies on the bounds without strokes).
fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
use crate::import::ai::style::StyleItem;
let stroked = st.items.iter().any(|i| matches!(i, StyleItem::Stroke { paint, .. } if !paint.is_none()))
|| object.children().into_iter().flatten().any(|c| strokes(c, 1));
!st.items.iter().any(|i| matches!(i, StyleItem::Gradient { .. }))
&& outlines_only(object)
&& object.children().into_iter().flatten().all(|c| moves_alike(c, 1))
&& st.effects.iter().all(|e| match e.id.as_str() {
"distort.twist" => !stroked && matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && straight(object),
"distort.transform" => {
let p = &e.params;
!stroked
|| (p["scaleH"].as_f64() == Some(100.0)
&& p["scaleV"].as_f64() == Some(100.0)
&& p["rotate"].as_f64() == Some(0.0)
&& p["reflectX"].as_bool() == Some(false)
&& p["reflectY"].as_bool() == Some(false))
}
_ => true,
})
}
/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names.
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, asks a group for fills or strokes, or has Pathfinder effects on anything but a group
/// with no fills or strokes.
/// this reads, asks a group for fills or strokes, has Pathfinder effects on anything but a group
/// with no fills or strokes, or moves the outline of an object it isn't read on ([`moves_with`]).
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.
@@ -910,6 +974,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
|| (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, .. })))
|| (!slot && st.moves_outline() && !moves_with(object, st))
{
return None;
}
@@ -1038,3 +1103,71 @@ fn strip(n: &mut Node, hidden: &HashSet<NodeId>) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::ImageObject;
use vectorcraft_geom::shapes;
fn square(id: u64, fill: Paint) -> Arc<Node> {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(fill, Paint::None, 0.0)))
}
#[test]
fn only_outlines_without_gradients_move_alike() {
// GUARDS: an image's pixels and a placed gradient stay behind when an effect moves the outline.
let solid = || square(2, Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)));
assert!(moves_alike(&Node::group(NodeId(1), vec![solid(), solid()]), 1));
let gradient = square(3, Paint::Gradient(Box::new(vectorcraft_color::gradient::GradientPaint::new(Gradient::default()))));
assert!(!moves_alike(&Node::group(NodeId(1), vec![solid(), gradient]), 1));
let image =
ImageObject { key: "a".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let image = Arc::new(Node::new(NodeId(4), NodeKind::Image(Box::new(image))));
assert!(!moves_alike(&Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(5), vec![image.clone()]))]), 1));
assert!(!outlines_only(&image));
// A text object's slot, and a member with an opacity mask.
let mut slot = Node::group(NodeId(6), vec![]);
slot.name = Some(format!("{}0", super::super::document::TEXT_SLOT));
assert!(!moves_alike(&Node::group(NodeId(1), vec![solid(), Arc::new(slot)]), 1));
let mut masked = Node::clone(&solid());
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(7), vec![]), true)));
assert!(!moves_alike(&Node::group(NodeId(1), vec![solid(), Arc::new(masked)]), 1));
// A member with an effect of its own.
let mut turned = Node::clone(&solid());
turned.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.twist".into(),
params: serde_json::json!({"angle": 30.0}),
visible: true,
});
assert!(!moves_alike(&Node::group(NodeId(1), vec![solid(), Arc::new(turned)]), 1));
// A member painting a pattern, whose tiles stay where they are, and a clipping group.
let tiled = square(8, Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY });
assert!(!moves_alike(&Node::group(NodeId(1), vec![solid(), tiled]), 1));
let mut clipped = Node::group(NodeId(9), vec![solid(), solid()]);
if let NodeKind::Group { clip, .. } = &mut clipped.kind {
*clip = true;
}
assert!(!moves_alike(&Node::group(NodeId(1), vec![Arc::new(clipped)]), 1));
}
#[test]
fn an_outline_moves_with_its_effect_only_on_outlines() {
// GUARDS: a top-level image or masked path took an effect that moves the outline.
use crate::import::ai::style::ArtStyle;
let effect = |id: &str, params| vectorcraft_doc::Effect { id: id.into(), params, visible: true };
let style = |e| ArtStyle { effects: vec![e], ..ArtStyle::default() };
let twist = style(effect("distort.twist", serde_json::json!({"angle": 30.0})));
let path = square(2, Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)));
assert!(moves_with(&path, &twist));
let mut masked = Node::clone(&path);
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(7), vec![]), true)));
assert!(!moves_with(&masked, &twist));
let image =
ImageObject { key: "a".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let image = Node::new(NodeId(4), NodeKind::Image(Box::new(image)));
let distort = style(effect("distort.freeDistort", serde_json::json!({"corners": [[0.1, 0.0], [1.1, 0.0], [1.0, 1.0], [0.0, 1.0]]})));
assert!(!moves_with(&image, &distort));
assert!(moves_with(&path, &distort));
}
}
+59 -18
View File
@@ -138,7 +138,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
/// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke)
/// and some Distort & Transform effects ([`any_object_takes`]) 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,
@@ -157,6 +158,13 @@ pub(super) struct ArtStyle {
pub pathfinder: bool,
}
impl ArtStyle {
/// Do its effects move the object's outline (Transform, Twist, Free Distort)?
pub fn moves_outline(&self) -> bool {
self.effects.iter().any(|e| matches!(e.id.as_str(), "distort.transform" | "distort.twist" | "distort.freeDistort"))
}
}
#[derive(Clone, Debug, PartialEq)]
pub(super) enum StyleItem {
Fill(Paint),
@@ -1230,6 +1238,52 @@ impl Builder<'_> {
}
}
/// Is `e`, on the whole object, an effect any object is read with? Of the Distort & Transform
/// effects: Transform without copies (Random is never read), Twist, a Tweak that moves nothing,
/// Pucker & Bloat at 0, and Free Distort when its corners make a parallelogram (an affine map).
/// Zig Zag is left to type. Which objects Transform, Twist and Free Distort are read on is
/// `read::ops`' `moves_with`.
fn any_object_takes(e: &Effect) -> bool {
match e.id.as_str() {
"stylize.dropShadow"
| "stylize.innerGlow"
| "stylize.outerGlow"
| "stylize.feather"
| "stylize.roundCorners"
| "stylize.scribble"
| "convertToShape.rectangle"
| "convertToShape.roundedRectangle"
| "convertToShape.ellipse"
| "path.offsetPath"
| "path.outlineObject"
| "path.outlineStroke"
| "distort.twist"
| "distort.tweak" => true,
// Copies are drawn as one path, its fills under all its strokes, where the file paints each
// copy's fill and stroke in turn.
"distort.transform" => e.params["copies"].as_i64() == Some(0),
"distort.puckerBloat" => e.params["amount"].as_f64() == Some(0.0),
"distort.freeDistort" => affine(&e.params),
id => is_pathfinder(id),
}
}
/// Do Free Distort params `p`'s corners (top left, top right, bottom right, bottom left) make a
/// parallelogram, so that the bilinear map is affine?
fn affine(p: &serde_json::Value) -> bool {
let Some(c) = p["corners"].as_array() else { return false };
let at = |i: usize, axis: usize| c.get(i).and_then(|c| c.get(axis)).and_then(serde_json::Value::as_f64);
(0..2).all(|axis| match (at(0, axis), at(1, axis), at(2, axis), at(3, axis)) {
(Some(tl), Some(tr), Some(br), Some(bl)) => {
// The corners are fractions of the object's box, divided out of the file's rounded
// decimals; the tolerance is relative to how far apart they lie, not where.
let (lo, hi) = [tl, tr, br, bl].iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
((tl + br) - (tr + bl)).abs() <= 1e-5 * (hi - lo).max(1.0)
}
_ => false,
})
}
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 };
@@ -1274,23 +1328,10 @@ fn style(filters: &[Filter]) -> ArtStyle {
&& count(false) <= 1
&& st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. }))
&& st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty())
&& st.effects.iter().all(|e| {
matches!(
e.id.as_str(),
"stylize.dropShadow"
| "stylize.innerGlow"
| "stylize.outerGlow"
| "stylize.feather"
| "stylize.roundCorners"
| "stylize.scribble"
| "convertToShape.rectangle"
| "convertToShape.roundedRectangle"
| "convertToShape.ellipse"
| "path.offsetPath"
| "path.outlineObject"
| "path.outlineStroke"
) || is_pathfinder(&e.id)
})
&& st.effects.iter().all(any_object_takes)
// An effect that moves the outline is drawn on its own: with others, their order and what
// scales with it aren't kept.
&& (!st.moves_outline() || st.effects.len() == 1)
// Pathfinder effects on a group with no fills or strokes of its own (`styled` checks the group).
&& (!st.pathfinder || st.items.is_empty());
st
+42
View File
@@ -891,6 +891,48 @@ fn pucker_and_twist_keep_their_sense() {
assert_eq!(read_effect("Adobe Twirl", "4000 /Real (angle) ,\n"), None);
}
#[test]
fn any_object_takes_the_distort_effects_that_draw_as_its_drawn_look() {
// GUARDS: `simple` left out every Distort & Transform effect, so only type took them.
let simple = |name: &str, keys: &str| {
let st = distort_effect_style(name, keys);
assert!(st.plain, "{name}: {st:?}");
st.simple
};
assert!(simple("Adobe Twirl", "45 /Real (angle) ,\n"));
assert!(simple("Adobe Transform", &format!("0.5 /Real (scaleH_Factor) ,\n{SWITCHES}")));
assert!(simple("Adobe Scribble and Tweak", ""));
assert!(simple("Adobe Punk and Bloat", ""));
assert!(simple("Adobe Free Distort", ""));
// A slant: the bottom edge moved as far as the top one.
let src = [[0.0, 0.0], [1.0, 0.0], [0.0, -1.0], [1.0, -1.0]];
assert!(simple("Adobe Free Distort", &corners(src, [[0.2, 0.0], [1.2, 0.0], [0.0, -1.0], [1.0, -1.0]])));
assert!(!simple("Adobe Free Distort", &corners(src, [[0.2, 0.0], [1.2, 0.1], [0.0, -1.0], [1.0, -1.0]])));
assert!(!simple("Adobe Punk and Bloat", "-35 /Real (d_factor) ,\n"));
assert!(!simple("Adobe Zigzag", ""));
assert!(!simple("Adobe Transform", &format!("1 /Int (numCopies) ,\n{SWITCHES}")));
// With another effect, only type takes it.
let chain = [basic("Adobe Twirl", "45 /Real (angle) ,\n"), basic("Adobe Offset Path", "4 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n")];
let data = format!("/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &chain));
let st = art_styles(data.as_bytes()).remove("Fx").unwrap_or_default();
assert!(st.plain && !st.simple, "{st:?}");
}
#[test]
fn affine_corners_are_a_parallelogram_however_far_they_reach() {
assert!(affine(&json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]})));
assert!(affine(&json!({"corners": [[180.0, -310.0], [320.0, -300.0], [330.0, -250.0], [190.0, -260.0]]})));
assert!(!affine(&json!({"corners": [[180.0, -310.0], [320.0, -300.0], [330.0, -250.01], [190.0, -260.0]]})));
// Far from the box, a small shape is held to its own size.
assert!(!affine(&json!({"corners": [[9999.0, 0.0], [10000.0, 0.0], [10000.05, 1.0], [9999.0, 1.0]]})));
// GUARDS: a slant written in rounded decimals, in a frame of 33.333 pt, missed by a hair.
let src = [[0.0, 0.0], [33.333, 0.0], [0.0, -33.333], [33.333, -33.333]];
let slant = distort_effect_style("Adobe Free Distort", &corners(src, [[6.6667, 0.0], [40.0, 0.0], [0.0, -33.333], [33.333, -33.333]]));
assert!(slant.plain && slant.simple, "{slant:?}");
assert!(!affine(&json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]})));
assert!(!affine(&json!({})));
}
#[test]
fn roughen_keeps_the_drawn_look() {
// Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the
+200
View File
@@ -4759,6 +4759,206 @@ fn an_object_with_path_effects_comes_in_as_itself_with_them_live() {
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
/// The paint of a blue fill in a style.
const BLUE_FILL: Option<&str> = Some("0 0 0 0 0 0 1 Xa\n");
/// An art style section with style `Bent`: effect `name` with dictionary `keys` on the whole
/// object, over a fill painting with `paint`, or over a fill and stroke that paint nothing.
fn distort_styles(name: &str, keys: &str, paint: Option<&str>) -> String {
let (paints, color) = match paint {
Some(p) => ("0 1", p),
None => ("0 0", ""),
};
format!(
"%AI9_BeginArtStyles\n/KnownStyle :\n(Bent) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n({name}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /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 O\n{color}{paints} Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
;\n /Part ,\n;\n%AI9_EndArtStyles\n"
)
}
/// Free Distort's 16 entries for a 40 × 30 pt object whose source frame is its own box, with its
/// destination corners `dst` (top left, top right, bottom left, bottom right, art space).
fn free_distort_keys(dst: [[f64; 2]; 4]) -> String {
let src = [[20.0, 50.0], [60.0, 50.0], [20.0, 20.0], [60.0, 20.0]];
let mut out = String::new();
for (which, c) in [("src", src), ("dst", dst)] {
for (i, [h, v]) in c.into_iter().enumerate() {
out += &format!("{h} /Real ({which}{i}h) ,\n{v} /Real ({which}{i}v) ,\n");
}
}
out
}
#[test]
fn an_object_with_a_distort_effect_that_draws_alike_comes_in_as_itself_with_it_live() {
// GUARDS: only type took Distort & Transform effects; a path with Transform, Twist, a still Tweak,
// Pucker & Bloat at 0 or an affine Free Distort came in as its drawn look.
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 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0));
// A slanted box: its corners make a parallelogram.
let slanted = free_distort_keys([[25.0, 50.0], [65.0, 50.0], [20.0, 20.0], [60.0, 20.0]]);
for (name, keys, id) in [
("Adobe Transform", "5 /Real (moveH_Pts) ,\n0.5 /Real (rotate_Radians) ,\n0 /Bool (transformPatterns) ,\n", "distort.transform"),
("Adobe Twirl", "45 /Real (angle) ,\n", "distort.twist"),
("Adobe Scribble and Tweak", "30 /Real (horz) ,\n0 /Bool (anch) ,\n0 /Bool (in) ,\n0 /Bool (out) ,\n", "distort.tweak"),
("Adobe Punk and Bloat", "0 /Real (d_factor) ,\n", "distort.puckerBloat"),
("Adobe Free Distort", slanted.as_str(), "distort.freeDistort"),
("Adobe Free Distort", "", "distort.freeDistort"),
] {
let r = read_ok(&format!("{}{}", distort_styles(name, keys, BLUE_FILL), layer(&art_)));
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{name}: {:?}", r.warnings);
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{name}: {a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{name}: the object, not its look: {a:#?}");
let b = a[0].geometric_bounds().unwrap();
assert_eq!((b.width(), b.height()), (40.0, 30.0), "{name}: the object keeps its own path");
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)), "{name}");
let ids: Vec<_> = a[0].appearance.effects.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, [id], "{name}");
}
}
#[test]
fn an_object_with_a_distort_effect_drawn_another_way_keeps_its_drawn_look() {
// Pucker & Bloat and Zig Zag draw other outlines than the file's, a Free Distort whose corners
// aren't a parallelogram bends the curves differently, and Transform's copies paint as one path.
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 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0));
let bent = free_distort_keys([[25.0, 50.0], [65.0, 52.0], [20.0, 20.0], [60.0, 20.0]]);
for (name, keys) in [
("Adobe Punk and Bloat", "-50 /Real (d_factor) ,\n"),
("Adobe Zigzag", "3 /Real (amount) ,\n1 /Real (absoluteness) ,\n"),
("Adobe Free Distort", bent.as_str()),
("Adobe Scribble and Tweak", "30 /Real (horz) ,\n"),
("Adobe Transform", "1 /Bool (randomize) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"),
("Adobe Transform", "5 /Real (moveH_Pts) ,\n2 /Int (numCopies) ,\n0 /Bool (transformPatterns) ,\n"),
] {
let r = read_ok(&format!("{}{}", distort_styles(name, keys, BLUE_FILL), layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{name}: {a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{name}: the drawn look: {a:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{name}: {:?}", r.warnings);
}
}
/// The gradient `G`, white to black, defined for a document.
const GRADIENT_G: &str = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n%_1 0 50 0 Bs\n%_0 0 50 100 Bs\nBD\n%AI5_EndGradient\n";
#[test]
fn an_object_whose_outline_moves_away_from_its_gradient_keeps_its_drawn_look() {
// GUARDS: a gradient stays where it is placed while Transform, Twist or Free Distort moves the
// outline, so the live object lost the gradient its drawn look shows.
let gradient = Some("Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n");
let hidden_rect = rect_path(20.0, 20.0, 60.0, 50.0).replace('\n', "\n%_");
let object = format!(
"0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_1 (G) 20 35 0 40 1 0 0 1 0 0 Bg\n%_{hidden_rect}f\n%_0 BB\n1 (Bent) XW\n",
rect_path(16.0, 16.0, 64.0, 54.0)
);
let hidden = |color: &str, x: f64| format!("{color}{}f\n", rect_path(x, 0.0, x + 10.0, 10.0)).replace('\n', "\n%_");
let shaded = format!("Bb\n1 (G) 5 5 0 10 1 0 0 1 0 0 Bg\n{}f\n0 BB\n", rect_path(5.0, 0.0, 15.0, 10.0)).replace('\n', "\n%_");
let group = format!("0 Ae\nu\n{RED}{}f\nU\n%_0 Ae\n%_u\n%_{}{shaded}U\n1 (Bent) XW\n", rect_path(0.0, 0.0, 15.0, 10.0), hidden(RED, 0.0));
let slanted = free_distort_keys([[25.0, 50.0], [65.0, 50.0], [20.0, 20.0], [60.0, 20.0]]);
let moves = [
("Adobe Transform", "5 /Real (moveH_Pts) ,\n0 /Bool (transformPatterns) ,\n"),
("Adobe Twirl", "45 /Real (angle) ,\n"),
("Adobe Free Distort", slanted.as_str()),
];
for (name, keys) in moves {
// A gradient in the style, and one on a member of a group.
for (paint, art_) in [(gradient, &object), (None, &group)] {
let r = read_ok(&format!("{GRADIENT_G}{}{}", distort_styles(name, keys, paint), layer(art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{name}: {a:#?}");
assert!(a[0].appearance.effects.is_empty(), "{name}: the drawn look: {a:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{name}: {:?}", r.warnings);
}
}
// An effect that leaves the outline where it is keeps the gradient under it.
let r = read_ok(&format!("{GRADIENT_G}{}{}", distort_styles("Adobe Punk and Bloat", "", gradient), layer(&object)));
let a = art(&r.doc);
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{a:#?}");
assert!(matches!(fill(&a[0]), Paint::Gradient(_)), "{:?}", fill(&a[0]));
}
/// A group with style `Bent`, its drawn look one path, holding a red and a blue square.
fn distort_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 (Bent) 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_transform_keeps_its_members_and_the_effect() {
// GUARDS: the group came in as its drawn look, one path with no effect.
let keys = "5 /Real (moveH_Pts) ,\n0 /Bool (transformPatterns) ,\n";
let r = read_ok(&format!("{}{}", distort_styles("Adobe Transform", keys, None), layer(&distort_group())));
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
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:#?}");
let [e] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance) };
assert_eq!((e.id.as_str(), e.params["moveH"].as_f64()), ("distort.transform", Some(5.0)));
assert!(a[0].appearance.items.is_empty(), "{:?}", a[0].appearance);
}
/// `styles` (from [`distort_styles`]) with a red stroke 2 pt wide in place of the stroke that
/// paints nothing.
fn with_red_stroke(styles: &str) -> String {
styles.replace("/StrokeStyle : 0 1 0 0 0 Xy\n0 0 Xd", "/StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 1 0 0 0 Xy\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd")
}
#[test]
fn twist_and_a_scaling_transform_are_read_only_where_their_outline_is_known() {
// GUARDS: Twist was read on curves, strokes and groups, and Transform scaling a stroked object,
// where the outline VectorCraft draws hasn't been checked.
let object = |hidden: &str| {
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}f\n%_0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
};
let square = object(&rect_path(20.0, 20.0, 60.0, 50.0));
let curved = object("20 35 m\n20 45 60 45 60 35 c\n60 25 20 25 20 35 c\n");
let twist = distort_styles("Adobe Twirl", "45 /Real (angle) ,\n", BLUE_FILL);
let scale = "0.5 /Real (scaleH_Factor) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n";
let corner = distort_styles("Adobe Transform", &format!("0 /Int (pinPoint) ,\n{scale}"), BLUE_FILL);
let center = distort_styles("Adobe Transform", scale, BLUE_FILL);
let moved = distort_styles("Adobe Transform", "0 /Int (pinPoint) ,\n5 /Real (moveH_Pts) ,\n0 /Bool (transformPatterns) ,\n", BLUE_FILL);
let read = |styles: &str, art_: &str| read_ok(&format!("{styles}{}", layer(art_)));
for (what, r) in [
("a twisted curve", read(&twist, &curved)),
("a twisted stroke", read(&with_red_stroke(&twist), &square)),
("a twisted group", read(&distort_styles("Adobe Twirl", "45 /Real (angle) ,\n", None), &distort_group())),
("a stroke scaled from a corner", read(&with_red_stroke(&corner), &square)),
("a stroke scaled from the center", read(&with_red_stroke(&center), &square)),
] {
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{what}: {a:#?}");
assert!(a[0].appearance.effects.is_empty(), "{what}: the drawn look: {a:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
}
for (what, r) in [
("a twisted square", read(&twist, &square)),
("a stroke moved from a corner", read(&with_red_stroke(&moved), &square)),
("a fill scaled from a corner", read(&corner, &square)),
("a fill scaled from the center", read(&center, &square)),
] {
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{what}: {a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }) && a[0].appearance.effects.len() == 1, "{what}: {a:#?}");
}
let r = read(&twist, &square);
let a = art(&r.doc);
let [e] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance) };
assert_eq!((e.id.as_str(), e.params["angle"].as_f64()), ("distort.twist", Some(45.0)));
}
#[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.
File diff suppressed because one or more lines are too long
+1
View File
@@ -1208,6 +1208,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
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`).
A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. Pattern brushes and brushes that can't be read are left out with a note; brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.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`).
In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.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 PDF content of a `.ai` has a page for each artboard, the layers are compared with every page on its artboard (until the pages compared take 8,388,608 pixels at the comparison's scale, about 67 letter-size pages, or number 128): art that only the layers or only a later page draws is handled as art that differs on the first page is, and a text object that a later page doesn't draw is left out unless its layer doesn't print (`crates/eps/src/import/tests_native.rs`).
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`).