.ai import: art styles, a clipping path's own fill, compound paths of looks and a group's own transparency from the editing data

This commit is contained in:
EricKuck 2026-10-12 11:14:19 -04:00 committed by bflatastic
parent 77120ada62
commit 939bc02f6b
11 changed files with 709 additions and 19 deletions

1
Cargo.lock generated
View File

@ -4669,6 +4669,7 @@ dependencies = [
"image",
"kurbo 0.13.1",
"ruzstd",
"serde_json",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",

View File

@ -6,7 +6,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-12 | `.ai` import reads an object's art style (a fill and a stroke, variable widths too, with an Outer Glow or Drop Shadow) as its own appearance, paints a clipping path's own fill under what it clips and keeps a group's transparency as its PDF part draws them. |
| 2026-10-12 | `.ai` import reads gradients with their matrix (radials and skew too), focal point and stop order, gradient meshes and the artboards' names from the editing data. |
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |

View File

@ -2076,6 +2076,11 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"Xm",
"50 -50 -100 -100 5000 -2000 Xm",
"0 0 0 0 1e308 -1e308 Xm",
"Xd",
"0 1 Xd",
"1 0 Xd",
"XD",
"(00800080) Xt 0 XD",
"X!",
"/Mesh X!",
"/End X!",
@ -2085,6 +2090,35 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"[1 0 0 1 0 0 10 10 12 10 1 8 10 1] /N X#",
"[1e308 -1e308 0] /N X#",
"0 /E X#",
"\n%AI9_BeginArtStyles\n",
"\n%AI9_EndArtStyles\n",
"/KnownStyle :",
"(style) /Name ,",
"/ActiveStyle :",
"/CompoundFilter :",
"/BasicFilter :",
"(Stroke Style Filter) 0 0 /Filter ,",
"(Fill Style Filter) 0 0 /Filter ,",
"(Adobe Outer Glow) 1 0 /Filter ,",
"(Adobe Drop Shadow) 1 0 /Filter ,",
"1 /Visible ,",
"2 /FillOrStroke ,",
"1 /Bool (BSDashAdj) ,",
"0.75 /Real (opac) ,",
"1e308 /Real (blur) ,",
"0 /Real ,",
"; (BSVariableWidthTValuesArray) ,",
"; (BSCenteredWidthsArray) ,",
"/StrokeStyle : 0 R",
"/FillStyle : 0 O",
"/Def ;",
"/Execution ;",
"[5 5] 1e308 d",
"-0.5 /Real ,",
"1.5 /Real ,",
"-2 /Real ,",
"; (BSLeftWidthsArray) ,",
"; (BSRightWidthsArray) ,",
"[0.5 1e308 0 0 0 0 1 0 0 1] /N X#",
"(DeviceGray) /CS X#",
"\n%\n",

View File

@ -25,6 +25,8 @@ kurbo = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
ruzstd = { workspace = true }
# Live effects' params, as the art styles that name them give them.
serde_json = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }

View File

@ -23,6 +23,7 @@ mod mesh;
mod obj;
mod paint;
mod read;
mod style;
use std::io::Read as _;

View File

@ -235,6 +235,11 @@ const BLENDS: [BlendMode; 16] = [
BlendMode::Luminosity,
];
/// The name effects take for blend mode `n` of `Xy` (and of effects' `blnd`).
pub(super) fn blend_name(n: usize) -> String {
format!("{:?}", BLENDS.get(n).copied().unwrap_or(BlendMode::Normal))
}
#[derive(Debug, Default)]
struct LayerAttrs {
name: String,
@ -350,8 +355,16 @@ struct Reader<'a> {
drawn_looks: usize,
/// Nodes read from `%_` lines.
hidden_ids: HashSet<NodeId>,
/// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read.
group_ended: bool,
/// Painted copies of clipping paths that have a fill (drawn under what they clip).
clip_fills: HashSet<NodeId>,
/// Groups closed as an object's drawn look (`9 () XW`).
looks: HashSet<NodeId>,
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
mesh: Option<(super::mesh::MeshReader, bool)>,
/// The art styles (`%AI9_BeginArtStyles`), by name.
styles: HashMap<String, super::style::ArtStyle>,
/// CMYK samples converted, by value.
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
/// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes.
@ -395,6 +408,8 @@ const IGNORED: &[&str] = &[
"TZ",
"Xs",
"Xt",
// A brushed path's width at each anchor (`(…) Xt n XD`), on its hidden source path.
"XD",
"Xi",
"XI",
"`",
@ -482,6 +497,10 @@ impl<'a> Reader<'a> {
warnings: Vec::new(),
drawn_looks: 0,
hidden_ids: HashSet::new(),
clip_fills: HashSet::new(),
looks: HashSet::new(),
group_ended: false,
styles: HashMap::new(),
mesh: None,
cmyk_cache: HashMap::new(),
done: false,
@ -554,6 +573,11 @@ impl<'a> Reader<'a> {
self.define_pattern(section);
return Ok(());
}
if m == "BeginArtStyles" {
let section = self.section("EndArtStyles")?;
self.styles = super::style::art_styles(section);
return Ok(());
}
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
return self.skip_to(end);
}
@ -755,10 +779,20 @@ impl<'a> Reader<'a> {
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, .. } = f;
// A compound path of clipping paths clips its clipping group.
let clipping = matches!(kind, Kind::Compound) && !clips.is_empty();
self.group_ended = !matches!(kind, Kind::Compound);
let mut painted = None;
let node = match kind {
// A compound path holding a group (its drawn look) is that look: its paths paint apart.
Kind::Compound if kids.iter().any(|k| self.looks.contains(&k.id)) => {
let (children, clip) = self.clip_first(kids, &clips);
self.new_node(NodeKind::Group { children, clip }, &gs)
}
Kind::Compound => {
// A group in a compound path (`*u u … U *U`) only holds its paths.
let Some(n) = self.compound(ungrouped(kids), &gs) else { return Ok(()) };
// A group in a compound path (`*u u … U *U`) only holds its paths. Clipping subpaths'
// painted copies make a compound of their own, under what it clips.
let (fills, kids): (Vec<Node>, Vec<Node>) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id));
painted = self.compound(fills, &gs);
let Some(n) = self.compound(kids, &gs) else { return Ok(()) };
n
}
// A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group.
@ -770,7 +804,12 @@ impl<'a> Reader<'a> {
if clipping && let Some(f) = self.frames.last_mut() {
f.clips.push(f.children.len());
}
self.add(node, opened_hidden || hidden)
self.add(node, opened_hidden || hidden)?;
if let Some(p) = painted {
self.clip_fills.insert(p.id);
self.add(p, opened_hidden || hidden)?;
}
Ok(())
}
/// The compound path of `kids`, painted and made transparent as its subpaths say.
@ -806,7 +845,9 @@ impl<'a> Reader<'a> {
}
};
let clips = clip_node.is_some();
let children = clip_node.into_iter().chain(rest.into_iter().map(|(_, n)| n)).map(Arc::new).collect();
// A clipping path's own fill paints under the art it clips.
let (fills, rest): (Vec<Indexed>, Vec<Indexed>) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id));
let children = clip_node.into_iter().chain(fills.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect();
(children, clips)
}
@ -880,6 +921,7 @@ impl<'a> Reader<'a> {
if !matches!(w, "Xy" | "Xd" | "XW" | "BB") {
self.after_object = false;
}
let group_ended = std::mem::take(&mut self.group_ended);
let vals = self.operands();
let vals = self.take_head(vals);
if let Some(d) = self.gradient_def.as_mut() {
@ -982,6 +1024,14 @@ impl<'a> Reader<'a> {
"d" => self.dash(&vals),
// Transparency and state.
"Xy" => self.transparency(&vals),
// A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as
// the file's PDF part draws it. A look's group (`9 () XW`) has no state of its own.
"Xd" if group_ended => {
let gs = self.gs.clone();
if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. })) {
(n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout);
}
}
"Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0),
"A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0),
"XW" => self.style_marker(&vals, hidden),
@ -1361,12 +1411,18 @@ impl<'a> Reader<'a> {
}
/// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its
/// drawn look before it. Other markers end what follows an object.
/// drawn look before it (all on `%_` lines inside another such object). Other markers end what
/// follows an object.
fn style_marker(&mut self, vals: &[V], hidden: bool) {
let code = Self::nums(vals).last().copied().unwrap_or(0.0);
let style = vals.iter().rev().find_map(V::text).unwrap_or_default();
let after = std::mem::take(&mut self.after_object);
if hidden || code != 1.0 || style.is_empty() || !after {
if code == 9.0
&& let Some(look) = self.frames.last().and_then(|f| f.children.last()).filter(|n| matches!(n.kind, NodeKind::Group { .. }))
{
self.looks.insert(look.id);
}
if code != 1.0 || style.is_empty() || !after {
return;
}
let Some(f) = self.frames.last_mut() else { return };
@ -1374,13 +1430,29 @@ impl<'a> Reader<'a> {
let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else {
return;
};
if !self.hidden_ids.contains(&object.id) || self.hidden_ids.contains(&look.id) || !matches!(look.kind, NodeKind::Group { clip: false, .. }) {
if !self.hidden_ids.contains(&object.id)
|| self.hidden_ids.contains(&look.id) != hidden
|| !matches!(look.kind, NodeKind::Group { clip: false, .. })
{
return;
}
// A style read here: the object, painted as it says.
if let Some(appearance) = self.styles.get(&style).and_then(|st| styled(object, st)) {
let Some(mut object) = f.children.pop() else { return };
f.children.pop();
object.appearance = appearance.into();
// Now the object itself, not a copy an enclosing look's `strip` would remove.
self.hidden_ids.remove(&object.id);
f.children.push(object);
return;
}
let is_text = slot_of(object.name.as_deref()).is_some();
let Some(object) = f.children.pop() else { return };
let Some(look) = f.children.last_mut() else { return };
strip(look, &self.hidden_ids);
// Inside a copy on `%_` lines everything is, so nothing tells the look's own copies apart.
if !hidden {
strip(look, &self.hidden_ids);
}
if is_text {
// A text object stays the type it is: over what else its look draws, or in place of a
// look that was only its type.
@ -1394,7 +1466,9 @@ impl<'a> Reader<'a> {
look.name = object.name.clone().or(look.name.take());
look.visible = object.visible;
look.locked = object.locked;
self.drawn_looks += 1;
if !hidden {
self.drawn_looks += 1;
}
}
// ---- paths -----------------------------------------------------------------------------
@ -1473,7 +1547,10 @@ impl<'a> Reader<'a> {
};
let mut items = Vec::new();
if fill && !clip {
items.push(AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(fill_paint.unwrap_or(gs.fill.clone())) }));
items.push(AppearanceItem::Fill(FillLayer {
overprint: gs.overprint_fill,
..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone()))
}));
}
if stroke && !clip {
let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.width);
@ -1493,7 +1570,28 @@ impl<'a> Reader<'a> {
if clip && let Some(f) = self.frames.last_mut() {
f.clips.push(f.children.len());
}
self.add(node, hidden)
// A clipping path's fill is painted too, under the art it clips, as the file's PDF part draws it.
let painted = clip && fill && !matches!(gs.fill, Paint::None);
let copy = painted.then(|| {
let mut copy = node.clone();
copy.id = self.doc.alloc_id();
if let NodeKind::Path { clipping, .. } = &mut copy.kind {
*clipping = false;
}
let paint = fill_paint.clone().unwrap_or_else(|| gs.fill.clone());
copy.appearance = Appearance {
items: vec![AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(paint) })],
..Appearance::default()
}
.into();
copy
});
self.add(node, hidden)?;
if let Some(copy) = copy {
self.clip_fills.insert(copy.id);
self.add(copy, hidden)?;
}
Ok(())
}
/// `/End X!`: the gradient mesh read, as one object.
@ -1836,6 +1934,28 @@ fn path_rule(n: &Node) -> FillRule {
}
}
/// `object`'s appearance with style `st`: `None` when the style isn't one this reads, or asks a
/// group for fills or strokes.
fn styled(object: &Node, st: &super::style::ArtStyle) -> Option<Appearance> {
use super::style::StyleItem;
if !st.plain || (object.is_container() && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) {
return None;
}
let mut items = vec![];
for item in &st.items {
items.push(match item {
StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())),
StyleItem::ObjectFill => AppearanceItem::Fill(object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_)))?.clone()),
StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => {
let mut layer = StrokeLayer::new(paint.clone(), *width);
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
AppearanceItem::Stroke(layer)
}
});
}
Some(Appearance { items, effects: st.effects.clone(), ..(*object.appearance).clone() })
}
/// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look).
fn strip(n: &mut Node, hidden: &HashSet<NodeId>) {
if let Some(children) = n.children_mut() {

View File

@ -0,0 +1,408 @@
//! Art styles (`%AI9_BeginArtStyles`; not publicly documented): the appearance an object's
//! `1 (style) XW` names, a chain of filters — a stack of fills and strokes round the object's
//! contents (`Conduit Filter`), then live effects, then its transparency (`Blend Style Filter`):
//!
//! ```text
//! /KnownStyle :
//! (name) /Name ,
//! /ActiveStyle :
//! /CompoundFilter :
//! (Chain Style Filter) 0 0 /Filter ,
//! … (Stack Style Filter) … (Stroke Style Filter) … (Conduit Filter) … (Fill Style Filter) …
//! (Adobe Outer Glow) 1 0 /Filter ,
//! 1 /Visible ,
//! 2 /Int (blnd) ,
//! 0.75 /Real (opac) ,
//! 5 /Real (blur) ,
//! /FillStyle : 0 O
//! c m y k r g b Xa
//! /Def ;
//! (sclr) ,
//! ```
//!
//! A style is *plain* when it is one fill and one stroke at most, in solid colours (a fill's gradient
//! is the object's own), and only effects read here (Outer Glow, Drop Shadow) after them. Such
//! objects are read as themselves with that fill, stroke and those effects; others keep their
//! drawn look. A fill's or stroke's style says whether it paints with `stroke fill Xd`.
use std::collections::HashMap;
use serde_json::json;
use vectorcraft_color::Paint;
use vectorcraft_doc::{Dash, Effect, LineCap, LineJoin, WidthProfile};
use super::lex::{Lexer, Tok};
use super::obj::V;
use super::paint;
/// The filters a plain style may chain.
const PLAIN: [&str; 7] =
["Chain Style Filter", "Stack Style Filter", "Stroke Style Filter", "Fill Style Filter", "Conduit Filter", "Blend Style Filter", GLOW];
const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files
const SHADOW: &str = "Adobe Drop Shadow"; // brand-ok: the filter's name in the files
/// A plain style's paint, as the object comes in.
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct ArtStyle {
/// The style reads as [`Self::items`] and [`Self::effects`].
pub plain: bool,
/// Its fill and stroke, bottom first.
pub items: Vec<StyleItem>,
/// Its live effects, in the order they apply.
pub effects: Vec<Effect>,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) enum StyleItem {
Fill(Paint),
/// A fill with a gradient: the object's own.
ObjectFill,
Stroke {
paint: Paint,
width: f64,
cap: LineCap,
join: LineJoin,
miter: f64,
dash: Option<Dash>,
profile: Option<WidthProfile>,
},
}
#[derive(Debug)]
struct Filter {
name: String,
visible: bool,
params: HashMap<String, f64>,
/// Its dictionary's arrays (`/Array : v /Real , … ; (key) ,`), by key.
arrays: HashMap<String, Vec<f64>>,
fill: Option<Paint>,
stroke: Option<Paint>,
width: f64,
cap: LineCap,
join: LineJoin,
miter: f64,
dash: Option<Dash>,
/// `stroke fill Xd`.
paints: (bool, bool),
gradient: bool,
}
impl Default for Filter {
fn default() -> Self {
Self {
name: String::new(),
visible: true,
params: HashMap::new(),
arrays: HashMap::new(),
fill: None,
stroke: None,
width: 1.0,
cap: LineCap::Butt,
join: LineJoin::Miter,
miter: 10.0,
dash: None,
paints: (false, false),
gradient: false,
}
}
}
/// The styles of the section `data` (between its markers), by name.
pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
let mut styles = HashMap::new();
let mut name: Option<String> = None;
let mut filters: Vec<Filter> = vec![];
let mut operands: Vec<V> = vec![];
let mut finish = |name: Option<String>, filters: &mut Vec<Filter>| {
if let Some(name) = name {
styles.insert(name, style(filters));
}
filters.clear();
};
let mut array: Option<Vec<V>> = None;
let mut dict_array: Option<Vec<f64>> = None;
let mut lex = Lexer::new(data);
while let Some(t) = lex.next_token() {
if let (Some(a), Tok::Num(v)) = (array.as_mut(), &t.tok) {
a.push(V::Num(*v));
continue;
}
match t.tok {
Tok::Num(v) => operands.push(V::Num(v)),
Tok::Str(s) | Tok::Hex(s) => operands.push(V::Str(s)),
Tok::Name(n) => match n {
b"KnownStyle" => {
finish(name.take(), &mut filters);
operands.clear();
}
b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text),
b"Filter" => filters.push(Filter { name: operands.iter().rev().find_map(V::text).unwrap_or_default(), ..Filter::default() }),
b"Visible" => {
if let Some(f) = filters.last_mut() {
f.visible = operands.iter().rev().find_map(V::num) != Some(0.0);
}
}
b"Array" => dict_array = Some(vec![]),
b"Int" | b"Real" | b"Bool" => {
let value = operands.iter().rev().find_map(V::num);
operands.clear();
if let Some(a) = dict_array.as_mut() {
a.extend(value);
continue;
}
// The key follows: `value /Real (key) ,`.
if let (Some(value), Some(Tok::Str(key))) = (value, lex.next_token().map(|t| t.tok))
&& let Some(f) = filters.last_mut()
{
f.params.insert(String::from_utf8_lossy(&key).into_owned(), value);
}
continue;
}
_ => {}
},
Tok::Word(w) => {
let op = std::str::from_utf8(w).unwrap_or_default();
if op == "[" {
array = Some(vec![]);
continue;
}
if op == "]" {
let items = array.take().unwrap_or_default();
operands.push(V::Arr(items));
continue;
}
if op == ";"
&& let Some(values) = dict_array.take()
{
if let (Some(Tok::Str(key)), Some(f)) = (lex.next_token().map(|t| t.tok), filters.last_mut()) {
f.arrays.insert(String::from_utf8_lossy(&key).into_owned(), values);
}
operands.clear();
continue;
}
if let Some(f) = filters.last_mut() {
paint_op(f, op, &operands);
}
if op == "," || op == ";" || op.starts_with('X') || op.starts_with('B') || op.len() <= 2 {
operands.clear();
}
continue;
}
Tok::Comment(_) | Tok::Data(_) | Tok::Preview(_) => {}
}
if operands.len() > 64 {
operands.clear();
}
}
finish(name, &mut filters);
styles
}
/// A paint operator of a fill's or stroke's style.
fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
let last = vals.iter().rev().find_map(V::num);
match op {
"Xa" | "k" | "g" | "x" | "Xk" | "XA" | "K" | "G" | "X" | "XK" => {
let Some((p, _)) = paint::color_op(op, vals) else { return };
if op.ends_with(|c: char| c.is_ascii_lowercase()) {
f.fill = Some(p);
} else {
f.stroke = Some(p);
}
}
"w" => f.width = last.filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(f.width),
"J" => {
f.cap = match last.map(|v| v as i64) {
Some(1) => LineCap::Round,
Some(2) => LineCap::Square,
_ => LineCap::Butt,
}
}
"j" => {
f.join = match last.map(|v| v as i64) {
Some(1) => LineJoin::Round,
Some(2) => LineJoin::Bevel,
_ => LineJoin::Miter,
}
}
"M" => f.miter = last.filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(f.miter).min(500.0),
"d" => {
let pattern: Vec<f64> = vals
.iter()
.rev()
.find_map(|v| if let V::Arr(a) = v { Some(a.iter().filter_map(V::num).collect()) } else { None })
.unwrap_or_default();
f.dash = (!pattern.is_empty() && pattern.len() <= 64 && pattern.iter().all(|v| v.is_finite() && *v >= 0.0)).then(|| Dash {
pattern,
offset: last.filter(|v| v.is_finite()).unwrap_or(0.0),
..Dash::default()
});
}
"Xd" => {
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
if let [.., stroke, fill] = n.as_slice() {
f.paints = (*stroke != 0.0, *fill != 0.0);
}
}
"Bg" => f.gradient = true,
_ => {}
}
}
/// A variable-width stroke's profile: its widths (fractions of the stroke's) at positions along the
/// path, the same both sides unless it gives each side's.
fn width_profile(arrays: &HashMap<String, Vec<f64>>) -> Option<WidthProfile> {
let at = arrays.get("BSVariableWidthTValuesArray")?;
let side = |key: &str| arrays.get(key).filter(|w| w.len() == at.len());
let (left, right) = match (side("BSLeftWidthsArray"), side("BSRightWidthsArray")) {
(Some(left), Some(right)) => (left, right),
_ => (side("BSCenteredWidthsArray")?, side("BSCenteredWidthsArray")?),
};
if at.len() > 1024 {
return None;
}
// The stroker expects the positions sorted over 0..=1 and no negative widths.
let mut points: Vec<(f64, f64, f64)> = at
.iter()
.zip(left)
.zip(right)
.filter(|((t, l), r)| t.is_finite() && l.is_finite() && r.is_finite())
.map(|((t, l), r)| (t.clamp(0.0, 1.0), l.max(0.0), r.max(0.0)))
.collect();
points.sort_by(|a, b| a.0.total_cmp(&b.0));
let uniform = points.iter().all(|(_, l, r)| (l - 1.0).abs() < 1e-9 && (r - 1.0).abs() < 1e-9);
(!uniform).then_some(WidthProfile { points })
}
fn style(filters: &[Filter]) -> ArtStyle {
let count = |name: &str| filters.iter().filter(|f| f.name == name).count();
let painted = filters.iter().rposition(|f| matches!(f.name.as_str(), "Stroke Style Filter" | "Fill Style Filter" | "Conduit Filter"));
let mut plain = count("Stroke Style Filter") <= 1 && count("Fill Style Filter") <= 1;
let mut effects = vec![];
let mut items = vec![];
// The stack lists its fills and strokes top first.
for f in filters.iter().rev().filter(|f| f.visible) {
match f.name.as_str() {
"Fill Style Filter" if f.paints.1 && f.gradient => items.push(StyleItem::ObjectFill),
"Fill Style Filter" if f.paints.1 => match &f.fill {
Some(p) => items.push(StyleItem::Fill(p.clone())),
None => plain = false,
},
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
// and the pattern, fitted, between them.
"Stroke Style Filter" if f.paints.0 && f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => plain = false,
"Stroke Style Filter" if f.paints.0 => match &f.stroke {
Some(p) if !f.gradient => items.push(StyleItem::Stroke {
paint: p.clone(),
width: f.width,
cap: f.cap,
join: f.join,
miter: f.miter,
dash: f.dash.clone(),
profile: width_profile(&f.arrays),
}),
_ => plain = false,
},
_ => {}
}
}
for (i, f) in filters.iter().enumerate() {
let effect = f.name == GLOW || f.name == SHADOW;
if !PLAIN.contains(&f.name.as_str()) && f.name != SHADOW {
plain = false;
}
// An effect inside the stack is a fill's or a stroke's, not the object's.
if effect && painted.is_some_and(|p| i < p) {
plain = false;
}
if !f.visible {
// A hidden fill or stroke leaves the object painting differently; a hidden effect, nothing.
plain &= effect;
continue;
}
let param = |k: &str| f.params.get(k).copied();
let mode = |default: usize| super::read::blend_name(param("blnd").map_or(default, |v| v.max(0.0) as usize));
let color = f.fill.as_ref().and_then(Paint::color).unwrap_or(vectorcraft_color::Color::BLACK);
match f.name.as_str() {
// `blur`'s scale isn't publicly documented: read as the Gaussian's σ, an effect's blur being 2σ.
GLOW => effects.push(Effect {
id: "stylize.outerGlow".into(),
params: json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}),
visible: true,
}),
SHADOW if param("csrc").unwrap_or(0.0) == 0.0 => effects.push(Effect {
id: "stylize.dropShadow".into(),
params: json!({
"mode": mode(1),
"opacity": param("opac").unwrap_or(0.75) * 100.0,
"blur": 2.0 * param("blur").unwrap_or(5.0),
// Undocumented; read as the dialog's offsets, not flipped like art-space y: its default 7/7 pt is
// right and down (https://helpx.adobe.com/illustrator/using/drop-shadows-glows-feathering.html).
"x": param("horz").unwrap_or(7.0),
"y": param("vert").unwrap_or(7.0),
"color": color,
}),
visible: true,
}),
// A shadow darkening the object's own colours isn't one effects describe.
SHADOW => plain = false,
_ => {}
}
}
ArtStyle { plain, items, effects }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_variable_width_stroke_keeps_its_profile() {
let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 30 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
/Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n";
let styles = art_styles(data);
let style = &styles["Tapered"];
assert!(style.plain);
let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) };
assert_eq!(*width, 30.0);
assert_eq!(profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]));
}
#[test]
fn dashes_aligned_to_corners_keep_the_drawn_look() {
// GUARDS: the look holds the fitted pattern and the solid ends the style's dash array doesn't.
let data = b"/KnownStyle :\n(Stitches) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\
/Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n\
/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n1 J 0 j 2 w 10 M [5 10 ]0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n";
assert!(!art_styles(data)["Stitches"].plain);
}
#[test]
fn a_drop_shadow_offset_down_points_down() {
// GUARDS: the document has y down; flipping `vert` like an art-space y would turn the dialog's
// default shadow (7 pt right, 7 pt down) upwards.
let data = b"/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n7 /Real (horz) ,\n7 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n";
let styles = art_styles(data);
let [shadow] = styles["Shade"].effects.as_slice() else { panic!("{:?}", styles["Shade"]) };
assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0)));
}
#[test]
fn hostile_dash_offsets_and_width_profiles_are_tamed() {
let mut f = Filter::default();
paint_op(&mut f, "d", &[V::Arr(vec![V::Num(5.0)]), V::Num(f64::NAN)]);
assert_eq!(f.dash.map(|d| d.offset), Some(0.0));
// Positions outside 0..=1 and out of order, a negative width, a non-finite one.
let arrays = HashMap::from([
("BSVariableWidthTValuesArray".to_string(), vec![1.5, -0.2, 0.5, 0.7]),
("BSCenteredWidthsArray".to_string(), vec![-1.0, 2.0, 0.5, f64::INFINITY]),
]);
assert_eq!(width_profile(&arrays).map(|p| p.points), Some(vec![(0.0, 2.0, 2.0), (0.5, 0.5, 0.5), (1.0, 0.0, 0.0)]));
}
}

View File

@ -238,6 +238,12 @@ fn compound_paths_and_clipping() {
// The clipping path goes first.
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert!(matches!(children[1].kind, NodeKind::Path { clipping: false, .. }));
// A clipping path with a fill paints it too, under the art it clips; without one, nothing.
let filled = format!("0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n{}W\nf\nQ\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0));
let doc = read_ok(&layer(&filled)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
let kinds: Vec<_> = children.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect();
assert_eq!(kinds, [(true, None), (false, Some(Color::rgb(0.0, 0.0, 1.0))), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]);
// A clipping path in a layer clips the layer.
let doc = read_ok(&layer(&format!("{}f\n{}W\nn\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc;
let NodeKind::Layer { clip, children, .. } = &doc.layers[0].kind else { panic!() };
@ -263,6 +269,20 @@ fn compound_path_takes_its_last_paths_paint() {
}
}
#[test]
fn a_group_whose_state_has_no_transparency_takes_the_current_one() {
// GUARDS: a group's own state block keeps the last `Xy`, as the PDF part draws it; a drawn look's group stays opaque.
let art_ = format!(
"{RED}0 Ae\nu\n0 0.5 0 0 0 Xy\n{}f\nU\n0 0 Xd\n6 () XW\n0 1 0 0 0 Xy\n0 Ae\nu\n0 0.5 0 0 0 Xy\n{}f\nU\n9 () XW\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0)
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
assert_eq!((a[0].opacity, a[0].children().unwrap()[0].opacity), (0.5, 0.5));
assert_eq!((a[1].opacity, a[1].children().unwrap()[0].opacity), (1.0, 0.5));
}
#[test]
fn transparency_of_objects_and_groups() {
let art_ = format!(
@ -332,6 +352,27 @@ fn gradients() {
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "the note after the object isn't the next path's paint");
}
#[test]
fn a_compound_path_holding_its_drawn_look_paints_its_paths_apart() {
// GUARDS: made one compound path, the look's paths take a single fill and the inner one punches a hole.
let art_ = format!(
"0 Ae\n*u\n0 Ae\nu\n{RED}{}f\n0 0 0 0 0 0 1 Xa\n{}f\nU\n9 () XW\n*U\n",
rect_path(0.0, 0.0, 20.0, 20.0),
rect_path(5.0, 5.0, 15.0, 15.0)
);
let a = art(&read_ok(&layer(&art_)).doc);
fn leaves(n: &Node, out: &mut Vec<Paint>) {
match n.children() {
Some(children) if !matches!(n.kind, NodeKind::Compound { .. }) => children.iter().for_each(|c| leaves(c, out)),
_ => out.push(fill(n)),
}
}
let mut painted = vec![];
a.iter().for_each(|n| leaves(n, &mut painted));
let fills: Vec<_> = painted.iter().map(Paint::color).collect();
assert_eq!(fills, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{a:#?}");
}
#[test]
fn gradients_through_a_skewed_matrix() {
// GUARDS: a sheared `Xm` leaves the matrix's columns oblique. A linear ramp runs along the
@ -369,6 +410,12 @@ fn stops_sharing_a_ramp_point_keep_their_order() {
assert_eq!(colors, [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 0.0)]);
}
#[test]
fn per_anchor_data_reads() {
let art_ = format!("{RED}{}f\n(00800080) Xt 0 XD\n%_(00004000) Xt 1 XD\n", rect_path(0.0, 0.0, 10.0, 10.0));
assert_eq!(art(&read_ok(&layer(&art_)).doc).len(), 1);
}
#[test]
fn artboards() {
let data = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
@ -804,6 +851,28 @@ fn artboards_after_what_stops_the_reading() {
assert_eq!(boards, [("one".to_string(), 0.0), ("two".to_string(), 200.0)]);
}
#[test]
fn a_compound_clipping_path_with_a_fill_paints_once_under_its_clip() {
// GUARDS: the painted copies of a compound's clipping subpaths went into the compound itself,
// doubling each subpath, so under even-odd the clip cancelled to nothing.
let art_ = format!(
"0 Ae\nq\n{RED}{}f\n1 XR\n*u\n0 0 0 0 0 0 1 Xa\n{}W\nf\n{}W\nf\n*U\nQ\n",
rect_path(0.0, 0.0, 100.0, 100.0),
rect_path(10.0, 10.0, 40.0, 40.0),
rect_path(50.0, 10.0, 80.0, 40.0),
);
let doc = read_ok(&layer(&art_)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
assert_eq!(children.len(), 3, "{children:#?}");
let NodeKind::Compound { children: clip, rule } = &children[0].kind else { panic!("{:?}", children[0].kind) };
assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd);
assert_eq!(clip.len(), 2);
assert!(clip.iter().all(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })));
let NodeKind::Compound { children: painted, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) };
assert_eq!((painted.len(), fill(&children[1]).color()), (2, Some(Color::rgb(0.0, 0.0, 1.0))));
assert_eq!(fill(&children[2]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
#[test]
fn a_radial_gradient_without_xm_is_its_bm_circle_else_its_bg_circle() {
// GUARDS: files write `Bg` as the unit circle and place the radial with `Bm`; reading `Bg` alone collapses it.
@ -821,6 +890,38 @@ fn a_radial_gradient_without_xm_is_its_bm_circle_else_its_bg_circle() {
}
/// Two art styles: `Blue`, a blue fill and a drop shadow; `Lit`, the object's gradient and an outer glow.
const ART_STYLES: &str = "%AI9_BeginArtStyles\n\
/KnownStyle :\n(Blue) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\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\
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n\
/KnownStyle :\n(Lit) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\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\n1 (G) 0 0 0 1 1 0 0 1 0 0 Bg\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\
/BasicFilter :\n(Adobe Outer Glow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n0.5 /Real (opac) ,\n2 /Real (blur) ,\n; /Dict ;\n /Part ,\n;\n\
%AI9_EndArtStyles\n";
#[test]
fn an_object_with_a_style_this_reads_comes_in_as_itself() {
let defs =
"%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!(
"0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_1 (G) 0 0 0 1 1 0 0 1 0 0 Bg\n%_10 0 0 10 5 5 Xm\n%_{hidden_rect}f\n%_0 BB\n1 (Lit) XW\n",
rect_path(0.0, 0.0, 10.0, 10.0)
);
let r = read_ok(&format!("{ART_STYLES}{defs}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
assert!(matches!(fill(&a[0]), Paint::Gradient(g) if g.swatch.as_deref() == Some("G")));
let effects: Vec<_> = a[0].appearance.effects.iter().map(|e| (e.id.as_str(), e.params["opacity"].as_f64())).collect();
assert_eq!(effects, [("stylize.outerGlow", Some(50.0))]);
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.
@ -828,6 +929,25 @@ fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
assert_eq!(art(&r.doc).len(), 1);
}
#[test]
fn a_styled_object_inside_a_drawn_look_survives_it() {
// GUARDS: an adopted styled object was read on `%_` lines; while its id stayed among those, the
// enclosing look's strip removed it.
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!(
"0 Ae\nu\n0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (Blue) XW\nU\n%_0 Ae\n%_u\n%_{hidden_rect}f\n%_U\n1 (Outer) XW\n",
rect_path(0.0, 0.0, 10.0, 10.0)
);
let r = read_ok(&format!("{ART_STYLES}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let inner = a[0].children().unwrap();
assert_eq!(inner.len(), 1, "{inner:#?}");
assert_eq!(fill(&inner[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
let shadow = &inner[0].appearance.effects[0];
assert_eq!((shadow.id.as_str(), shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), ("stylize.dropShadow", Some(3.0), Some(4.0)));
}
#[test]
fn a_gradient_mesh_reads_as_a_patch() {
let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {

View File

@ -113,7 +113,7 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
square(20, 10),
square(30, 10),
"*U".into(),
// A clipping group: its objects, then its clip path.
// A clipping group: its objects, then its clip path (filled, so it paints too).
"q".into(),
square(40, 10),
"50 10 m 60 10 L 60 20 L 50 20 L h W f".into(),
@ -128,11 +128,12 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
.join("\n");
let sub = layer("Inner", true, &square(80, 10));
let art = native(&layer("Outer", true, &format!("{body}\n{sub}")));
// The page has what shows: the clip group's square is outside its clip path.
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n");
// The page has what shows: the clip group's square is outside its clip path, which paints.
let clip_fill = "0 0 1 0 setcmykcolor 50 10 moveto 60 10 lineto 60 20 lineto 50 20 lineto closepath fill";
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n") + "\n" + clip_fill;
let r = import(&eps(&page, &art)).unwrap();
let outer = &r.document.layers[0];
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[2]", "group[1]", "other"]);
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[3]", "group[1]", "other"]);
let children = outer.children().unwrap();
assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out");
// The clip path comes first in its group.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long