AI import: groups and nested groups survive

Legacy PostScript .ai files (Illustrator 3-8, and their EPS) open through
the EPS interpreter, which only recorded what was drawn: every group came
in flattened. In a file in Illustrator's format (its %AI comments, or
Illustrator named as the creator) the `u` ... `U` it writes at the top
level now open and close groups, whatever its prolog defines them as, so
groups come in nested as they were, between and inside clipping groups.

clipnest's Clip carries an optional region: without one it is a plain
group, nested in the same chain as the clips (ids given in opening order,
so a chain of clips and groups sorts by id).

Clips nested without end used to nest clipping groups past the stack
(a stack overflow in EPS and EMF/WMF import): clips and groups now nest
at most 128 deep with a warning, and each object's chain counts against
the EPS reader's drawing budget.

PDF-compatible .ai (Illustrator 9+) can't get the same: its PDF content
draws each object on its own and doesn't mark plain groups (only layers,
clipping groups and groups with opacity, blending or a mask, which
already come back); the groups live only in the private data, which is
out of scope. Documented in ROADMAP and docs/mcp.md.

Addresses Discord feedback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 17:06:57 +07:00
1 parent d66a8b6127
commit 0bdce8c633
12 files changed
+291 -46

No files matched your search

+4 -4
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -152,7 +152,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export.
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web).
@@ -211,7 +211,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 82% | global/video rulers, smart-guide preference depth, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451) | 3–5 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against more real files (two Illustrator 30 files open as vectors so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against more real files (two Illustrator 30 files open as vectors so far), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
@@ -235,7 +235,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
## Out of scope (by design or by law)
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance.
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back).
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
+33 -17
View File
@@ -1,8 +1,10 @@
//! Clipped art read from page descriptions (metafiles, PostScript) into clipping groups.
//! Clipped and grouped art read from page descriptions (metafiles, PostScript) into clipping
//! groups and groups.
//!
//! Importers record each object they draw with the chain of clips in force at the time, outermost
//! first ([`Drawn`]); [`nest`] then puts runs of objects under the same clip into one clipping
//! group, nesting groups for nested clips, keeping the drawing order.
//! Importers record each object they draw with the chain of clips (and groups) in force at the
//! time, outermost first ([`Drawn`]); [`nest`] then puts runs of objects under the same clip into
//! one clipping group (under the same group into one group), nesting groups for nested clips,
//! keeping the drawing order.
use std::sync::Arc;
@@ -11,20 +13,30 @@ use vectorcraft_geom::{BezPath, FillRule, PathData};
use crate::appearance::Appearance;
use crate::{Document, Node, NodeKind};
/// A clip: a region in document space and its rule. Each has an id unique within an import, so
/// what one clip clips can be grouped.
/// A clip: a region in document space and its rule, or a plain group (one that holds its art
/// without clipping it, such as an Illustrator file's `u` … `U`). Each has an id unique within an
/// import, so what one clip clips (or one group holds) can be grouped.
#[derive(Debug)]
pub struct Clip {
pub id: u32,
pub path: BezPath,
pub rule: FillRule,
/// The region and its rule; `None` for a plain group.
pub region: Option<(BezPath, FillRule)>,
}
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
/// for each): deeper ones are left out.
pub const MAX_NEST: usize = 128;
/// The warning for clips left out for nesting deeper than [`MAX_NEST`].
pub fn deep_clips() -> String {
format!("clips nested more than {MAX_NEST} deep were left out: what they clip is clipped by the clips around them")
}
/// Objects in drawing order, each with its clips (outermost first).
pub type Drawn = Vec<(Vec<Arc<Clip>>, Node)>;
/// The nodes of `items`, those under clips grouped into clipping groups (ids from `doc`), in
/// order.
/// The nodes of `items`, those under clips grouped into clipping groups and those in groups into
/// groups (ids from `doc`), in order.
pub fn nest(doc: &mut Document, items: Drawn) -> Vec<Arc<Node>> {
nest_at(doc, items, 0)
}
@@ -53,14 +65,18 @@ fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
out
}
/// A clipping group of `items`, clipped by `c` (their clip at `depth`).
/// A clipping group of `items`, clipped by `c` (their clip at `depth`), or their group when `c` is
/// a plain group.
fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<Node> {
let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(&c.path), Appearance::default());
if let NodeKind::Path { clipping, rule, .. } = &mut path.kind {
*clipping = true;
*rule = c.rule;
let mut children = vec![];
if let Some((bp, r)) = &c.region {
let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(bp), Appearance::default());
if let NodeKind::Path { clipping, rule, .. } = &mut path.kind {
*clipping = true;
*rule = *r;
}
children.push(Arc::new(path));
}
let mut children = vec![Arc::new(path)];
children.extend(nest_at(doc, items, depth + 1));
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: true }))
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() }))
}
@@ -92,6 +92,22 @@ fn postscript_ai_files_and_garbage() {
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
assert_eq!(s.doc().unwrap().path, None);
assert_eq!(objects(&s), 1);
// Illustrator's groups come in as groups, nested as they were.
let ai = b"%!PS-Adobe-3.0
%%Creator: Adobe Illustrator(R) 8.0
%%BoundingBox: 0 0 100 100
%%EndComments
/u {} def /U {} def /L {lineto} def /f {closepath fill} def
u 0 0 moveto 9 0 L 9 9 L f u 20 0 moveto 29 0 L 29 9 L f 40 0 moveto 49 0 L 49 9 L f U U
showpage
%%EOF
";
open(&mut s, "groups.ai", ai).unwrap();
let d = s.doc().unwrap().doc.clone();
let outer = &d.layers[0].children().unwrap()[0];
let inner = outer.children().map(|c| (c.len(), c[1].children().map(Vec::len))).unwrap();
assert!(matches!(outer.kind, vectorcraft_doc::NodeKind::Group { clip: false, .. }), "{:?}", outer.kind);
assert_eq!(inner, (2, Some(2)));
// A .ait one opens as a new untitled document.
let r = open(&mut s, "legacy.ait", ps).unwrap();
assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}");
+6 -3
View File
@@ -873,10 +873,12 @@ proptest! {
survive("mutated EPS PostScript", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
}
/// Hostile PostScript programs: operators in any order with any operands, opened and placed.
/// Hostile PostScript programs: operators in any order with any operands, opened and placed;
/// as Illustrator's too, its groups (`u` … `U`) unbalanced, deep and among clips.
#[test]
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60)) {
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n%%EndComments\n{}\nshowpage\n", tokens.join(" "));
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>()) {
let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" };
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{}\nshowpage\n", tokens.join(" "));
survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?;
let r = catch_quiet(|| {
let mut s = rich_session();
@@ -922,6 +924,7 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
"<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 1] /Function << /FunctionType 2 /C0 [0 0 0] /C1 [1 1 1] /N 1 >> >>",
"<< /PatternType 2 /Shading << /ShadingType 3 /ColorSpace /DeviceGray /Coords [0 0 0 9 9 9] /Function << /FunctionType 3 /Functions [] /Bounds [] /Encode [] >> >> >>",
"<< /ImageType 1 /Width 2 /Height 2 /BitsPerComponent 8 /ImageMatrix [2 0 0 2 0 0] /DataSource (abcdefghijkl) >>",
"u", "U", "u u u", "U U", "{ u } 300 repeat", "1 1 300 { pop u 0 0 9 9 rectclip } for",
])
.prop_map(str::to_string),
]
+66 -17
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use kurbo::{PathEl, Shape};
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::clipnest::{Clip, Drawn};
use vectorcraft_doc::clipnest::{Clip, Drawn, MAX_NEST, deep_clips};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2};
@@ -25,7 +25,7 @@ const HAIRLINE: f64 = 0.25;
const MAX_HIVAL: usize = 4095;
/// Most elements the current path may have.
const MAX_PATH: usize = 1 << 20;
/// Most path elements drawn in all.
/// Most path elements drawn in all (each object's clips and groups counting as elements too).
const MAX_DRAWN: usize = 1 << 24;
/// Samples taken of a shading function that isn't a plain interpolation.
const SHADING_SAMPLES: usize = 32;
@@ -147,8 +147,13 @@ pub(crate) struct Out {
pub page: Affine,
/// The page (the bounding box) in document space.
pub frame: Rect,
/// Clip and group ids are given in the order they open, so a chain of both sorts by id.
next_clip: u32,
/// Path elements drawn so far (see [`MAX_DRAWN`]).
/// The groups open (outermost first), and how many more opened past [`MAX_NEST`] (their art
/// goes in the innermost group kept).
groups: Vec<Arc<Clip>>,
too_deep: usize,
/// Path elements (and the clips and groups of each object) drawn so far (see [`MAX_DRAWN`]).
elements: usize,
/// The path the last object was filled with: a stroke of the same path right after joins it.
merge: Option<BezPath>,
@@ -175,6 +180,8 @@ impl Out {
page,
frame,
next_clip: 0,
groups: vec![],
too_deep: 0,
elements: 0,
merge: None,
cmyk: 0,
@@ -190,10 +197,48 @@ impl Out {
}
}
/// Add `node` under `clips`; `None` when it was left out.
/// Open a group: what is drawn until it ends ([`Self::end_group`]) goes in it.
pub fn begin_group(&mut self) {
if self.groups.len() >= MAX_NEST {
self.too_deep += 1;
self.warn(&format!("groups nested more than {MAX_NEST} deep were read as part of the group around them"));
return;
}
self.next_clip += 1;
self.groups.push(Arc::new(Clip { id: self.next_clip, region: None }));
}
/// End the innermost group open (an end without a beginning ends nothing).
pub fn end_group(&mut self) {
if self.too_deep > 0 {
self.too_deep -= 1;
} else {
self.groups.pop();
}
}
/// The chain an object drawn under `clips` is drawn in: the clips and the groups open, in the
/// order they opened.
fn chain(&self, clips: &[Arc<Clip>]) -> Vec<Arc<Clip>> {
if self.groups.is_empty() {
return clips.to_vec();
}
let mut chain = Vec::with_capacity(clips.len() + self.groups.len());
let (mut a, mut b) = (clips.iter().peekable(), self.groups.iter().peekable());
while let (Some(x), Some(y)) = (a.peek(), b.peek()) {
let next = if x.id < y.id { a.next() } else { b.next() };
chain.extend(next.cloned());
}
chain.extend(a.chain(b).cloned());
chain
}
/// Add `node` under `clips` (in the groups open); `None` when it was left out.
pub fn push(&mut self, mut node: Node, clips: &[Arc<Clip>]) -> Option<NodeId> {
self.merge = None;
self.elements = self.elements.saturating_add(node.path_data().map_or(1, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum()));
let chain = self.chain(clips);
let size = node.path_data().map_or(1, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum()) + chain.len();
self.elements = self.elements.saturating_add(size);
if self.drawn.len() >= MAX_NODES || self.elements > MAX_DRAWN {
self.warn(TOO_MUCH);
return None;
@@ -204,7 +249,7 @@ impl Out {
}
node.id = self.doc.alloc_id();
let id = node.id;
self.drawn.push((clips.to_vec(), node));
self.drawn.push((chain, node));
Some(id)
}
@@ -234,14 +279,15 @@ impl Out {
/// clips (`gsave fill grestore stroke`).
fn stroke(&mut self, bp: BezPath, st: StrokeLayer, clips: &[Arc<Clip>]) {
self.count(&st.paint);
if self.merge.as_ref() == Some(&bp)
&& let Some((chain, last)) = self.drawn.last_mut()
&& chain.len() == clips.len()
&& chain.iter().zip(clips).all(|(a, b)| Arc::ptr_eq(a, b))
{
last.appearance.items.push(AppearanceItem::Stroke(st));
self.merge = None;
return;
if self.merge.as_ref() == Some(&bp) {
let here = self.chain(clips);
if let Some((chain, last)) = self.drawn.last_mut()
&& chain.iter().map(|c| c.id).eq(here.iter().map(|c| c.id))
{
last.appearance.items.push(AppearanceItem::Stroke(st));
self.merge = None;
return;
}
}
let n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Appearance::default() });
self.push(n, clips);
@@ -256,7 +302,7 @@ impl Out {
return None;
}
self.next_clip += 1;
Some(Arc::new(Clip { id: self.next_clip, path, rule }))
Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) }))
}
}
@@ -472,14 +518,17 @@ impl Interp<'_> {
}
fn clip_with(&mut self, path: BezPath, rule: FillRule) {
if let Some(c) = self.out.clip(path, rule) {
let Some(c) = self.out.clip(path, rule) else { return };
if self.g.clips.len() < MAX_NEST {
self.g.clips.push(c);
} else {
self.out.warn(&deep_clips());
}
}
/// The area the clip leaves, in document space (the page without a clip).
fn clip_bounds(&self) -> Rect {
self.g.clips.last().map_or(self.out.frame, |c| c.path.bounding_box())
self.g.clips.iter().rev().find_map(|c| c.region.as_ref()).map_or(self.out.frame, |(p, _)| p.bounding_box())
}
// ---------- colour ----------
+20
View File
@@ -52,6 +52,9 @@ pub(crate) struct Interp<'a> {
pub g: GState,
pub saved: Vec<GState>,
pub out: Out,
/// The program is an Illustrator file's: its `u` … `U` (written at the top level, whatever
/// its prolog defines them as) are groups.
pub illustrator: bool,
}
fn new_dict() -> DictRef {
@@ -128,6 +131,7 @@ impl<'a> Interp<'a> {
g,
saved: vec![],
out,
illustrator: false,
}
}
@@ -135,10 +139,26 @@ impl<'a> Interp<'a> {
pub fn run(&mut self) -> Res {
loop {
let Some(o) = self.lex.next()? else { return Ok(()) };
// An Illustrator group begins before its `u` runs (`Some(true)`) and ends after its `U`
// has (`Some(false)`).
let group = match &o {
Obj::Exec(name) if self.illustrator => match &**name {
"u" => Some(true),
"U" => Some(false),
_ => None,
},
_ => None,
};
if group == Some(true) {
self.out.begin_group();
}
match self.exec_token(o) {
Err(PsError::Quit) => return Ok(()),
r => r?,
}
if group == Some(false) {
self.out.end_group();
}
}
}
+15 -2
View File
@@ -9,6 +9,10 @@
//! - `data`: data the program reads from itself through decoding filters, and images.
//! - `text`: fonts by name and type as point type.
//!
//! Clipped art comes in as clipping groups. In an Illustrator file (Illustrator 3–8, written with
//! the prolog that defines its operators), the groups it writes (`u` … `U`, nested) come in as
//! groups.
//!
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
//! of a PostScript file without one. A file whose program can't be read (an operator the
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in as its TIFF
@@ -26,6 +30,8 @@ mod text;
mod tests;
#[cfg(test)]
mod tests_fontnames;
#[cfg(test)]
mod tests_illustrator;
use std::sync::Arc;
@@ -60,6 +66,8 @@ struct Dsc {
/// `llx lly urx ury` in PostScript's default space.
bbox: Option<[f64; 4]>,
pages: Option<u32>,
/// Written by Illustrator (or in its format): `%AI…` comments, or Illustrator the creator.
illustrator: bool,
}
/// The four numbers of a bounding box comment's value, when it has them and they make a box.
@@ -90,6 +98,10 @@ impl Dsc {
}
}
let value = |key: &str| line.strip_prefix(key).map(str::trim);
let creator = value("%%Creator:").is_some_and(|v| v.contains("Illustrator")); // brand-ok: the creator its files name
if header && (line.starts_with("%AI") || line.starts_with("%%AI") || creator) {
d.illustrator = true;
}
if let Some(v) = value("%%HiResBoundingBox:") {
if header || atend {
hires = box_of(v).or(hires);
@@ -136,6 +148,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
doc.layers.clear();
let page = Affine::new([1.0, 0.0, 0.0, -1.0, -llx, ury]);
let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, frame));
it.illustrator = dsc.illustrator;
let result = it.run();
it.release();
let mut out = it.out;
@@ -165,8 +178,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
}
}
/// The document of what `out` drew: one layer, clipped art in clipping groups, the spot inks as
/// spot swatches, in CMYK when most process colours were.
/// The document of what `out` drew: one layer, clipped art in clipping groups (grouped art in
/// groups), the spot inks as spot swatches, in CMYK when most process colours were.
fn finish(mut out: Out) -> Imported {
let drawn = std::mem::take(&mut out.drawn);
let mut children = vectorcraft_doc::clipnest::nest(&mut out.doc, drawn);
+14
View File
@@ -195,6 +195,20 @@ fn clips_become_clipping_groups() {
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }));
}
#[test]
fn clips_nest_only_so_deep() {
// Each clip inside the last one used to nest a clipping group one level deeper, past the stack.
let r = read("1 1 200000 { pop 0 0 50 50 rectclip } for 1 1 5 5 rectfill");
let mut depth = 0;
let mut n = objects(&r.document)[0].clone();
while let NodeKind::Group { children, clip: true } = &n.kind {
depth += 1;
n = children.last().unwrap().clone();
}
assert_eq!(depth, vectorcraft_doc::clipnest::MAX_NEST);
assert!(r.warnings.iter().any(|w| w.contains("clips nested more than")), "{:?}", r.warnings);
}
#[test]
fn shadings_become_gradients() {
let axial =
@@ -0,0 +1,91 @@
//! Illustrator files (Illustrator 3–8 and their EPS): the groups they write (`u` … `U`) come in
//! as groups, nested as they were, between and inside clipping groups.
use std::sync::Arc;
use vectorcraft_doc::clipnest::MAX_NEST;
use vectorcraft_doc::{Node, NodeKind};
use crate::import::import;
/// A minimal file in Illustrator's format: its header comments, a prolog of our own defining the
/// operators `body` uses (`u` and `U` as `ugroup` and `Ugroup`), and `body`.
fn ai(header: &str, ugroup: &str, body: &str) -> Vec<u8> {
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n{header}%%BoundingBox: 0 0 100 100\n%%EndComments\n%%BeginProlog\n\
/m {{moveto}} def /L {{lineto}} def /f {{closepath fill}} def /S {{stroke}} def /g {{setgray}} def\n\
/u {{{ugroup}}} def /U {{{}}} def\n%%EndProlog\n{body}\nshowpage\n%%EOF\n",
if ugroup.contains("gsave") { "grestore" } else { "" }
)
.into_bytes()
}
const AI8: &str = "%%Creator: Adobe Illustrator(R) 8.0\n%AI5_FileFormat 4.0\n";
/// A triangle filled at `x`.
fn tri(x: u32) -> String {
format!("{x} {x} m {} {x} L {} {} L f", x + 5, x + 5, x + 5)
}
/// The layer's objects as a tree: `p` a path, `c` a clip path, `g[…]` a group, `k[…]` a clipping
/// group.
fn tree(nodes: &[Arc<Node>]) -> String {
let one = |n: &Arc<Node>| match &n.kind {
NodeKind::Group { children, clip: false } => format!("g[{}]", tree(children)),
NodeKind::Group { children, clip: true } => format!("k[{}]", tree(children)),
NodeKind::Path { clipping: true, .. } => "c".into(),
NodeKind::Path { .. } => "p".into(),
k => format!("{k:?}"),
};
nodes.iter().map(one).collect::<Vec<_>>().join(" ")
}
fn read(header: &str, ugroup: &str, body: &str) -> (String, Vec<String>) {
let r = import(&ai(header, ugroup, body)).unwrap();
(tree(r.document.layers[0].children().unwrap()), r.warnings)
}
#[test]
fn groups_and_nested_groups_come_in_as_groups() {
let body = format!("u {} u {} {} U U {}", tri(10), tri(30), tri(50), tri(70));
assert_eq!(read(AI8, "", &body), ("g[p g[p p]] p".into(), vec![]));
// Illustrator 3's header names it only as the creator; a prolog whose groups save and restore
// the graphics state groups the same.
assert_eq!(read("%%Creator:Adobe Illustrator(TM) 3.2\n", "gsave", &body).0, "g[p g[p p]] p");
// Groups of one object, and groups next to each other.
assert_eq!(read(AI8, "", &format!("u {} U u {} U", tri(10), tri(30))).0, "g[p] g[p]");
}
#[test]
fn groups_nest_with_clipping_groups() {
// A clipping group inside a group, a group inside a clipping group.
let clip = "gsave 0 0 m 50 0 L 50 50 L 0 50 L closepath clip newpath";
let body = format!("u {} {clip} u {} {} U grestore {} U", tri(10), tri(20), tri(30), tri(40));
assert_eq!(read(AI8, "", &body).0, "g[p k[c g[p p]] p]");
// A fill and a stroke of one path in a group stay one object.
let body = "u 10 10 m 20 10 L 20 20 L closepath gsave 0 g fill grestore S U";
let r = import(&ai(AI8, "", body)).unwrap();
let layer = r.document.layers[0].children().unwrap();
assert_eq!(tree(layer), "g[p]");
assert_eq!(layer[0].children().unwrap()[0].appearance.items.len(), 2);
}
#[test]
fn other_postscript_keeps_its_own_u() {
// A program that isn't Illustrator's may name its own procedures `u` and `U`.
let body = format!("u {} {} U", tri(10), tri(30));
assert_eq!(read("%%Creator: some app\n", "", &body).0, "p p");
}
#[test]
fn unbalanced_and_runaway_groups_are_read_safely() {
// Ends without a beginning end nothing; groups left open hold the rest.
assert_eq!(read(AI8, "", &format!("U U {} u {} u {}", tri(10), tri(20), tri(30))).0, "p g[p g[p]]");
// Groups nest only so deep: the deeper ones' art goes into the innermost group read, and the
// groups around them close where they should.
let n = MAX_NEST + 50;
let body = format!("{} {} {} {}", "u ".repeat(n), tri(10), "U ".repeat(n), tri(30));
let (t, warnings) = read(AI8, "", &body);
assert_eq!(t, format!("{}p{} p", "g[".repeat(MAX_NEST), "]".repeat(MAX_NEST)));
assert!(warnings.iter().any(|w| w.contains("nested more than")), "{warnings:?}");
}
+5 -2
View File
@@ -13,7 +13,7 @@ mod wmf;
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::clipnest::{Clip, Drawn, nest};
use vectorcraft_doc::clipnest::{Clip, Drawn, MAX_NEST, deep_clips, nest};
use vectorcraft_doc::{
Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind,
StrokeLayer, TextObject,
@@ -696,8 +696,11 @@ impl Player {
if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) {
return;
}
if self.dc.clips.len() >= MAX_NEST {
return self.warn(&deep_clips());
}
self.next_clip += 1;
self.dc.clips.push(Arc::new(Clip { id: self.next_clip, path, rule }));
self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) }));
}
/// SelectClipPath (`mode`: 1 and, 5 copy; the others cut out, which isn't read).
+16
View File
@@ -309,3 +309,19 @@ fn damaged_files_fail_or_import_partly() {
let huge = hand_wmf(100, 100, &[(0x0324, le16(&[i16::MAX])), (0x0538, le16(&[-1]))]);
assert!(import(&huge).is_ok());
}
#[test]
fn clips_nest_only_so_deep() {
// Each clip inside the last one used to nest a clipping group one level deeper, past the stack.
let mut records = vec![(30, le(&[0, 0, 1500, 1500])); 200_000];
records.push((43, le(&[100, 100, 200, 200])));
let back = import(&hand_emf(5000, 3000, &records)).unwrap();
let mut depth = 0;
let mut n = back.document.layers[0].children().unwrap()[0].clone();
while let NodeKind::Group { children, clip: true } = &n.kind {
depth += 1;
n = children.last().unwrap().clone();
}
assert_eq!(depth, vectorcraft_doc::clipnest::MAX_NEST);
assert!(back.warnings.iter().any(|w| w.contains("clips nested more than")), "{:?}", back.warnings);
}
+5 -1
View File
@@ -1946,7 +1946,11 @@ dictionaries (the standard ones are values in `systemdict`; `dictstack`, `intern
intervals share storage, resources (categories made from `Generic`, `resourceforall`), executable filters (`cvx exec`
runs their data; `flushfile` skips it), axial and radial shadings and shading patterns (gradients), images and image
masks (data in the file through ASCII85, hex, run-length, Flate, LZW or DCT filters, or from procedures), and type as
point type in the font the file names (embedded font programs are skipped). The artboard is the `%%HiResBoundingBox`
point type in the font the file names (embedded font programs are skipped). In a file in Illustrator's own format
(Illustrator 3–8 `.ai` and their EPS, written with the prolog that defines its operators) the groups it writes (`u` …
`U`) come in as groups, nested as they were; clips and groups nest at most 128 deep. A PDF-compatible `.ai` doesn't
mark its plain groups (only layers, clipping groups and groups with opacity, blending or a mask), so they open
ungrouped. The artboard is the `%%HiResBoundingBox`
(else `%%BoundingBox`; a letter page without one). A program the interpreter can't run (an operator it doesn't know,
an error, a runaway loop) or that draws nothing comes in as its TIFF preview (palette previews with an alpha channel
too) with a warning; without a preview, the art drawn up to the error is kept with a warning, and a file with none is