.ai import: a wraparound group's graphics wrap with no offset

Every graphic in a version 7 wraparound group (`*w` … `*W`), before
or after its type, is now a wrap object with an offset of 0 instead of
the 6 pt default, and a group with no type in it marks all of its
graphics. A graphic before the type is below it, so the type flows
around it only after it is moved above. Operands on `*w` or `*W`, and
a `*w` inside a compound path, are errors.

Docs: docs/file-format-parity.md.
This commit is contained in:
dotCooCoo
2026-11-02 12:04:06 +07:00
committed by bflatastic
parent 79d5b5a0bf
commit c3093fcafb
27 changed files with 91 additions and 12 deletions
+3 -1
View File
@@ -8,7 +8,9 @@
//! canvas (16,383 points around the template box's centre) or from edge to edge of one artboard
//! is a ruler guide on that layer ([`Document::guides`]); other guide paths stay paths.
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
//! with `W` clips); a group's transparency and name follow its end.
//! with `W` clips); a group's transparency and name follow its end. A wraparound group `*w` …
//! `*W` (version 7 specification §15.4.2) is a group whose objects other than its type wrap
//! area type, with no offset.
//! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case
//! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`.
//! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`.
+7 -7
View File
@@ -98,13 +98,13 @@ impl<'a> Reader<'a> {
self.pending_unread_fill = pending_unread_fill;
self.pending_unread_stroke = pending_unread_stroke;
}
// Wraparound text: the objects after its text object are wrap objects, at the default
// options (the format gives none) unless their dictionaries give theirs.
if matches!(kind, Kind::Wrap)
&& let Some(text) = kids.iter().position(|k| matches!(k.kind, NodeKind::Text(_)) || slot_of(k.name.as_deref()).is_some())
{
for k in kids.iter_mut().skip(text + 1).filter(|k| k.extras().wrap.is_none()) {
k.extras_mut().wrap = Some(TextWrap::default());
// Wraparound text: every object in it other than type is a wrap object, before the text or
// after it, with no offset (the format gives none) unless its dictionary gives its options.
// One before the text is below it, so the text flows around it only once it is moved above.
if matches!(kind, Kind::Wrap) {
let graphics = kids.iter_mut().filter(|k| !matches!(k.kind, NodeKind::Text(_)) && slot_of(k.name.as_deref()).is_none());
for k in graphics.filter(|k| k.extras().wrap.is_none()) {
k.extras_mut().wrap = Some(TextWrap { offset: 0.0, invert: false });
}
}
// A group holding only clipping paths (`q … u <path> W n U … Q`) doesn't clip on its own: `u`/`U` don't save
+5
View File
@@ -125,6 +125,11 @@ impl<'a> Reader<'a> {
self.end_path_quietly();
self.open(Kind::Compound, hidden)?
}
// A wraparound group takes no operands and isn't an element of a compound path.
"*w" | "*W" if !vals.is_empty() => return Err(format!("`{w}` takes no operands")),
"*w" if self.frames.iter().any(|f| matches!(f.kind, Kind::Compound)) => {
return Err("a wraparound group is inside a compound path".into());
}
"*w" => {
self.end_path_quietly();
self.open(Kind::Wrap, hidden)?
+2 -2
View File
@@ -142,8 +142,8 @@ pub(super) struct LayerAttrs {
pub(super) enum Kind {
Layer(Box<LayerAttrs>),
Group,
/// Wraparound text (`*w` … `*W`, version 7 specification §15.4.2): a group whose objects after
/// its text object are wrap objects.
/// Wraparound text (`*w` … `*W`, version 7 specification §15.4.2): a group whose objects other
/// than its type are wrap objects.
Wrap,
Compound,
Clip,
+31 -2
View File
@@ -3337,12 +3337,41 @@ fn legacy_wrap_group() {
assert_eq!(kids.len(), 1, "{:?}", r.warnings);
let NodeKind::Group { children, clip: false } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) };
let wraps: Vec<(bool, bool)> = children.iter().map(|c| (matches!(c.kind, NodeKind::Text(_)), wrap_of(c).is_some())).collect();
assert_eq!(wraps, [(false, false), (true, false), (false, true), (false, true)], "{:?}", r.warnings);
assert_eq!(wrap_of(&children[2]), Some(vectorcraft_doc::TextWrap::default()));
assert_eq!(wraps, [(false, true), (true, false), (false, true), (false, true)], "objects before the text are wrap objects too: {:?}", r.warnings);
let none = Some(vectorcraft_doc::TextWrap { offset: 0.0, invert: false });
assert_eq!(wrap_of(&children[0]), none, "no offset");
assert_eq!(wrap_of(&children[2]), none, "no offset");
assert_eq!(wrap_of(&children[3]), Some(vectorcraft_doc::TextWrap { offset: 3.0, invert: true }));
assert!(!r.warnings.iter().any(|w| w.contains("*w") || w.contains("*W")), "{:?}", r.warnings);
}
/// A wraparound group with no type in it: each of its objects is a wrap object, with no offset.
#[test]
fn legacy_wrap_group_without_text() {
let body = format!("*w\n{RED}{}f\n{}f\n*W\n", rect_path(0.0, 0.0, 5.0, 5.0), rect_path(30.0, 30.0, 50.0, 50.0));
let r = read_ok(&layer(&body));
let kids = art(&r.doc);
assert_eq!(kids.len(), 1, "{:?}", r.warnings);
let NodeKind::Group { children, clip: false } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) };
let wraps: Vec<_> = children.iter().map(|c| wrap_of(c)).collect();
assert_eq!(wraps, [Some(vectorcraft_doc::TextWrap { offset: 0.0, invert: false }); 2]);
}
/// `*w` and `*W` take no operands, and a wraparound group can't be an element of a compound path.
#[test]
fn legacy_wrap_group_errors() {
let square = format!("{RED}{}f\n", rect_path(0.0, 0.0, 5.0, 5.0));
for (body, error) in [
(format!("1 *w\n{square}*W\n"), "`*w` takes no operands"),
(format!("*w\n{square}0 *W\n"), "`*W` takes no operands"),
(format!("*u\n*w\n{square}*W\n*U\n"), "a wraparound group is inside a compound path"),
(format!("*u\nu\n*w\n{square}*W\nU\n*U\n"), "a wraparound group is inside a compound path"),
] {
let e = read(stream(&layer(&body)).as_bytes()).unwrap_err();
assert!(e.contains(error), "{body:?}: {e}");
}
}
/// The printer's crop marks Illustrator draws after the last layer (`%AI3_BeginCrops`, inside
/// `userdict /AI3_noCropMarks known not { … } if`) aren't art outside the layers: the file reads
/// from its layers, without them (the crop area is `%AI3_Cropmarks`).