.ai import: Twist stays live on curves and groups with nothing stroked

Twist was read as a live effect only on an unstroked path or compound
path whose segments were all straight, and on those at any angle.

- A curved path or compound path, or a group, with nothing stroked and
  no guide in it now opens with its Twist live when the twist is at
  most 90° either way. Larger twists on these keep the file's drawn
  look. The 90° limit is VectorCraft's own.
- Paths and compound paths with straight segments and nothing stroked
  keep their Twist live at any angle, as before, guides included.
- Stroked objects keep the drawn look, as before.
- .ai save keeps a link to a Twist graphic style on the objects the
  reader reads it on, and leaves it out, with the style-link warning,
  on the others.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-12 10:22:29 -08:00
parent a15a4abc5c
commit 5f5058f425
5 changed files with 159 additions and 12 deletions
+75
View File
@@ -6935,6 +6935,81 @@ fn a_linked_object_past_the_object_limit_warns_that_its_link_is_left_out() {
}
}
/// A link to a graphic style with Twist is kept where the reader reads Twist: on a curve, a curved
/// compound path or a group with nothing stroked up to a quarter turn, and on a path with straight
/// segments at any angle. A stroked curve, a group with a stroked member or a guide, or a curve,
/// compound path or group twisted further, is written without the link and warns.
#[test]
fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
// A document with the object `make` builds from its appearance, `ap` with Twist at `angle`,
// linked to a graphic style of that appearance.
fn twisted(angle: f64, ap: Appearance, make: impl FnOnce(&mut Document, Appearance) -> Node) -> Document {
let mut doc = Document::new(200.0, 100.0);
let ap = Appearance { effects: vec![fx("distort.twist", serde_json::json!({"angle": angle}))], ..ap };
let mut g = vectorcraft_doc::GraphicStyle::new("Twisted", ap.clone());
g.id = 9;
doc.graphic_styles.push(g);
let mut n = make(&mut doc, ap);
n.extras_mut().graphic_style = Some(9);
put(&mut doc, n);
doc
}
let oval = || shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 60.0));
let square = || shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0));
let red = solid(Color::rgb(1.0, 0.0, 0.0));
let filled = || Appearance::basic(black_fill(), Paint::None, 0.0);
let path = |p: PathData| move |doc: &mut Document, ap: Appearance| Node::path(doc.alloc_id(), p, ap);
let ring = |doc: &mut Document, ap: Appearance| {
let outer = Node::path(doc.alloc_id(), oval(), Appearance::default());
let inner = Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(30.0, 20.0, 70.0, 50.0)), Appearance::default());
let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::NonZero });
n.appearance = ap.into();
n
};
// A group of a filled square and `second`.
let group = |second: fn(&mut Document) -> Node| {
move |doc: &mut Document, ap: Appearance| {
let first = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), filled());
let second = second(doc);
let mut n = Node::group(doc.alloc_id(), vec![Arc::new(first), Arc::new(second)]);
n.appearance = ap.into();
n
}
};
let curve = |doc: &mut Document| {
Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(50.0, 10.0, 90.0, 60.0)), Appearance::basic(black_fill(), Paint::None, 0.0))
};
let stroked_curve = |doc: &mut Document| {
let ap = Appearance::basic(black_fill(), solid(Color::rgb(1.0, 0.0, 0.0)), 2.0);
Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(50.0, 10.0, 90.0, 60.0)), ap)
};
let guide = |doc: &mut Document| {
let path = shapes::rectangle(Rect::new(50.0, 10.0, 90.0, 60.0));
Node::new(doc.alloc_id(), NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: true })
};
for (what, doc, kept) in [
("a twisted curve", twisted(45.0, filled(), path(oval())), true),
("a curve twisted a quarter turn back", twisted(-90.0, filled(), path(oval())), true),
("a square twisted three quarters of a turn", twisted(270.0, filled(), path(square())), true),
("a curve twisted past a quarter turn", twisted(120.0, filled(), path(oval())), false),
("a stroked curve", twisted(45.0, Appearance::basic(black_fill(), red.clone(), 2.0), path(oval())), false),
("a curved compound path", twisted(-60.0, filled(), ring), true),
("a curved compound path twisted past a quarter turn", twisted(120.0, filled(), ring), false),
("a twisted group", twisted(90.0, Appearance::default(), group(curve)), true),
("a group twisted past a quarter turn", twisted(135.0, Appearance::default(), group(curve)), false),
("a group with a stroked member", twisted(45.0, Appearance::default(), group(stroked_curve)), false),
("a group with a guide", twisted(45.0, Appearance::default(), group(guide)), false),
] {
let out = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), !kept, "{what}: {:?}", out.warnings);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
let style = n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str());
assert_eq!(style, kept.then_some("Twisted"), "{what}: {n:?}");
assert_eq!(n.appearance.effects.iter().any(|e| e.id == "distort.twist"), kept, "{what}: {n:?}");
}
}
/// Twist and Transform on an object that isn't linked to a graphic style are not written as a style
/// of its own: the object reads back without live effects.
#[test]
+19 -5
View File
@@ -1044,7 +1044,8 @@ fn strokes(n: &Node, depth: usize) -> bool {
|| 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?
/// Are all of path or compound path `n`'s segments straight, each anchor's handles on it? Any other
/// object, a group included, counts as no.
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),
@@ -1053,10 +1054,21 @@ fn straight(n: &Node) -> bool {
}
}
/// Is neither `n` nor anything in it a guide? Deeper than [`MAX_DEPTH`] counts as no.
fn no_guides(n: &Node, depth: usize) -> bool {
depth <= MAX_DEPTH && !n.is_guide() && n.children().into_iter().flatten().all(|c| no_guides(c, depth + 1))
}
/// The largest Twist angle, in degrees either way, read as a live effect on an object that isn't a
/// path or compound path with straight segments: VectorCraft's own limit. An object with a larger
/// Twist keeps the file's drawn look.
const MAX_TWIST: f64 = 90.0;
/// 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).
/// in it [`moves_alike`], and the style paints no gradient. Twist is read only when nothing is
/// stroked: on a path or compound path with straight segments at any angle, or up to [`MAX_TWIST`]
/// degrees either way on another object with no guide in it. Transform that scales, turns or reflects
/// is read 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()))
@@ -1065,7 +1077,9 @@ fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
&& 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.twist" => {
!stroked && (straight(object) || (no_guides(object, 0) && e.params["angle"].as_f64().is_some_and(|a| a.abs() <= MAX_TWIST)))
}
"distort.transform" => {
let p = &e.params;
!stroked
+63 -5
View File
@@ -7092,13 +7092,12 @@ fn with_red_stroke(styles: &str) -> String {
#[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.
// GUARDS: Twist was read on strokes, 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);
@@ -7106,9 +7105,7 @@ fn twist_and_a_scaling_transform_are_read_only_where_their_outline_is_known() {
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)),
] {
@@ -7133,6 +7130,67 @@ fn twist_and_a_scaling_transform_are_read_only_where_their_outline_is_known() {
assert_eq!((e.id.as_str(), e.params["angle"].as_f64()), ("distort.twist", Some(45.0)));
}
#[test]
fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_a_quarter_turn() {
// GUARDS: Twist was read only on paths and compound paths with straight segments.
// The object, `hidden` painted with its style, under its drawn look.
let object = |hidden: &str| {
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
};
let square = object(&format!("{}f\n", rect_path(20.0, 20.0, 60.0, 50.0)));
let oval = "20 35 m\n20 45 60 45 60 35 c\n60 25 20 25 20 35 c\n";
let curved = object(&format!("{oval}f\n"));
let ring = object(&format!("*u\n{oval}f\n30 35 m\n30 40 50 40 50 35 c\n50 30 30 30 30 35 c\nf\n*U\n"));
let twist = |angle: f64, paint| distort_styles("Adobe Twirl", &format!("{angle} /Real (angle) ,\n"), paint);
let read = |styles: &str, art_: &str| read_ok(&format!("{styles}{}", layer(art_)));
// The first member of a group, and a second one.
let group = |second: &str| {
let member = |s: String| s.replace('\n', "\n%_");
format!(
"0 Ae\nu\n{RED}{}f\nU\n%_0 Ae\n%_u\n%_{}{}U\n1 (Bent) XW\n",
rect_path(-1.0, -1.0, 16.0, 11.0),
member(format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))),
member(second.to_string())
)
};
let hidden_member = group(&format!("1 Xw\n{RED}{}f\n0 Xw\n", rect_path(5.0, 0.0, 15.0, 10.0)));
// A compound path, its first subpath `first` and a guide.
let with_guide = |first: &str| object(&format!("*u\n{first}f\n5 0 m\n15 10 L\n(N) *\n*U\n"));
for (what, r, members) in [
("a twisted curve", read(&twist(45.0, BLUE_FILL), &curved), 0),
("a curved compound path", read(&twist(-60.0, BLUE_FILL), &ring), 2),
("a twisted group", read(&twist(90.0, None), &distort_group()), 2),
("a group with a hidden member", read(&twist(45.0, None), &hidden_member), 2),
("a curve twisted a quarter turn back", read(&twist(-90.0, BLUE_FILL), &curved), 0),
("a square twisted three quarters of a turn, as before", read(&twist(270.0, BLUE_FILL), &square), 0),
("a straight compound path with a guide, as before", read(&twist(45.0, BLUE_FILL), &with_guide(&rect_path(20.0, 20.0, 60.0, 50.0))), 2),
] {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{what}: {a:#?}");
assert_eq!(a[0].children().map_or(0, |c| c.len()), members, "{what}: the object, not its look: {a:#?}");
let [e] = a[0].appearance.effects.as_slice() else { panic!("{what}: {:?}", a[0].appearance) };
assert_eq!(e.id, "distort.twist", "{what}");
}
// A group with a stroked member or a guide, a curved compound path with a guide, and curves and
// groups twisted past a quarter turn, keep the drawn look.
let stroked_member = group(&format!("0 1 1 0 1 0 0 XA\n2 w\n{}S\n", rect_path(5.0, 0.0, 15.0, 10.0)));
let guide_member = group("5 0 m\n15 10 L\n(N) *\n");
for (what, r) in [
("a group with a stroked member", read(&twist(45.0, None), &stroked_member)),
("a group with a guide", read(&twist(45.0, None), &guide_member)),
("a curved compound path with a guide", read(&twist(45.0, BLUE_FILL), &with_guide(oval))),
("a curve twisted past a quarter turn", read(&twist(91.0, BLUE_FILL), &curved)),
("a curve twisted half a turn back", read(&twist(-180.0, BLUE_FILL), &curved)),
("a group twisted past a quarter turn", read(&twist(135.0, None), &distort_group())),
] {
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);
}
}
#[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 -1
View File
@@ -1501,7 +1501,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
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` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`).
In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.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`).
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 object with nothing stroked whose only effect is Twist: a path or compound path with straight segments at any angle, and another path, compound path or group with no guide in it at up to 90° either way (a limit VectorCraft sets). 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).
`document.open`, `file.place` and `document.pdfInfo` reject a `cropTo`, `crop`, `password`, `colorMode` or `textAs` that is not a string, such as `"textAs": true`, and `document.pdfInfo` rejects a `thumbnailSize` that is not a number; the error names the param, and nothing opens or is placed. A param left out or given as null keeps its default (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`).
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`).