Part of #637: the editing data's structure read whole, colours, gradients, transparency, names, images and artboards included

The layers reader of the editing copy (operators translated into
PostScript for the interpreter, any file with RGB colours, gradients,
transparency or images falling back to its page) is replaced by a reader
of the whole structure (import/ai): layers and sublayers with their
options, groups, compound paths, clipping groups and layer clipping,
names, hidden and locked objects, RGB, CMYK, grey, named and spot
colours, gradients, transparency, fill rule, overprint, embedded images
with their alpha channel, guides and every artboard, and objects drawn
as their look when they have several fills or strokes, effects or a
brush. Containers: Zstandard, zlib or plain data in EPS and .ai.

The text placement, the comparison with the page and the overwrite guard
stay around it. The page's type takes its text object's place without a
group; the comparison is by lightness, on nothing, without layers that
don't print. Symbols, pattern fills, placed files and unknown operators
make the file come in as its page where they show, and are left out of
layers that don't.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-09 16:02:36 +07:00
1 parent 6e2cd95532
commit 0be7640088
25 files changed
+3453 -778

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-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
## Where we are
@@ -38,7 +38,7 @@ since 2026-10-01.
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~4 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for layers, hidden objects and hidden type (see Out of scope for what it doesn't read yet); DWG has no open spec |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
@@ -226,7 +226,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 preset polish (Rearrange All Artboards, 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 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, 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; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394) | 2–4 |
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), 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, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (area type and type on a path, appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| 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 |
@@ -250,7 +250,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, beyond layer structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams) is not documented as a whole. We read only the part that is the legacy Illustrator file format: layers (names, order, colour, hidden), nested layers, groups, compound paths, clipping groups, hidden objects, and plain path/colour operators. It was worked out from files the contributors have the right to use; Adobe's published specification of that format was not used. Its Zstandard wrapper is the public [RFC 8878](https://www.rfc-editor.org/rfc/rfc8878) and the ASCII85 wrapper is PostScript Level 2's. Everything else in it (the app's private `%_` comments, its binary text document, live effects, brushes, gradients, images) is not read: such a file comes in as its page, with a warning, as before. Type that shows comes from the page, which draws it; type that doesn't (hidden layers and objects, art off the page) is made from the file's text document (characters, font, size, colour, alignment and where it is, for point type: the keys were checked against the page's own type in the owner's files and are used only where they matched), and area type and type on a path in it are left out with a note. A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Each check is a render comparison against the page, which rejects the layers if they differ by more than 5%. The importer was built from public documentation and from files users own, used locally and never committed; no Adobe software was run. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its 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). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type that shows comes from the page, which draws it, into its text object's place (no group around it); type that doesn't (hidden layers and objects, art off the page) is made from the file's text document (characters, font, size, colour, alignment and where it is, for point type), and area type and type on a path in it are left out with a note. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike); the page is used when they differ by more than 5%. A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its 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). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
- **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.
+1 -5
View File
@@ -21,8 +21,6 @@ pub struct Clip {
pub id: u32,
/// The region and its rule; `None` for a plain group.
pub region: Option<(BezPath, FillRule)>,
/// The group is hidden (its art is in it, not shown).
pub hidden: bool,
}
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
@@ -80,7 +78,5 @@ fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<N
children.push(Arc::new(path));
}
children.extend(nest_at(doc, items, depth + 1));
let mut group = Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() });
group.visible = !c.hidden;
Arc::new(group)
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() }))
}
+3
View File
@@ -938,6 +938,9 @@ mod tests_psd;
#[cfg(test)]
mod tests_epsimport;
#[cfg(test)]
mod tests_aiimport;
#[cfg(test)]
mod tests_pdflayers;
@@ -0,0 +1,93 @@
//! Illustrator EPS and `.ai` files open from the editing data they carry: layers, groups, names,
//! hidden objects, artboards, colours and images as they were; what the reader doesn't read yet
//! opens as before, with a warning saying why.
use std::io::Write as _;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_testkit::ai;
use super::*;
fn open(s: &mut Session, name: &str, bytes: &[u8], params: Value) -> Result<Value> {
let mut p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)});
if let (Some(p), Some(extra)) = (p.as_object_mut(), params.as_object()) {
p.extend(extra.clone());
}
s.execute("document.open", &p)
}
/// The `.ai` private data of `data`, compressed as older `.ai` files compress it.
fn compressed(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
[&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat()
}
fn names(nodes: &[Arc<Node>]) -> Vec<String> {
nodes.iter().map(|n| n.name.clone().unwrap_or_default()).collect()
}
/// The sample's structure, as [`ai::sample_data`] builds it.
fn check_sample(s: &Session) {
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art", "Hidden"]);
assert_eq!(doc.artboards.len(), 1);
assert_eq!(doc.artboards[0].rect, vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0));
let art = doc.layers[0].children().unwrap();
assert_eq!(names(art), ["Pair", "Ring", "Window"]);
let pair = art[0].children().unwrap();
assert_eq!(names(pair), ["Red", ""]);
assert!(pair[0].visible && !pair[1].visible, "the hidden member stays hidden");
assert!(matches!(art[1].kind, NodeKind::Compound { .. }));
assert!(matches!(&art[2].kind, NodeKind::Group { clip: true, children } if matches!(children[0].kind, NodeKind::Path { clipping: true, .. })));
let hidden = &doc.layers[1];
assert!(!hidden.visible);
let kids = hidden.children().unwrap();
assert_eq!(names(kids), ["Shaded", "", "Sub", ""]);
assert!(matches!(kids[0].appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Gradient(_))));
assert_eq!((kids[1].opacity, kids[1].blend), (0.5, vectorcraft_color::BlendMode::Screen));
assert!(kids[2].locked && matches!(kids[2].kind, NodeKind::Layer { .. }));
let NodeKind::Image(im) = &kids[3].kind else { panic!("an image") };
assert_eq!((im.width, im.height), (2, 1));
}
#[test]
fn an_illustrator_eps_opens_with_its_layers() {
let mut s = Session::new();
let r = open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
assert_eq!((&r["format"], &r["warnings"]), (&json!("eps"), &json!([])), "{r}");
check_sample(&s);
// Saved as EPS and opened again, it comes back the same.
let eps = s.execute("document.exportEps", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(eps["dataBase64"].as_str().unwrap()).unwrap();
let r = open(&mut s, "again.eps", &bytes, json!({})).unwrap();
assert_eq!(r["restored"], json!(true), "{r}");
check_sample(&s);
}
#[test]
fn an_illustrator_ai_file_opens_from_its_editing_data() {
let mut s = Session::new();
let file = ai::ai(&compressed(&ai::sample_data()), ai::page_pdf());
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
assert_eq!(r["format"], json!("ai"), "{r}");
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("weren't read")), "{r}");
check_sample(&s);
// Pages picked: the PDF's.
open(&mut s, "art.ai", &file, json!({"pages": "1"})).unwrap();
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
}
#[test]
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
let mut s = Session::new();
let r = open(&mut s, "symbols.eps", &ai::eps(symbols.as_bytes(), ai::page_ps()), json!({})).unwrap();
assert!(r["warnings"].to_string().contains("layers weren't read") && r["warnings"].to_string().contains("symbols"), "{r}");
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Layer 1"]);
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}");
}
+177
View File
@@ -1707,3 +1707,180 @@ proptest! {
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![]).0))?;
}
}
/// The operators, operands and section comments of hostile editing data.
fn arb_ai_token() -> impl Strategy<Value = String> {
prop_oneof![
arb_num(),
prop::sample::select(vec![
"m",
"l",
"L",
"c",
"C",
"v",
"V",
"y",
"Y",
"h",
"H",
"N",
"n",
"F",
"f",
"S",
"s",
"B",
"b",
"W",
"(b) *",
"u",
"U",
"*u",
"*U",
"q",
"Q",
"LB",
"Lb",
"Ln",
"(name) Ln",
"g",
"G",
"k",
"K",
"x",
"X",
"Xa",
"XA",
"Xx",
"XX",
"(Ink) 0.5 x",
"O",
"R",
"XR",
"w",
"J",
"j",
"M",
"d",
"[6 3] 0 d",
"[]0 d",
"Xy",
"2 0.5 1 1 1 Xy",
"99 -4 Xy",
"Xw",
"1 Xw",
"A",
"1 A",
"Ae",
"XW",
"1 (style) XW",
"9 () XW",
"Bd",
"(G) 1 3 Bd",
"Bs",
"0 0 0 0 1 0 0 2 1 6 50 0 Bs",
"BD",
"Bb",
"1 Bb",
"BB",
"2 BB",
"1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg",
"Bg",
"Bm",
"1e308 0 0 1e-308 0 0 Bm",
"Bh",
"XN",
"/DeviceCMYK XN",
"/DeviceGray XN",
"[ 1 0 0 1 0 0 ] 0 0 2 2 2 2 8 3 1 0 1 0",
"[ 1 0 0 1 0 0 ] 0 0 99999 99999 99999 99999 1 1 0 0 0 0",
"[",
"]",
"/Name",
"(text)",
"<ff00>",
":",
";",
",",
"/ArtDictionary :",
"/XMLUID : (A_x41_) ; (AI10_ArtUID) ,",
"(n) /String (AIArtName) ,",
"/Document :",
"/Array :",
"/Dictionary :",
"0 0 /RealPoint (PositionPoint1) ,",
"/AI11Text :",
"0 /StoryIndex ,",
"/SymbolInstance :",
"/Binary : /ASCII85Decode ,",
"~>",
"p",
"To",
"frobnicate",
"\n%AI5_BeginLayer\n",
"\n%AI5_EndLayer--\n",
"\n%_",
"\n%AI5_BeginRaster\n",
"\n%AI5_EndRaster\n",
"\n%%BeginData: 12\rXI\n",
"\n%%EndData\n",
"\n%AI5_BeginGradient: (G)\n",
"\n%AI14_BeginSymbol\n",
"\n%AI10_EndSymbol\n",
"\n%AI17_Begin_Content_if_version_gt:24 4\n",
"\n%AI17_Alternate_Content\n",
"\n%AI17_End_Versioned_Content\n",
"\n%AI3_Cropmarks: 0 0 1e308 -1e308\n",
"\n%AI5_ArtSize: 0 0\n",
"\n%AI9_ColorModel: 2\n",
"\n%AI5_BeginPlace\n",
"\n%AI26_BeginPlacedObjectPreview\n",
"\n%%PageTrailer\n",
])
.prop_map(str::to_string),
]
}
/// The testkit's sample editing data, compressed as older `.ai` files compress it.
fn ai_sample_compressed() -> Vec<u8> {
use std::io::Write as _;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(&vectorcraft_testkit::ai::sample_data()).unwrap();
[&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat()
}
proptest! {
#![proptest_config(config())]
/// Hostile editing data in Illustrator EPS and `.ai` files: operators in any order with any
/// operands, sections and dictionaries left open, images of any size.
#[test]
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) {
use vectorcraft_testkit::ai;
let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" ")));
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![]).0))?;
let eps = ai::eps(data.as_bytes(), ai::page_ps());
survive("hostile editing data in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
}
/// The testkit's sample damaged, as it is and compressed, in EPS and `.ai` files.
#[test]
fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
use vectorcraft_testkit::ai;
static PLAIN: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
static PACKED: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
for (what, sample) in [("editing data", PLAIN.get_or_init(ai::sample_data)), ("compressed editing data", PACKED.get_or_init(ai_sample_compressed))] {
let mut bytes = sample.clone();
for &(at, b) in &edits {
let n = bytes.len();
bytes[at % n] = b;
}
bytes.truncate(cut.max(4));
let file = ai::ai(&bytes, ai::page_pdf());
survive(what, || vectorcraft_engine::cmd::fileio::load("x.ai", &file).ok().map(|l| l.doc))?;
let file = ai::eps(&bytes, ai::page_ps());
survive(what, || vectorcraft_engine::cmd::fileio::load("x.eps", &file).ok().map(|l| l.doc))?;
}
}
}
+3 -3
View File
@@ -21,15 +21,15 @@ fn ascii85(data: &[u8]) -> String {
out
}
/// A square that is gradient-filled in the layers (which can't be read) and flat on the page: the
/// file comes in as its page, and its hidden layer is left out.
/// A square drawn with an operator the layers' reader doesn't know and flat on the page: the file
/// comes in as its page, and its hidden layer is left out.
fn lossy_eps() -> Vec<u8> {
let layer = |name: &str, visible: u8, body: &str| {
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
};
let editing = format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n",
layer("Art", 1, "1 0 0 0 1 0 Bg\n0 0 1 0 k\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"),
layer("Art", 1, "1 0 0 0 1 0 Zq\n0 0 1 0 k\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"),
layer("Hidden", 0, "0 0 1 0 k\n50 50 m\n54 50 L\n54 54 L\n50 54 L\nf"),
);
let packed = ruzstd::encoding::compress_to_vec(editing.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest);
+333
View File
@@ -0,0 +1,333 @@
//! The scanner of the editing data: PostScript-like tokens, read line by line.
//!
//! - A line starting with `%_` holds tokens like any other line (they are marked [`Token::hidden`]:
//! readers of the printed format skip them as comments).
//! - Any other line starting with `%` is a [`Tok::Comment`] (the section markers).
//! - `%` later in a line starts a comment that is skipped.
//! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary previews of placed
//! files are skipped.
use super::super::lex::{find, hex_decode};
/// A token.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Tok<'a> {
Num(f64),
/// A literal `(…)` string, escapes undone.
Str(Vec<u8>),
/// A hexadecimal `<…>` string.
Hex(Vec<u8>),
/// A literal `/name`.
Name(&'a [u8]),
/// An operator (any other word, `[` and `]` included).
Word(&'a [u8]),
/// A comment line, without its first `%`.
Comment(&'a [u8]),
/// An image's samples: what follows `XI` in a `%%BeginData:` section.
Data(&'a [u8]),
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Token<'a> {
pub tok: Tok<'a>,
/// Read from a `%_` line.
pub hidden: bool,
}
/// Binary sections skipped whole: (begin, end) markers.
const SKIPPED: [(&[u8], &[u8]); 2] = [(b"%AI26_BeginPlacedObjectPreview", b"%AI26_EndPlacedObjectPreview"), (b"%%BeginDocument", b"%%EndDocument")];
fn is_space(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'\0')
}
fn is_delim(b: u8) -> bool {
matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
}
fn is_eol(b: u8) -> bool {
matches!(b, b'\r' | b'\n')
}
pub(super) struct Lexer<'a> {
src: &'a [u8],
pos: usize,
/// The current line started with `%_`.
hidden: bool,
}
impl<'a> Lexer<'a> {
pub fn new(src: &'a [u8]) -> Self {
Self { src, pos: 0, hidden: false }
}
fn peek(&self) -> Option<u8> {
self.src.get(self.pos).copied()
}
fn rest(&self) -> &'a [u8] {
self.src.get(self.pos..).unwrap_or_default()
}
fn at_line_start(&self) -> bool {
self.pos == 0 || self.src.get(self.pos - 1).is_some_and(|b| is_eol(*b))
}
/// The rest of the current line (without its end), the position moved past it.
fn line(&mut self) -> &'a [u8] {
let rest = self.rest();
let n = rest.iter().position(|b| is_eol(*b)).unwrap_or(rest.len());
self.pos += n;
rest.get(..n).unwrap_or_default()
}
/// Skip past the next `needle` (the end of data that isn't tokens) → what was skipped.
pub fn skip_past(&mut self, needle: &[u8]) -> &'a [u8] {
let rest = self.rest();
let n = find(rest, needle).map_or(rest.len(), |i| i + needle.len());
self.pos += n;
rest.get(..n).unwrap_or_default()
}
/// The next token; `None` at the end.
pub fn next_token(&mut self) -> Option<Token<'a>> {
loop {
let b = self.peek()?;
if is_eol(b) {
self.pos += 1;
self.hidden = false;
continue;
}
if is_space(b) {
self.pos += 1;
continue;
}
if b == b'%' {
if self.at_line_start() {
if self.rest().starts_with(b"%_") {
self.pos += 2;
self.hidden = true;
continue;
}
let line = self.line();
if let Some(t) = self.special(line) {
return Some(t);
}
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false });
}
// A comment later in a line.
self.line();
continue;
}
let hidden = self.hidden;
self.pos += 1;
let tok = match b {
b'(' => Tok::Str(self.string()),
b'<' => {
let rest = self.rest();
let end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len());
self.pos += (end + 1).min(rest.len());
// Malformed digits read as an empty string.
Tok::Hex(rest.get(..end).and_then(hex_decode).unwrap_or_default())
}
b'[' | b']' | b'{' | b'}' | b')' | b'>' => Tok::Word(self.src.get(self.pos - 1..self.pos).unwrap_or_default()),
b'/' => Tok::Name(self.word()),
_ => {
self.pos -= 1;
let w = self.word();
match number(w) {
Some(v) => Tok::Num(v),
None => Tok::Word(w),
}
}
};
return Some(Token { tok, hidden });
}
}
/// The token a comment line stands for when it starts a section read differently: image data
/// (`%%BeginData:` followed by `XI`) and skipped binary sections.
fn special(&mut self, line: &'a [u8]) -> Option<Token<'a>> {
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| line.starts_with(b)) {
let after = find(self.rest(), end).map_or(self.src.len(), |i| self.pos + i);
self.pos = after;
self.line();
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false });
}
let n = line.strip_prefix(b"%%BeginData:")?;
let n: usize = std::str::from_utf8(n).ok()?.split_whitespace().next()?.parse().ok()?;
// The count starts after the comment's line end character (a following `\n` counts).
let from = self.pos + 1;
let region = self.src.get(from..)?;
let skip = region.iter().take_while(|b| is_space(**b)).count();
if !region.get(skip..)?.starts_with(b"XI") {
return None;
}
let mut data = from + skip + 2;
match (self.src.get(data), self.src.get(data + 1)) {
(Some(b'\r'), Some(b'\n')) => data += 2,
(Some(b'\r' | b'\n'), _) => data += 1,
_ => {}
}
// The count leaves out an alpha channel: the data ends where `%%EndData` starts.
let declared = from.saturating_add(n).min(self.src.len()).max(data);
let window =
self.src.get(declared.saturating_sub(4).max(data)..declared.saturating_add(n.saturating_mul(2)).saturating_add(64).min(self.src.len()));
let end = match window.and_then(|w| find(w, b"%%EndData")) {
Some(i) => declared.saturating_sub(4).max(data) + i,
None => declared,
};
self.pos = end;
Some(Token { tok: Tok::Data(self.src.get(data..end).unwrap_or_default()), hidden: false })
}
/// A regular token's characters.
fn word(&mut self) -> &'a [u8] {
let start = self.pos;
while let Some(b) = self.peek() {
if is_space(b) || is_delim(b) {
break;
}
self.pos += 1;
}
self.src.get(start..self.pos).unwrap_or_default()
}
/// A literal string after its `(` (an unterminated one ends with the data).
fn string(&mut self) -> Vec<u8> {
let mut out = vec![];
let mut depth = 0usize;
while let Some(b) = self.peek() {
self.pos += 1;
match b {
b'(' => {
depth += 1;
out.push(b);
}
b')' if depth == 0 => break,
b')' => {
depth -= 1;
out.push(b);
}
b'\\' => {
let Some(e) = self.peek() else { break };
self.pos += 1;
match e {
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'b' => out.push(8),
b'f' => out.push(12),
b'\r' => {
if self.peek() == Some(b'\n') {
self.pos += 1;
}
}
b'\n' => {}
b'0'..=b'7' => {
let mut v = u32::from(e - b'0');
for _ in 0..2 {
match self.peek() {
Some(d @ b'0'..=b'7') => {
v = v * 8 + u32::from(d - b'0');
self.pos += 1;
}
_ => break,
}
}
out.push((v & 0xff) as u8);
}
other => out.push(other),
}
}
_ => out.push(b),
}
}
out
}
}
/// A finite number token (`12`, `-3.5`, `.5`, `1e-3`), or `None`.
fn number(w: &[u8]) -> Option<f64> {
let first = *w.first()?;
if !(first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.')) {
return None;
}
if w.iter().any(|b| b.is_ascii_alphabetic() && !matches!(b, b'e' | b'E')) {
return None;
}
let v: f64 = std::str::from_utf8(w).ok()?.parse().ok()?;
v.is_finite().then_some(v)
}
#[cfg(test)]
mod tests {
use super::*;
fn all(src: &[u8]) -> Vec<Token<'_>> {
let mut l = Lexer::new(src);
std::iter::from_fn(|| l.next_token()).collect()
}
fn t(tok: Tok<'_>, hidden: bool) -> Token<'_> {
Token { tok, hidden }
}
#[test]
fn hidden_lines_and_comments() {
let toks = all(b"%AI5_BeginLayer\r\n1 (a\\)b) Ln\r%_/X : ;\n0 %_BS\n<4142>\n");
assert_eq!(
toks,
vec![
t(Tok::Comment(b"AI5_BeginLayer"), false),
t(Tok::Num(1.0), false),
t(Tok::Str(b"a)b".to_vec()), false),
t(Tok::Word(b"Ln"), false),
t(Tok::Name(b"X"), true),
t(Tok::Word(b":"), true),
t(Tok::Word(b";"), true),
t(Tok::Num(0.0), false),
t(Tok::Hex(b"AB".to_vec()), false),
]
);
}
#[test]
fn image_data_is_one_token() {
// The count starts after the comment's `\r`: `\nXI\n` and 3 samples; an alpha channel
// after them isn't counted.
let src = b"[1 0 0 1 0 0] XI\r\n%%BeginData: 7\r\nXI\n(%)A%%EndData\r\nXH\r\n";
let toks = all(src);
assert!(toks.contains(&t(Tok::Data(b"(%)A"), false)), "{toks:?}");
assert_eq!(toks.last(), Some(&t(Tok::Word(b"XH"), false)));
// A section of hex lines is comments.
let toks = all(b"%%BeginData: 9 Hex Bytes\r\n%00FF\r\n%%EndData\r\n1\r\n");
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
}
#[test]
fn binary_previews_are_skipped() {
let toks = all(b"%AI26_BeginPlacedObjectPreview\r\n2 1 (\x00\xff\r\n%AI26_EndPlacedObjectPreview\r\nN\r\n");
assert_eq!(toks.last(), Some(&t(Tok::Word(b"N"), false)));
assert_eq!(toks.len(), 2);
}
#[test]
fn skip_past_data() {
let mut l = Lexer::new(b"/Binary : /ASCII85Decode ,\r\n%_(abc[%\r\n%x~>\r\n%_; 1");
for _ in 0..4 {
l.next_token();
}
assert_eq!(l.skip_past(b"~>"), b"\r\n%_(abc[%\r\n%x~>");
assert_eq!(l.next_token().map(|t| t.tok), Some(Tok::Word(b";")));
assert_eq!(l.next_token().map(|t| t.tok), Some(Tok::Num(1.0)));
}
#[test]
fn hostile_input_ends() {
for src in [&b"(unterminated \\"[..], b"<12", b"%%BeginData: 99999999999\r\nXI", b"%%BeginData: 5\rXI\r", b"/", b"1e999 nan"] {
let toks = all(src);
assert!(toks.len() < 10);
}
}
}
+159
View File
@@ -0,0 +1,159 @@
//! Illustrator's editing data: the document as the app keeps it, which an Illustrator EPS carries
//! after its printed page and a `.ai` file (PDF compatible or not) in its first page's
//! `/PieceInfo /Illustrator /Private` streams.
//!
//! - In an EPS it starts at the `%AI9_PrivateDataBegin` comment: header comments, then
//! `%AI24_DataStream` (Zstandard) or `%AI9_DataStream` (zlib) and the compressed data as ASCII85
//! on comment lines, up to `%AI9_PrivateDataEnd`. Data that isn't compressed follows the header
//! as it is.
//! - In a `.ai` file the streams joined start with `%AI24_ZStandard_Data` (Zstandard) or
//! `%AI12_CompressedData` (zlib), else are the data itself ([`decode_private`]).
//!
//! The data is a PostScript-like program of the legacy Illustrator format's operators (layers,
//! groups, paths, colours, gradients, images) with newer ones (transparency, hidden objects) and
//! dictionaries (names, artboards, type). `lex` scans it, `read` builds the document.
//!
//! Reading is bounded: the data decompresses to at most [`MAX_DATA`], and the reader caps nesting,
//! objects and points. Anything it doesn't read is an error: the caller opens the file the way it
//! did before (the printed page, or the PDF), with a warning saying why.
mod lex;
mod obj;
mod paint;
mod read;
use std::io::Read as _;
use super::lex::find;
use crate::ps;
pub(crate) use read::slot_of;
pub use read::{Structure, read};
#[cfg(test)]
mod tests;
/// Largest editing data read, decompressed (bytes).
pub const MAX_DATA: u64 = 256 << 20;
/// Largest Zstandard window accepted (a frame asking for more is damaged data).
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
const EPS_BEGIN: &[u8] = b"%AI9_PrivateDataBegin";
const EPS_END: &[u8] = b"%AI9_PrivateDataEnd";
/// The editing data an Illustrator EPS carries (`ps`: its PostScript), decoded: `None` without
/// one, an error when it can't be decoded.
pub fn eps_data(ps: &[u8]) -> Option<Result<Vec<u8>, String>> {
let start = find(ps, EPS_BEGIN)? + EPS_BEGIN.len();
let rest = ps.get(start..)?;
let section = rest.get(..find(rest, EPS_END).unwrap_or(rest.len())).unwrap_or_default();
Some(eps_section(section))
}
/// The editing data between the EPS markers: header comments, then the data (encoded or not).
fn eps_section(section: &[u8]) -> Result<Vec<u8>, String> {
let mut header = Vec::new();
let mut lines = section.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty());
let mut codec = None;
for line in lines.by_ref() {
if line.starts_with(b"%AI24_DataStream") {
codec = Some(Codec::Zstd);
break;
}
if line.starts_with(b"%AI9_DataStream") {
codec = Some(Codec::Zlib);
break;
}
header.extend_from_slice(line);
header.extend_from_slice(b"\n");
if header.len() as u64 > MAX_DATA {
return Err(too_large());
}
}
let Some(codec) = codec else {
// Not compressed: the section is the data, as it is (images' samples are binary).
if section.len() as u64 > MAX_DATA {
return Err(too_large());
}
return Ok(section.to_vec());
};
let mut text = String::new();
for line in lines {
let body = line.strip_prefix(b"%").unwrap_or(line);
text.push_str(std::str::from_utf8(body).map_err(|_| damaged("its ASCII85 encoding"))?);
if text.len() as u64 > MAX_DATA {
return Err(too_large());
}
}
if !text.contains("~>") {
text.push_str("~>");
}
let packed = ps::ascii85_decode(&text).ok_or_else(|| damaged("its ASCII85 encoding"))?;
let mut data = header;
data.extend(codec.decode(&packed, MAX_DATA.saturating_sub(data.len() as u64))?);
Ok(data)
}
/// The editing data of a `.ai` file: its `AIPrivateData` streams joined (`raw`), decompressed. The
/// compressed data may follow header comments that aren't.
pub fn decode_private(raw: &[u8]) -> Result<Vec<u8>, String> {
if raw.len() as u64 > MAX_DATA {
return Err(too_large());
}
for (marker, codec) in [(&b"%AI24_ZStandard_Data"[..], Codec::Zstd), (b"%AI12_CompressedData", Codec::Zlib)] {
if let Some(at) = find(raw, marker) {
let (header, body) = raw.split_at(at);
let mut data = header.to_vec();
data.extend(codec.decode(body.get(marker.len()..).unwrap_or_default(), MAX_DATA.saturating_sub(header.len() as u64))?);
return Ok(data);
}
}
Ok(raw.to_vec())
}
#[derive(Clone, Copy)]
enum Codec {
Zstd,
Zlib,
}
impl Codec {
/// `data` decompressed, at most `max` bytes.
fn decode(self, data: &[u8], max: u64) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
match self {
Codec::Zlib => {
flate2::read::ZlibDecoder::new(data).take(max + 1).read_to_end(&mut out).map_err(|_| damaged("its zlib compression"))?;
}
Codec::Zstd => {
let mut src = data;
// The data may be several frames one after the other.
while src.iter().any(|b| *b != 0) {
let left = (max + 1).saturating_sub(out.len() as u64);
if left == 0 {
break;
}
let d = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
.map_err(|_| damaged("its Zstandard compression"))?;
let before = out.len();
d.take(left).read_to_end(&mut out).map_err(|_| damaged("its Zstandard compression"))?;
if out.len() == before {
break;
}
}
}
}
if out.len() as u64 > max {
return Err(too_large());
}
Ok(out)
}
}
fn damaged(what: &str) -> String {
format!("{what} is damaged")
}
fn too_large() -> String {
format!("it decompresses to more than {} MB", MAX_DATA >> 20)
}
+208
View File
@@ -0,0 +1,208 @@
//! Operand values, and the dictionaries the editing data writes postfix: `/Type :` opens one,
//! `values key ,` adds an entry (a type name such as `/Real` may stand before the key; in an
//! `/Array` there is no key), `;` closes it (after a last `values /key` entry, or a bare value: its
//! content), leaving it as a value.
use std::rc::Rc;
#[derive(Clone, Debug, PartialEq)]
pub(super) enum V {
Num(f64),
Str(Vec<u8>),
Name(String),
Arr(Vec<V>),
Obj(Rc<Obj>),
/// `[`, until its `]`.
Mark,
}
impl V {
pub fn num(&self) -> Option<f64> {
match self {
V::Num(v) => Some(*v),
_ => None,
}
}
pub fn obj(&self) -> Option<&Obj> {
match self {
V::Obj(o) => Some(o),
_ => None,
}
}
/// A string's or name's text.
pub fn text(&self) -> Option<String> {
match self {
V::Str(s) => Some(text(s)),
V::Name(n) => Some(n.clone()),
_ => None,
}
}
}
/// Text from the data's bytes: UTF-8, else Windows-1252 (Latin-1 for its gaps).
pub(super) fn text(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => bytes.iter().map(|b| cp1252(*b)).collect(),
}
}
fn cp1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
'˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9f => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
_ => char::from(b),
}
}
/// A dictionary.
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct Obj {
pub ty: String,
/// Entries in order: the key (`None` in an array) and the values before it.
pub entries: Vec<(Option<String>, Vec<V>)>,
/// Values closed without a key.
pub content: Vec<V>,
}
impl Obj {
/// The values of entry `key` (the first one).
pub fn get(&self, key: &str) -> Option<&[V]> {
self.entries.iter().find(|(k, _)| k.as_deref() == Some(key)).map(|(_, v)| v.as_slice())
}
/// The numbers of entry `key`.
pub fn nums(&self, key: &str) -> Vec<f64> {
self.get(key).unwrap_or_default().iter().filter_map(V::num).collect()
}
/// The first text value of entry `key`.
pub fn text(&self, key: &str) -> Option<String> {
self.get(key)?.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text)
}
/// The dictionary value of entry `key`.
pub fn obj(&self, key: &str) -> Option<&Obj> {
self.get(key)?.iter().find_map(V::obj)
}
/// The dictionaries an array holds, in order.
pub fn items(&self) -> impl Iterator<Item = &Obj> {
self.entries.iter().flat_map(|(_, v)| v.iter()).chain(self.content.iter()).filter_map(V::obj)
}
/// The first text of the content.
pub fn content_text(&self) -> Option<String> {
self.content.iter().find_map(V::text)
}
}
/// Add the values on `stack` above `base` to `o` as one entry (`,`): the last is the key, except
/// in an array.
pub(super) fn add_entry(o: &mut Obj, stack: &mut Vec<V>, base: usize) {
let mut vals: Vec<V> = stack.drain(base.min(stack.len())..).collect();
if o.ty == "Array" {
o.entries.push((None, vals));
return;
}
let key = match vals.last() {
Some(V::Str(_) | V::Name(_)) => vals.pop().and_then(|k| k.text()),
_ => None,
};
o.entries.push((key, vals));
}
/// Close `o` (`;`): a last entry ending with a name is an entry, other values are its content.
pub(super) fn close(o: &mut Obj, stack: &mut Vec<V>, base: usize) {
if stack.len() <= base {
return;
}
if o.ty != "Array" && matches!(stack.last(), Some(V::Name(_))) {
add_entry(o, stack, base);
} else {
let vals: Vec<V> = stack.drain(base.min(stack.len())..).collect();
o.content.extend(vals);
}
}
/// An object name from its XML id (`AI10_ArtUID`): `_` stands for a space and `_xHH_` for a
/// character; ids made unique end with `_` and 20 or more digits.
pub(super) fn xml_name(id: &str) -> String {
let id = strip_unique(id);
let mut out = String::with_capacity(id.len());
let mut rest = id;
while let Some(c) = rest.chars().next() {
if c == '_' {
if let Some((ch, n)) = escaped(rest) {
out.push(ch);
rest = rest.get(n..).unwrap_or_default();
continue;
}
out.push(' ');
} else {
out.push(c);
}
rest = rest.get(c.len_utf8()..).unwrap_or_default();
}
out
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
let end = body.find('_')?;
let hex = body.get(..end)?;
if hex.is_empty() || hex.len() > 6 {
return None;
}
let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?;
Some((c, end + 3))
}
/// `id` without the `_000…_` suffix that makes it unique.
fn strip_unique(id: &str) -> &str {
let Some(body) = id.strip_suffix('_') else { return id };
let Some(at) = body.rfind('_') else { return id };
let digits = body.get(at + 1..).unwrap_or_default();
if digits.len() >= 20 && digits.bytes().all(|b| b.is_ascii_digit()) { body.get(..at).unwrap_or(id) } else { id }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_from_xml_ids() {
assert_eq!(xml_name("Layer_1"), "Layer 1");
assert_eq!(xml_name("My_Rect__x23_2"), "My Rect #2");
assert_eq!(xml_name("_x31_abc__x28_paren_x29_"), "1abc (paren)");
assert_eq!(xml_name("Ünï_日本"), "Ünï 日本");
assert_eq!(xml_name("S_00000092429200007574234970000005974767392385616558_"), "S");
assert_eq!(xml_name("a_x_"), "a x ");
assert_eq!(xml_name("_xD800_"), " xD800 ");
}
#[test]
fn entries_and_content() {
let mut o = Obj { ty: "XMLUID".into(), ..Obj::default() };
let mut st = vec![V::Num(9.0), V::Str(b"Main".to_vec())];
close(&mut o, &mut st, 1);
assert_eq!(o.content_text().as_deref(), Some("Main"));
assert_eq!(st, vec![V::Num(9.0)]);
let mut d = Obj { ty: "Dictionary".into(), ..Obj::default() };
let mut st = vec![V::Num(1.0), V::Num(2.0), V::Name("RealPoint".into()), V::Str(b"P".to_vec())];
add_entry(&mut d, &mut st, 0);
let mut st = vec![V::Name("Def".into())];
close(&mut d, &mut st, 0);
assert_eq!(d.nums("P"), vec![1.0, 2.0]);
assert_eq!(d.get("Def"), Some(&[][..]));
let mut a = Obj { ty: "Array".into(), ..Obj::default() };
let mut st = vec![V::Obj(Rc::new(d.clone()))];
add_entry(&mut a, &mut st, 0);
assert_eq!(a.items().count(), 1);
}
#[test]
fn latin_text() {
assert_eq!(text(b"caf\xe9 \x80"), "café €");
assert_eq!(text("ü".as_bytes()), "ü");
}
}
+277
View File
@@ -0,0 +1,277 @@
//! Colours and gradients of the editing data.
//!
//! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
//! values its equivalent), `Xx`/`XX` a named colour of either model
//! (`c m y k [r g b] (name) tint type`). Lower case paints fills, upper case strokes.
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
//! tx ty Bg`, the gradient matrix `a b c d tx ty Bm`, the focal point `x y angle length Bh`,
//! `flag BB`.
use std::collections::HashMap;
use kurbo::{Affine, Point, Vec2};
use vectorcraft_color::gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
use vectorcraft_color::{Color, Paint};
use super::obj::V;
/// Largest number of stops read in one gradient.
const MAX_STOPS: usize = 256;
/// A colour read with the swatch it names (spot and global colours).
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Named {
pub name: String,
/// The full colour.
pub color: Color,
pub tint: f32,
}
/// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named
/// colour it uses. `None` when the operands don't fit.
pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
let nums: Vec<f32> = vals.iter().filter_map(V::num).map(|v| v as f32).collect();
let unit = |v: f32| v.clamp(0.0, 1.0);
let last = |n: usize| nums.get(nums.len().checked_sub(n)?..);
match op.to_ascii_lowercase().as_str() {
"g" => Some((Paint::solid(Color::gray(1.0 - unit(*nums.last()?))), None)),
"k" => {
let [c, m, y, k] = last(4)? else { return None };
Some((Paint::solid(Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))), None))
}
"xa" => {
let [r, g, b] = last(3)? else { return None };
Some((Paint::solid(Color::rgb(unit(*r), unit(*g), unit(*b))), None))
}
"x" => named(vals, Some(false)),
"xx" => named(vals, None),
_ => None,
}
}
/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces
/// the model.
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
let name = vals.get(at)?.text()?;
let before: Vec<f32> = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect();
let after: Vec<f32> = vals.get(at + 1..)?.iter().filter_map(V::num).map(|v| v as f32).collect();
let unit = |v: f32| v.clamp(0.0, 1.0);
let tint = 1.0 - unit(*after.first()?);
let n = before.len();
let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) {
let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None };
Color::rgb(unit(*r), unit(*g), unit(*b))
} else {
// The CMYK values come first (an RGB equivalent may follow them).
let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? };
let [c, m, y, k] = before.get(from..from + 4)? else { return None };
Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))
};
let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint };
Some((paint, Some(Named { name, color, tint })))
}
/// The colour of a gradient stop's colour values and style.
fn stop_color(style: f64, comps: &[V]) -> Option<(Color, Option<Named>)> {
let nums: Vec<f32> = comps.iter().filter_map(V::num).map(|v| v as f32).collect();
let unit = |v: f32| v.clamp(0.0, 1.0);
let tail = |n: usize| nums.get(nums.len().checked_sub(n)?..);
Some(match style as i64 {
0 => (Color::gray(1.0 - unit(*nums.last()?)), None),
1 => {
let [c, m, y, k] = tail(4)? else { return None };
(Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k)), None)
}
2 => {
let [r, g, b] = tail(3)? else { return None };
(Color::rgb(unit(*r), unit(*g), unit(*b)), None)
}
_ => {
let (paint, named) = named(comps, Some(style as i64 == 4))?;
(paint.color()?, named)
}
})
}
/// A gradient definition being read.
#[derive(Debug)]
pub(super) struct GradientDef {
pub name: String,
pub gradient: Gradient,
}
impl GradientDef {
/// `(name) type stops Bd`.
pub fn begin(vals: &[V]) -> Option<Self> {
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None })?;
let nums: Vec<f64> = vals.iter().filter_map(V::num).collect();
let kind = if nums.first().copied() == Some(1.0) { GradientKind::Radial } else { GradientKind::Linear };
Some(Self { name, gradient: Gradient { kind, stops: vec![] } })
}
/// A stop (`Bs`).
pub fn stop(&mut self, vals: &[V], spots: &mut Vec<Named>) {
if self.gradient.stops.len() >= MAX_STOPS {
return;
}
let n = vals.len();
let num = |i: usize| vals.get(i).and_then(V::num);
let (Some(ramp), Some(mid)) = (n.checked_sub(1).and_then(num), n.checked_sub(2).and_then(num)) else { return };
// Newer stops have an opacity and the marker 6 before the midpoint.
let newer = n >= 5 && n.checked_sub(3).and_then(num) == Some(6.0);
let (style_at, opacity) = if newer { (n.checked_sub(5), n.checked_sub(4).and_then(num).unwrap_or(1.0)) } else { (n.checked_sub(3), 1.0) };
let (Some(style_at), Some(style)) = (style_at, style_at.and_then(num)) else { return };
let Some((color, named)) = stop_color(style, vals.get(..style_at).unwrap_or_default()) else { return };
let mut stop = GradientStop::new((ramp / 100.0).clamp(0.0, 1.0) as f32, color);
stop.opacity = opacity.clamp(0.0, 1.0) as f32;
stop.midpoint = (mid / 100.0).clamp(0.13, 0.87) as f32;
if let Some(n) = named {
stop.set_color(n.color.tinted(n.tint), Some((n.name.clone(), n.tint)));
spots.push(n);
}
self.gradient.stops.push(stop);
}
/// The finished gradient (`BD`): stops in order of their ramp points.
pub fn finish(mut self) -> Option<(String, Gradient)> {
if self.gradient.stops.is_empty() {
return None;
}
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
Some((self.name, self.gradient))
}
}
/// A gradient instance on an object, between `Bb` and `BB`.
#[derive(Clone, Debug, Default)]
pub(super) struct Instance {
pub stroke: bool,
pub name: Option<String>,
origin: Point,
angle: f64,
length: f64,
/// `Bg`'s own matrix.
bg: Affine,
/// The gradient matrix (`Bm`), when there is one.
bm: Option<Affine>,
/// The focal point's offset from the origin (`Bh`).
hilight: Vec2,
}
fn affine(n: &[f64]) -> Option<Affine> {
let [a, b, c, d, e, f] = n else { return None };
let m = Affine::new([*a, *b, *c, *d, *e, *f]);
(m.as_coeffs().iter().all(|v| v.is_finite())).then_some(m)
}
impl Instance {
pub fn begin(vals: &[V]) -> Self {
let stroke = vals.iter().filter_map(V::num).next_back() == Some(1.0);
Self { stroke, length: 1.0, ..Self::default() }
}
/// `flag (name) x y angle length a b c d tx ty Bg`.
pub fn bg(&mut self, vals: &[V]) {
let Some(at) = vals.iter().rposition(|v| matches!(v, V::Str(_))) else { return };
self.name = vals.get(at).and_then(V::text);
let n: Vec<f64> = vals.get(at + 1..).unwrap_or_default().iter().filter_map(V::num).collect();
if let [x, y, angle, length, m @ ..] = n.as_slice() {
self.origin = Point::new(*x, *y);
self.angle = *angle;
self.length = *length;
self.bg = affine(m).unwrap_or(Affine::IDENTITY);
}
}
/// `a b c d tx ty Bm`.
pub fn bm(&mut self, vals: &[V]) {
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
self.bm = n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12);
}
/// `x y angle length Bh`.
pub fn bh(&mut self, vals: &[V]) {
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
if let [x, y, ..] = n.as_slice() {
self.hilight = Vec2::new(*x, *y);
}
}
/// The paint, through `to_doc` (art space → the document), when the gradient is defined.
pub fn paint(&self, defs: &HashMap<String, Gradient>, to_doc: Affine) -> Option<Paint> {
let name = self.name.as_ref()?;
let gradient = defs.get(name)?.clone();
let m = to_doc * self.bm.unwrap_or(Affine::IDENTITY) * self.bg;
let a = self.angle.to_radians();
let dir = Vec2::new(a.cos(), a.sin()) * self.length;
let start = m * self.origin;
let end = m * (self.origin + dir);
if !(start.is_finite() && end.is_finite()) || start.distance(end) < 1e-9 {
return None;
}
// Across the vector, for an elliptical radial gradient.
let across = m * (self.origin + Vec2::new(-dir.y, dir.x));
let aspect = (across.distance(start) / start.distance(end)).clamp(0.01, 100.0);
let focal = (self.hilight.hypot() > 1e-9).then(|| m * (self.origin + self.hilight)).filter(|p| p.is_finite());
let radial = gradient.kind == GradientKind::Radial;
let geom = GradientGeom { start, end, aspect: if radial { aspect } else { 1.0 }, focal: if radial { focal } else { None } };
let angle = -(end - start).atan2().to_degrees();
Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch: Some(name.clone()), ..GradientPaint::new(gradient) })))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn nums(v: &[f64]) -> Vec<V> {
v.iter().map(|n| V::Num(*n)).collect()
}
#[test]
fn colours() {
let (p, _) = color_op("g", &nums(&[0.7])).unwrap();
assert!(matches!(p.color(), Some(Color::Gray { k }) if (k - 0.3).abs() < 1e-6));
let (p, _) = color_op("XA", &nums(&[0.0, 0.99, 1.0, 0.0, 1.0, 0.0, 0.0])).unwrap();
assert_eq!(p.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
let mut v = nums(&[0.0, 0.5, 1.0, 0.0]);
v.push(V::Str(b"MySpot".to_vec()));
v.push(V::Num(0.4));
let (p, named) = color_op("x", &v).unwrap();
let named = named.unwrap();
assert_eq!(named.name, "MySpot");
assert!((named.tint - 0.6).abs() < 1e-6);
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
assert!(color_op("k", &nums(&[1.0])).is_none());
}
#[test]
fn gradient_definition_and_instance() {
let mut d = GradientDef::begin(&[V::Str(b"G".to_vec()), V::Num(1.0), V::Num(2.0)]).unwrap();
let mut spots = vec![];
d.stop(&nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.5, 6.0, 30.0, 100.0]), &mut spots);
d.stop(&nums(&[0.81, 0.0, 1.0, 6.0, 50.0, 0.0]), &mut spots);
let (name, g) = d.finish().unwrap();
assert_eq!(name, "G");
assert_eq!(g.kind, GradientKind::Radial);
assert_eq!(g.stops.len(), 2);
assert_eq!(g.stops[1].opacity, 0.5);
assert!(matches!(g.stops[0].color, Color::Gray { k } if (k - 0.19).abs() < 1e-6));
let defs = HashMap::from([(name, g)]);
let mut i = Instance::begin(&[]);
let mut bg = vec![V::Num(1.0), V::Str(b"G".to_vec())];
bg.extend(nums(&[0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
i.bg(&bg);
i.bm(&nums(&[50.0, 0.0, 0.0, -50.0, 100.0, 100.0]));
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
let geom = gp.geom.unwrap();
assert_eq!(geom.start, Point::new(100.0, 100.0));
assert_eq!(geom.end, Point::new(150.0, 100.0));
assert!((geom.aspect - 1.0).abs() < 1e-9);
assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none());
}
}
File diff suppressed because it is too large. Load diff
+409
View File
@@ -0,0 +1,409 @@
//! The editing data reader on synthetic data, written from the format as `read` documents it.
use std::sync::Arc;
use kurbo::{Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
use super::*;
/// Editing data: header comments (crop marks 0 0 200 100), then `body`.
fn stream(body: &str) -> String {
format!(
"%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"
)
}
/// One layer named `L` holding `art`.
fn layer(art: &str) -> String {
format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n0 A\n0 Xw\n{art}\nLB\n%AI5_EndLayer--\n")
}
fn read_ok(body: &str) -> Structure {
match read(stream(body).as_bytes()) {
Ok(i) => i,
Err(e) => panic!("{e}"),
}
}
/// The children of the first layer.
fn art(doc: &Document) -> Vec<Arc<Node>> {
doc.layers.first().and_then(|l| l.children()).cloned().unwrap_or_default()
}
fn fill(n: &Node) -> Paint {
n.appearance.fill().map(|f| f.paint.clone()).unwrap_or_default()
}
fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
format!("{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\n")
}
const RED: &str = "0 1 1 0 1 0 0 Xa\n";
#[test]
fn layers_and_sublayers() {
let body = format!(
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Bottom) Ln\n{RED}{}f\n\
%AI5_BeginLayer\n0 1 0 0 0 0 0 -1 10 200 30 0 50 0 Lb\n(Inner) Ln\n{}f\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\
%AI5_BeginLayer\n1 0 1 1 1 0 1 3 79 79 255 0 70 0 Lb\n(Top) Ln\n{}f\nLB\n%AI5_EndLayer--\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0)
);
let doc = read_ok(&body).doc;
let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Bottom", "Top"], "bottom first");
let bottom = &doc.layers[0];
assert!(bottom.visible && !bottom.locked);
let kids = bottom.children().unwrap();
assert_eq!(kids.len(), 2);
let inner = &kids[1];
assert_eq!(inner.name.as_deref(), Some("Inner"));
assert!(!inner.visible && inner.locked);
let NodeKind::Layer { color, printable, .. } = &inner.kind else { panic!("a sublayer") };
assert_eq!((*color, *printable), (LayerColor::Custom([10, 200, 30]), false));
let NodeKind::Layer { color, preview, dim_images, .. } = &doc.layers[1].kind else { panic!() };
assert_eq!((*color, *preview, *dim_images), (LayerColor::Preset(3), false, Some(70)));
}
#[test]
fn groups_names_hidden_and_locked() {
let art_ = format!(
"0 Ae\nu\n{RED}{}f\n%_/ArtDictionary :\n%_/XMLUID : (My_Rect__x23_2) ; (AI10_ArtUID) ,\n%_;\n%_\n\
1 A\n0 Ae\nu\n0 A\n1 Xw\n{}f\n0 Xw\nU\n%_/ArtDictionary :\n%_(Inner group) /String (AIArtName) ,\n%_;\n9 () XW\n\
0 A\nU\n%_/ArtDictionary :\n%_/XMLUID : (Outer) ; (AI10_ArtUID) ,\n%_;\n%_\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 top = art(&doc);
assert_eq!(top.len(), 1);
let outer = &top[0];
assert_eq!(outer.name.as_deref(), Some("Outer"));
let kids = outer.children().unwrap();
assert_eq!(kids[0].name.as_deref(), Some("My Rect #2"));
let inner = &kids[1];
assert_eq!(inner.name.as_deref(), Some("Inner group"));
assert!(inner.locked, "locked when it opened");
let hidden = &inner.children().unwrap()[0];
assert!(!hidden.visible);
}
#[test]
fn paths_colours_and_strokes() {
let art_ = format!(
"{RED}0 R\n0 0 1 0 0 0 1 XA\n1 J 2 j 3 w 4 M [6 3 ]1 d\n1 XR\n{}b\n\
0.1 0.2 0.3 0.4 k\n{}f\n0.7 g\n{}F\n0 0.5 1 0 (Ink) 0.4 x\n{}f\n\
10 50 m\n20 60 30 60 40 50 c\n50 40 60 40 v\n70 60 80 60 y\nS\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0),
rect_path(60.0, 0.0, 70.0, 10.0),
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
assert_eq!(a.len(), 5);
// y up from the crop marks' top: (0, 0)-(10, 10) is at y 90-100.
let NodeKind::Path { path, rule, .. } = &a[0].kind else { panic!() };
assert_eq!(path.bounds(), Some(Rect::new(0.0, 90.0, 10.0, 100.0)));
assert!(path.subpaths[0].closed);
assert_eq!(path.subpaths[0].anchors.len(), 4, "the closing point merges with the first");
assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd);
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
let st = a[0].appearance.stroke().unwrap();
assert_eq!((st.width, st.cap, st.join, st.miter_limit), (3.0, LineCap::Round, LineJoin::Bevel, 4.0));
assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
let dash = st.dash.clone().unwrap();
assert_eq!((dash.pattern, dash.offset), (vec![6.0, 3.0], 1.0));
assert_eq!(fill(&a[1]).color(), Some(Color::cmyk(0.1, 0.2, 0.3, 0.4)));
assert!(matches!(fill(&a[2]).color(), Some(Color::Gray { k }) if (k - 0.3).abs() < 1e-6));
assert!(!matches!(&a[2].kind, NodeKind::Path { path, .. } if path.subpaths[0].closed), "F leaves the path open");
let Paint::Solid { swatch, tint, .. } = fill(&a[3]) else { panic!() };
assert_eq!(swatch.as_deref(), Some("Ink"));
assert!((tint - 0.6).abs() < 1e-6);
let ink = doc.swatch("Ink").unwrap();
assert!(ink.spot && ink.global);
// Curves: `c`, `v` (first control at the current point) and `y` (second at the end).
let NodeKind::Path { path, .. } = &a[4].kind else { panic!() };
assert_eq!(path.subpaths[0].anchors.len(), 4);
assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some());
}
#[test]
fn compound_paths_and_clipping() {
let art_ = format!(
"0 Ae\n*u\n{RED}{}f\n{}f\n*U\n%_/ArtDictionary :\n%_/XMLUID : (Donut) ; (AI10_ArtUID) ,\n%_;\n\
0 Ae\nq\n{}f\n{}h\nW\nn\nQ\n%_/ArtDictionary :\n%_/XMLUID : (Clipped) ; (AI10_ArtUID) ,\n%_;\n9 () XW\n",
rect_path(0.0, 0.0, 30.0, 30.0),
rect_path(10.0, 10.0, 20.0, 20.0),
rect_path(40.0, 0.0, 80.0, 40.0),
rect_path(50.0, 10.0, 60.0, 20.0),
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!((children.len(), a[0].name.as_deref()), (2, Some("Donut")));
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
let NodeKind::Group { children, clip: true } = &a[1].kind else { panic!("a clipping group") };
assert_eq!(a[1].name.as_deref(), Some("Clipped"));
// 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 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!() };
assert!(*clip && matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
}
#[test]
fn transparency_of_objects_and_groups() {
let art_ = format!(
"{RED}1 0.5 0 0 0 Xy\n{}f\n0 Ae\nu\n0 1 0 0 0 Xy\n{}f\nU\n2 0.7 1 1 0 Xy\n0 0 Xd\n6 () XW\n0 1 0 0 0 Xy\n{}f\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0),
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
assert_eq!((a[0].opacity, a[0].blend), (0.5, BlendMode::Multiply));
assert_eq!((a[1].opacity, a[1].blend, a[1].isolate, a[1].knockout), (0.7, BlendMode::Screen, true, Knockout::On));
let inner = &a[1].children().unwrap()[0];
assert_eq!(inner.opacity, 1.0);
assert_eq!((a[2].opacity, a[2].blend), (1.0, BlendMode::Normal), "the next object's state isn't the group's");
}
#[test]
fn gradients() {
let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\
%_0 0 0 0 1 0 0 2 1 6 40 0 Bs\n%_0 0 0 0 0 0 1 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let art_ = format!(
"{RED}{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n{}f\n",
rect_path(50.0, 0.0, 150.0, 100.0),
rect_path(0.0, 0.0, 10.0, 10.0),
);
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let a = art(&doc);
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert_eq!(g.gradient.stops.len(), 2);
assert!((g.gradient.stops[0].midpoint - 0.4).abs() < 1e-6);
assert_eq!(g.gradient.stops[1].opacity, 0.5);
let geom = g.geom.unwrap();
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
// The gradient was the one object's.
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
#[test]
fn artboards() {
let data = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_(One) /UnicodeString (Name) ,\n%_; ,\n\
%_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let art_ = rect_path(420.0, -70.0, 470.0, -20.0) + "f\n";
let doc = read_ok(&format!("{data}{}", layer(&art_))).doc;
let boards: Vec<_> = doc.artboards.iter().map(|a| (a.name.clone(), a.rect)).collect();
assert_eq!(boards, [("One".to_string(), Rect::new(0.0, 0.0, 300.0, 200.0)), ("Artboard 2".to_string(), Rect::new(400.0, 200.0, 600.0, 350.0))]);
let NodeKind::Path { path, .. } = &art(&doc)[0].kind else { panic!() };
assert_eq!(path.bounds(), Some(Rect::new(420.0, 220.0, 470.0, 270.0)));
// Without artboards or crop marks: the art size around the template box's centre.
let old = format!(
"%!PS-Adobe-3.0\n%AI5_ArtSize: 600 400\n%AI3_TemplateBox: 300.5 199.5 300.5 199.5\n%%EndComments\n{}",
layer("10 10 m\n20 20 L\nS\n")
);
let doc = read(old.as_bytes()).unwrap().doc;
assert_eq!(doc.artboards[0].rect, Rect::new(0.0, 0.0, 600.0, 400.0));
}
#[test]
fn a_drawn_look_stands_for_its_object() {
// The look (a group, with a part on `%_` lines for newer readers), then the object on `%_`
// lines and its style.
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!(
"0 Ae\nu\n%_0 Ae\n%_u\n%_{hidden_rect}S\n%_U\n{RED}{}f\n0 0 0 0 0 0 1 XA\n{}S\nU\n\
%_/ArtDictionary :\n%_/XMLUID : (Neon_00000092429200007574234970000005974767392385616558_) ; (AI10_ArtUID) ,\n%_;\n\
0 0.4 0 0 0 Xy\n0 0 Xd\n6 () XW\n%_{hidden_rect}n\n%_/ArtDictionary :\n%_/XMLUID : (Neon) ; (AI10_ArtUID) ,\n%_;\n1 (Neon style) XW\n\
0 1 0 0 0 Xy\n{}f\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(30.0, 0.0, 40.0, 10.0),
);
let r = read_ok(&layer(&art_));
let a = art(&r.doc);
assert_eq!(a.len(), 2, "{a:#?}");
assert_eq!(a[0].name.as_deref(), Some("Neon"));
assert_eq!(a[0].opacity, 0.4);
assert_eq!(a[0].children().unwrap().len(), 2, "the newer readers' part is left out");
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
}
/// Editing data with one layer holding an image: `matrix` (`[a b c d tx ty]`), its size, colour
/// space, kind and alpha channels, its samples, and the size the data comment declares.
fn image(matrix: &str, (w, h): (u32, u32), (space, kind, alpha): (&str, u32, u32), data: &[u8], declared: usize) -> Vec<u8> {
let mut s = format!(
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/{space} XN\n{matrix} {w} {h} 0 Xh\n\
{matrix} 0 0 {w} {h} {w} {h} 8 {kind} {alpha} 0 1 0 4 4 0 0\n%%BeginData: {declared}\rXI\n"
)
.into_bytes();
s.extend_from_slice(data);
s.extend_from_slice(b"%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\nLB\r\n%AI5_EndLayer--\r\n");
let mut out = stream("").into_bytes();
let at = out.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap();
out.splice(at..at, s);
out
}
fn pixels(doc: &Document) -> (image::RgbaImage, kurbo::Affine) {
let a = art(doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
let blob = doc.images.get(&im.key).unwrap();
(image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), im.xf)
}
#[test]
fn images_with_alpha_channels() {
// RGB samples, then the alpha channel (not counted in the data's size).
let data = [255, 0, 0, 0, 255, 0, 255, 128];
let doc = read(&image("[ 10 0 0 10 50 90 ]", (2, 1), ("DeviceRGB", 3, 1), &data, 10)).unwrap().doc;
let (img, xf) = pixels(&doc);
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 255, 0, 128]));
// Pixel space (y down) to the top left at (50, 90) in art space: (50, 10) on the page.
assert_eq!(xf * Point::ZERO, Point::new(50.0, 10.0));
assert_eq!(xf * Point::new(2.0, 1.0), Point::new(70.0, 20.0));
// CMYK and grey.
let doc = read(&image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceCMYK", 4, 0), &[0, 0, 0, 255], 6)).unwrap().doc;
let px = pixels(&doc).0.get_pixel(0, 0).0;
assert!(px[0] < 64 && px[3] == 255, "100% black is dark: {px:?}");
let doc = read(&image("[ 1 0 0 1 0 100 ]", (2, 1), ("DeviceGray", 1, 0), &[0, 200], 6)).unwrap().doc;
assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [200, 200, 200, 255]);
// Data cut short: the image goes, with a warning.
let r = read(&image("[ 1 0 0 1 0 100 ]", (4, 4), ("DeviceRGB", 3, 0), &[1, 2, 3], 6)).unwrap();
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
}
#[test]
fn text_objects_are_slots_named_after_their_story() {
let words = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n3 /StoryIndex ,\n;\n%_/ArtDictionary :\n%_/XMLUID : (Title) ; (AI10_ArtUID) ,\n%_;\n";
let r = read_ok(&layer(&format!("1 Xw\n{words}0 Xw\n")));
let a = art(&r.doc);
assert_eq!(slot_of(a[0].name.as_deref()), Some(Some(3)));
assert!(!a[0].visible);
assert_eq!(r.slot_names.get(&a[0].id).map(String::as_str), Some("Title"));
// A copy on `%_` lines of a story that has a plain text object goes; one without stays.
let commented = |story: u32| -> String { format!("/AI11Text :\n{story} /StoryIndex ,\n;\n").lines().map(|l| format!("%_{l}\n")).collect() };
let r = read_ok(&layer(&format!("/AI11Text :\n0 /StoryIndex ,\n;\n{}{}", commented(0), commented(1))));
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
assert_eq!(stories, [Some(Some(0)), Some(Some(1))]);
}
#[test]
fn what_it_doesnt_read_is_an_error_where_it_shows() {
let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err();
assert!(e.contains("`frobnicate`"), "{e}");
let e = read(stream(&layer("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n")).as_bytes()).unwrap_err();
assert!(e.contains("pattern fills"), "{e}");
let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err();
assert!(e.contains("symbols"), "{e}");
// On a layer that doesn't show, it is left out of it.
let hidden = "%AI5_BeginLayer\n0 1 1 1 0 0 0 1 255 79 79 0 50 0 Lb\n(Off) Ln\n1 2 frobnicate\n(J) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\nLB\n%AI5_EndLayer--\n";
let r = read_ok(hidden);
assert_eq!(r.hidden_unread.iter().cloned().collect::<Vec<_>>(), ["`frobnicate`", "pattern fills"]);
// Unbalanced containers, a layer that doesn't end, no layers.
assert!(read(stream(&layer("U\n")).as_bytes()).is_err());
assert!(read(stream("%AI5_BeginLayer\n(L) Ln\n0 Ae\nu\n").as_bytes()).is_err());
assert!(read(stream("").as_bytes()).is_err());
// The setup's procedures and what follows the page don't count.
read_ok(&format!("Adobe_level2_AI5 /initialize get exec\n{}", layer("")));
}
#[test]
fn sections_it_skips() {
let body = "%AI5_Begin_NonPrinting\n%AI14_BeginSymbol\n0 Ae\nu\nfrobnicate\n%AI10_EndSymbol\n%AI3_BeginPattern: (P)\nU\nU\n%AI3_EndPattern\n%AI5_End_NonPrinting--\n\
%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n%AI17_Begin_Content_if_version_gt:23 1\nfrobnicate\n%AI17_End_Versioned_Content\nfrobnicate\n%AI17_End_Versioned_Content\n\
%_/Document :\n%_/Binary : /ASCII85Decode ,\n%_(]<[%\n%;;;~>\n%_; (Profile) ,\n%_;\n";
let doc = read_ok(&format!("{body}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))).doc;
assert_eq!(art(&doc).len(), 1);
}
#[test]
fn cmyk_documents() {
let doc = read_ok(&format!("%AI9_ColorModel: 2\n{}", layer(""))).doc;
assert_eq!(doc.color_mode, ColorMode::Cmyk);
}
#[test]
fn hostile_data_ends_without_panics() {
let deep = "0 Ae\nu\n".repeat(300) + &"U\n".repeat(300);
assert!(read(stream(&layer(&deep)).as_bytes()).unwrap_err().contains("nested too deeply"));
let keys = "/X :\n".repeat(300);
assert!(read(stream(&layer(&keys)).as_bytes()).is_err());
for junk in [
&b"%AI5_BeginLayer\n(\xff\xfe"[..],
b"%AI5_BeginLayer\n<zz",
b"]]]] [ [ ; , : : ;",
b"1e308 1e308 m 1e308 -1e308 L f",
b"%%BeginData: 5\rXI\n",
] {
let _ = read(junk);
}
// Images with absurd sizes are left out.
let r = read(&image("[ 1 0 0 1 0 0 ]", (1 << 20, 1 << 20), ("DeviceRGB", 3, 0), &[0; 12], 12)).unwrap();
assert!(art(&r.doc).is_empty());
}
fn zstd(data: &[u8]) -> Vec<u8> {
ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest)
}
/// An EPS whose page draws a line, and the editing data `packed` after it (`marker`: its codec).
fn eps(marker: &str, packed: &[u8]) -> Vec<u8> {
let lines: String = crate::ps::ascii85(packed).lines().map(|l| format!("%{l}\n")).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n%%EndComments\n0 0 moveto 10 10 lineto stroke\nshowpage\n%%EOF\n\
%AI9_PrivateDataBegin\n%!PS-Adobe-3.0 EPSF-3.0\n%AI3_Cropmarks: 0 0 200 100\n%AI5_FileFormat 14.0\n{marker}\n{lines}%AI9_PrivateDataEnd\n"
)
.into_bytes()
}
#[test]
fn eps_containers() {
let body = stream(&layer(&(rect_path(0.0, 0.0, 10.0, 10.0) + "f\n")));
let body = body.as_bytes();
for file in [eps("%AI24_DataStream", &zstd(body)), eps("%AI9_DataStream", &crate::ps::deflate(body))] {
let (ps, _) = crate::sections(&file).unwrap();
let data = eps_data(ps).unwrap().unwrap();
assert!(data.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0\n%AI3_Cropmarks"), "the header comes first");
assert!(data.ends_with(body));
assert_eq!(read(&data).unwrap().doc.layers.len(), 1);
}
// Damaged data says why.
let (ps, _) = crate::sections(&eps("%AI24_DataStream", b"not zstd")).map(|(p, t)| (p.to_vec(), t.map(<[u8]>::to_vec))).unwrap();
assert!(eps_data(&ps).unwrap().unwrap_err().contains("Zstandard"));
// Data that isn't compressed is used as it is.
let plain = format!("%!PS-Adobe-3.0\n%%EOF\n%AI9_PrivateDataBegin\n{}%AI9_PrivateDataEnd\n", stream(&layer("")));
assert!(read(&eps_data(plain.as_bytes()).unwrap().unwrap()).is_ok());
// An EPS without editing data has none.
let other = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n%%EndComments\n0 0 moveto 10 10 lineto stroke\nshowpage\n";
assert!(eps_data(other).is_none());
}
#[test]
fn ai_private_data() {
let body = stream(&layer(""));
let body = body.as_bytes();
let header = b"%%BoundingBox: 0 0 1 1\r\n";
for raw in [
[&b"%AI24_ZStandard_Data"[..], &zstd(body)].concat(),
[&header[..], b"%AI12_CompressedData", &crate::ps::deflate(body)].concat(),
body.to_vec(),
] {
let data = decode_private(&raw).unwrap();
assert!(data.ends_with(b"%%EOF\n"));
assert!(read(&data).is_ok());
}
assert!(decode_private(b"%AI12_CompressedDataxx").is_err());
}
+2 -96
View File
@@ -153,18 +153,6 @@ impl Default for GState {
}
}
/// The name of the empty group that stands for a text object (see [`Out::text_slot`]), followed by
/// the number of its story in the text document when the file says.
pub(crate) const TEXT_SLOT: &str = "\u{0}text";
/// Is `name` that of a text slot, and which story is it of?
pub(crate) fn slot_of(name: Option<&str>) -> Option<Option<u32>> {
name?.strip_prefix(TEXT_SLOT).map(|rest| rest.parse().ok())
}
/// An object drawn, with its chain of clips and groups.
type DrawnItem = (Vec<Arc<Clip>>, Node);
/// What the program draws.
pub(crate) struct Out {
/// The document being made: ids, images and swatches.
@@ -181,14 +169,6 @@ pub(crate) struct Out {
/// goes in the innermost group kept).
groups: Vec<Arc<Clip>>,
too_deep: usize,
/// Illustrator clipping groups open (`q` … `Q`): the group's id (none when it opened past
/// [`MAX_NEST`]) and how many objects were drawn before it.
open_clips: Vec<(Option<u32>, usize)>,
/// Illustrator compound paths open (`*u` … `*U`): how many objects were drawn before each.
compounds: Vec<(usize, bool)>,
/// What the file says of the objects it draws now: hidden (Illustrator's `Xw`, a state it
/// changes only when the next object differs).
pub hidden: bool,
/// 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.
@@ -224,9 +204,6 @@ impl Out {
next_clip: 0,
groups: vec![],
too_deep: 0,
open_clips: vec![],
compounds: vec![],
hidden: false,
elements: 0,
merge: None,
cmyk: 0,
@@ -252,7 +229,7 @@ impl Out {
return;
}
self.next_clip += 1;
self.groups.push(Arc::new(Clip { id: self.next_clip, region: None, hidden: self.hidden }));
self.groups.push(Arc::new(Clip { id: self.next_clip, region: None }));
}
/// End the innermost group open (an end without a beginning ends nothing).
@@ -264,76 +241,6 @@ impl Out {
}
}
/// Open an Illustrator clipping group (`q`): a group that [`Self::end_clip_group`] clips with
/// the clip path the file gives at its end.
pub fn begin_clip_group(&mut self) {
let deep = self.too_deep;
self.begin_group();
let id = if self.too_deep == deep { self.groups.last().map(|c| c.id) } else { None };
self.open_clips.push((id, self.drawn.len()));
}
/// End the innermost clipping group open (`Q`), clipped to `region` when the file gave one
/// (without, it stays a plain group). An end without a beginning ends nothing.
pub fn end_clip_group(&mut self, region: Option<(BezPath, FillRule)>) {
let Some((id, start)) = self.open_clips.pop() else { return };
let hidden = self.groups.last().is_some_and(|c| Some(c.id) == id && c.hidden);
self.end_group();
let (Some(id), Some(region)) = (id, region) else { return };
// The objects drawn in the group hold the group's clip as it was when they were drawn,
// without its region.
let clip = Arc::new(Clip { id, region: Some(region), hidden });
for (chain, _) in self.drawn.iter_mut().skip(start) {
for c in chain.iter_mut().filter(|c| c.id == id) {
*c = clip.clone();
}
}
}
/// Open an Illustrator compound path (`*u`): the paths drawn until [`Self::end_compound`] are
/// its parts.
pub fn begin_compound(&mut self) {
self.compounds.push((self.drawn.len(), self.hidden));
}
/// End the innermost compound path open (`*U`): its parts become one compound path painted
/// with the first part's appearance (what Illustrator does). One part stays a plain path.
pub fn end_compound(&mut self) {
let Some((start, hidden)) = self.compounds.pop() else { return };
let parts = self.drawn.get(start..).unwrap_or_default();
let same_chain = |a: &DrawnItem, b: &DrawnItem| a.0.iter().map(|c| c.id).eq(b.0.iter().map(|c| c.id));
let Some(first) = parts.first() else { return };
if parts.len() < 2 || !parts.iter().all(|p| matches!(p.1.kind, NodeKind::Path { .. }) && same_chain(first, p)) {
return;
}
let parts = self.drawn.split_off(start);
let chain = parts.first().map(|p| p.0.clone()).unwrap_or_default();
let (mut rule, mut appearance) = (FillRule::NonZero, Appearance::default());
let mut children = Vec::with_capacity(parts.len());
for (i, (_, mut part)) in parts.into_iter().enumerate() {
if i == 0 {
appearance = std::mem::take(&mut part.appearance);
if let NodeKind::Path { rule: r, .. } = &part.kind {
rule = *r;
}
}
part.appearance = Appearance::default();
children.push(Arc::new(part));
}
let mut compound = Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule });
compound.appearance = appearance;
compound.visible = !hidden;
self.drawn.push((chain, compound));
}
/// Where a text object of the file's editing data goes: an empty group named [`TEXT_SLOT`],
/// which the importer replaces with the text it read from the file's page.
pub fn text_slot(&mut self, story: Option<u32>) {
let mut n = Node::group(NodeId(0), vec![]);
n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}")));
self.push(n, &[]);
}
/// The chain an object drawn under `clips` is drawn in: the clips and the groups open, in the
/// order they opened.
pub fn chain(&self, clips: &[Arc<Clip>]) -> Vec<Arc<Clip>> {
@@ -365,7 +272,6 @@ impl Out {
return None;
}
node.id = self.doc.alloc_id();
node.visible = !self.hidden;
let id = node.id;
self.drawn.push((chain, node));
Some(id)
@@ -420,7 +326,7 @@ impl Out {
return None;
}
self.next_clip += 1;
Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)), hidden: false }))
Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) }))
}
}
+1 -37
View File
@@ -7,7 +7,7 @@
use std::cell::RefCell;
use std::rc::Rc;
use vectorcraft_geom::{Affine, BezPath, FillRule};
use vectorcraft_geom::Affine;
use super::graphics::{GState, Out};
use super::lex::{IMMEDIATE, Lexer};
@@ -65,11 +65,6 @@ pub(crate) struct Interp<'a> {
/// The program is in the legacy Illustrator format: its group operators `u` … `U` (at the top
/// level, whatever its prolog defines them as) are groups (see the module docs of `import`).
pub illustrator: bool,
/// The program is the editing copy of an Illustrator file's art (see `native`): its clipping
/// groups, compound paths and text objects have operators of their own too.
pub native: bool,
/// The clip path `W` set for the clipping group about to end.
clip_region: Option<(BezPath, FillRule)>,
}
/// Where a PostScript error was raised: the operator that raised it (none for an unknown name)
@@ -169,8 +164,6 @@ impl<'a> Interp<'a> {
saved: vec![],
out,
illustrator: false,
native: false,
clip_region: None,
}
}
@@ -179,9 +172,6 @@ impl<'a> Interp<'a> {
loop {
let Some(o) = self.lex.next()? else { return Ok(()) };
let o = self.scanned(o, self.lex.immediate)?;
if self.native && self.native_op(&o)? {
continue;
}
// A group begins before its `u` runs (`Some(true)`) and ends after its `U`
// has (`Some(false)`).
let group = match &o {
@@ -205,32 +195,6 @@ impl<'a> Interp<'a> {
}
}
/// The operators only the editing copy of an Illustrator file's art has (`native`), run
/// instead of defined; `false` for any other token.
fn native_op(&mut self, o: &Obj) -> Res<bool> {
let Obj::Exec(name) = o else { return Ok(false) };
match &**name {
"q" => self.out.begin_clip_group(),
"Q" => {
let region = self.clip_region.take();
self.out.end_clip_group(region);
}
"W" => {
let path = self.take_path();
self.clip_region = (!path.elements().is_empty()).then_some((path, FillRule::NonZero));
}
"*u" => self.out.begin_compound(),
"*U" => self.out.end_compound(),
"Xw" => self.out.hidden = self.pop_num()? != 0.0,
"__txt" => {
let story = self.pop_num()?;
self.out.text_slot((story >= 0.0 && story < f64::from(u32::MAX)).then_some(story as u32));
}
_ => return Ok(false),
}
Ok(true)
}
/// Empty the standard dictionaries, so the memory they hold is freed with the interpreter:
/// systemdict holds itself, and programs link dictionaries into each other.
pub fn release(&mut self) {
+1
View File
@@ -25,6 +25,7 @@
//! error, the operator and the procedures it ran in; without a preview, what was drawn before the
//! error is kept (with that warning), else the file is refused.
mod ai;
mod ate;
mod data;
mod graphics;
+159 -611
View File
@@ -1,84 +1,46 @@
//! The layers of an Illustrator EPS or `.ai`.
//!
//! Besides the page it prints, such a file carries the app's own copy of the art: an EPS after its
//! `%%EOF`, between `%AI9_PrivateDataBegin` and `%AI9_PrivateDataEnd` (ASCII85 text of a Zstandard
//! stream, `%AI24_DataStream`; older files: zlib, `%AI9_DataStream`); a PDF-compatible `.ai` in its `AIPrivateData` streams (the Zstandard
//! stream after `%AI24_ZStandard_Data`). It is the document as the legacy Illustrator format writes
//! it (`%AI5_BeginLayer`, `Lb`, `Ln`, `u` … `U`, `*u` … `*U`, `q` … `Q`, `m` `l` `c` `f` `S` …). It
//! has what the page doesn't: the layers with their names, which of them are hidden (and what is on
//! them), the hidden objects, the art outside the artboards, the groups, the compound paths and the
//! clipping groups.
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). It has what the
//! page doesn't: the layers with their names and options, the groups, compound paths and clipping
//! groups, the objects' names, the hidden objects and layers, the art outside the artboards and
//! every artboard. `ai` reads that structure into a document, with the colours, gradients,
//! transparency and images of the art.
//!
//! The page stays the source of what the art looks like; this copy only gives it its structure. Only
//! the legacy format's own operators and comments are read, never what the app keeps in its private
//! comments (`%_`) and dictionaries. So:
//! Text objects are slots in that document: their characters, fonts and places are in the file's
//! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot
//! whose text is where the page has it. Type that doesn't show (a hidden layer or object, or off
//! the page) is made from the text document; where that can't be read (area type, type on a path)
//! it's left out, with a warning.
//!
//! - Text objects are stubs here: their characters, fonts and places are in the file's text document
//! (see `ate`). Type that shows comes from the page, which draws it, into the slot whose text is
//! where the page has it. Type that doesn't show (a hidden layer or object, or off the page) is made
//! from the text document; where that can't be read (area type, type on a path) it's left out, with
//! a warning.
//! - A line with an operator that isn't read (gradients, patterns, images, blends, …) isn't read. If
//! one is on a layer that shows, the art of this copy would differ from the page's, so the layers
//! aren't used: the file comes in as its page, as it did before.
//!
//! Sources (no Adobe software was run, no Adobe file is in this repo, and no Adobe specification,
//! SDK or installation was used):
//!
//! - The layer, group, compound-path and clipping vocabulary of the legacy format, the container
//! (`%AI9_PrivateDataBegin`, `%AI24_DataStream`, `%AI24_ZStandard_Data`, the `AIPrivateData`
//! streams), the meaning of the `Xw` hidden flag and the text document were worked out from `.eps`
//! and `.ai` files their users own, used locally and never committed. (Adobe published a
//! specification of the legacy format, listed by PRONOM at
//! <https://www.nationalarchives.gov.uk/PRONOM/fmt/423>; it was not used here.)
//! - The wrappers: the text is the standard ASCII85 encoding; the compressed stream is Zstandard,
//! RFC 8878 (<https://www.rfc-editor.org/rfc/rfc8878>), read with the permissively licensed
//! `ruzstd` crate (MIT); the older one is zlib, read with `flate2`. The PostScript the layers are
//! translated into is run by this project's own interpreter (`interp`).
//! What a layer that shows has that `ai` doesn't read makes the file come in as its page, as it did
//! before; on a layer that doesn't show it is left out of that layer, with a warning. The page is
//! also the safety net: when the layers' art looks different from it, the page is used.
//!
//! Where a file doesn't match what this module expects, it is left alone and its page is imported.
use std::collections::BTreeSet;
use std::io::Read;
use std::sync::Arc;
use vectorcraft_doc::clipnest::nest;
use vectorcraft_doc::{AppearanceItem, Document, LayerColor, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use super::Imported;
use super::ai::{self, Structure, slot_of};
use super::ate::{Story, Texts};
use super::graphics::{GState, Out, slot_of};
use super::interp::Interp;
use super::{Imported, box_of, finish_with, reason};
const BEGIN: &[u8] = b"%AI9_PrivateDataBegin";
const END: &[u8] = b"%AI9_PrivateDataEnd";
const STREAM: &[u8] = b"%AI24_DataStream";
/// The same, in files from before Zstandard.
const STREAM_ZLIB: &[u8] = b"%AI9_DataStream";
/// What a `.ai`'s private data starts with, before its Zstandard stream.
const ZSTD_MARK: &[u8] = b"%AI24_ZStandard_Data";
/// Most bytes the editing copy may have once decompressed.
const MAX_DECODED: u64 = 256 << 20;
/// The largest Zstandard window a frame may ask for: the decoder allocates it before reading.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
/// The notes that say the import left out something the file has (see [`is_loss`]).
const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out";
const ART_LEFT_OUT: &str = "hidden layers have art this can't read";
const PART_LEFT_OUT: &str = "part of a layer was left out";
const LAYERS_UNREAD: &str = "the file's layers weren't read";
/// Does this import note say that the file has something the document doesn't (hidden text, art or
/// layers that could not be read)? Writing the document over the file would lose it for good.
pub fn is_loss(note: &str) -> bool {
note.ends_with(TEXT_LEFT_OUT)
|| [ART_LEFT_OUT, PART_LEFT_OUT, LAYERS_UNREAD, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
|| [ART_LEFT_OUT, LAYERS_UNREAD, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
}
/// Deepest nesting of layers read (a file with deeper ones comes in as its page).
const MAX_DEPTH: usize = 32;
/// Most layers read.
const MAX_LAYERS: usize = 4096;
/// Most text objects in the layers, and most pieces of type in one place on the page, that are matched
/// to each other (more than this and the layers aren't used).
const MAX_SLOTS: usize = 5000;
@@ -88,378 +50,6 @@ const MAX_TEXT: usize = 16 << 20;
/// Most pairs of a text object and a piece of the page's type compared.
const MAX_PAIRS: usize = 4_000_000;
/// What the operators of the format do, as PostScript: paths are the path operators, a fill or a
/// stroke uses the colour the last `k` `K` `g` `G` set (a file's objects each set their own).
const PROLOGUE: &str = "\
/m {moveto} def /l {lineto} def /L {lineto} def /c {curveto} def /C {curveto} def
/v {currentpoint 6 2 roll curveto} def /V {currentpoint 6 2 roll curveto} def
/y {2 copy curveto} def /Y {2 copy curveto} def
/h {closepath} def /N {newpath} def /n {closepath newpath} def
/fc [0 0 0 0] def /sc [0 0 0 1] def /eo false def
/setc {dup length 1 eq {0 get setgray} {dup length 3 eq {aload pop setrgbcolor} {aload pop setcmykcolor} ifelse} ifelse} def
/k {4 array astore /fc exch def} def /K {4 array astore /sc exch def} def
/g {1 exch sub 0 0 0 4 -1 roll 4 array astore /fc exch def} def /G {1 exch sub 0 0 0 4 -1 roll 4 array astore /sc exch def} def
/Xk {pop pop pop 4 array astore /fc exch def} def /XK {pop pop pop 4 array astore /sc exch def} def
/x {pop pop 4 array astore /fc exch def} def /X {pop pop 4 array astore /sc exch def} def
/Xx {6 {pop} repeat 4 array astore /fc exch def} def /XX {6 {pop} repeat 4 array astore /sc exch def} def
/H {} def
/D {1 eq /eo exch def} def
/doF {fc setc eo {eofill} {fill} ifelse} def /doS {sc setc stroke} def
/F {doF} def /f {closepath doF} def /S {doS} def /s {closepath doS} def
/B {gsave doF grestore doS} def /b {closepath B} def
/w {setlinewidth} def /J {setlinecap} def /j {setlinejoin} def /M {setmiterlimit} def /d {setdash} def
/u {} def /U {} def
";
/// Operators that draw, set the state they draw with, or group (`q` `Q` `W` `*u` `*U` are the
/// interpreter's own, see `Interp::native_op`).
const KEEP: &[&str] = &[
"m", "l", "L", "c", "C", "v", "V", "y", "Y", "h", "n", "N", "f", "F", "s", "S", "b", "B", "k", "K", "g", "G", "Xk", "XK", "x", "X", "Xx", "XX",
"H", "w", "J", "j", "M", "d", "D", "u", "U", "*u", "*U", "q", "Q", "W", "Xw",
];
/// Operators that only describe an object to the app (its lock, its overprint, its name's id, …):
/// the line is left out.
const IGNORE: &[&str] = &["A", "Ae", "AE", "As", "Ap", "O", "R", "i", "Xd", "Xy", "XR", "XW", "XP"];
/// One token of a line.
enum Tok<'a> {
Num(f64),
Str(Vec<u8>),
Name(&'a str),
}
/// The tokens of `line`: numbers, strings, names (the brackets of an array are left out); `None`
/// for a string that doesn't end.
fn tokens(line: &str) -> Option<Vec<Tok<'_>>> {
let b = line.as_bytes();
let mut out = vec![];
let mut i = 0;
while let Some(&c) = b.get(i) {
match c {
c if c.is_ascii_whitespace() || c == b'[' || c == b']' => i += 1,
b'(' => {
let (mut depth, mut s) = (1, vec![]);
i += 1;
loop {
let c = *b.get(i)?;
i += 1;
match c {
b'\\' => {
let e = *b.get(i)?;
i += 1;
s.push(match e {
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'0'..=b'7' => {
let mut v = u32::from(e - b'0');
for _ in 0..2 {
match b.get(i) {
Some(d @ b'0'..=b'7') => {
v = v * 8 + u32::from(d - b'0');
i += 1;
}
_ => break,
}
}
(v & 0xff) as u8
}
other => other,
});
}
b'(' => {
depth += 1;
s.push(c);
}
b')' => {
depth -= 1;
if depth == 0 {
break;
}
s.push(c);
}
_ => s.push(c),
}
}
out.push(Tok::Str(s));
}
_ => {
let start = i;
while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'[' | b']' | b'(')) {
i += 1;
}
let word = line.get(start..i)?;
out.push(word.parse::<f64>().map_or(Tok::Name(word), Tok::Num));
}
}
}
Some(out)
}
/// What the editing copy holds of a layer.
#[derive(Default)]
struct Layer {
name: String,
visible: bool,
color: Option<[u8; 3]>,
items: Vec<Item>,
/// The code read since the last sublayer.
code: String,
/// Operators on this layer that aren't read.
unsupported: BTreeSet<String>,
}
enum Item {
Code(String),
Sub(Layer),
}
impl Layer {
fn flush(&mut self) {
if !self.code.is_empty() {
self.items.push(Item::Code(std::mem::take(&mut self.code)));
}
}
}
struct Parsed {
/// The art's bounding box `llx lly urx ury`.
bbox: [f64; 4],
/// The first artboard (`%AI3_Cropmarks`), in the same space.
artboard: Option<[f64; 4]>,
/// The template box (`%AI3_TemplateBox`): its centre is the centre of the app's canvas.
template: Option<[f64; 4]>,
layers: Vec<Layer>,
}
/// The editing copy of the art, decompressed.
fn decode(ps: &[u8]) -> Option<Vec<u8>> {
fn find(h: &[u8], n: &[u8], from: usize) -> Option<usize> {
h.get(from..)?.windows(n.len()).position(|w| w == n).map(|p| p + from)
}
let begin = find(ps, BEGIN, 0)?;
let end = find(ps, END, begin).unwrap_or(ps.len());
let block = ps.get(begin..end)?;
// Newer files compress with Zstandard, older ones (CS-era) with zlib.
let (at, zstd) = match find(block, STREAM, 0) {
Some(at) => (at + STREAM.len(), true),
None => (find(block, STREAM_ZLIB, 0)? + STREAM_ZLIB.len(), false),
};
// The stream's lines each start with a `%`.
let text: String = block
.get(at..)?
.split(|b| matches!(b, b'\r' | b'\n'))
.filter(|l| !l.is_empty())
.flat_map(|l| l.strip_prefix(b"%").unwrap_or(l).iter())
.map(|b| char::from(*b))
.collect();
let packed = crate::ps::ascii85_decode(&text)?;
if zstd { unzstd(&packed) } else { unzlib(&packed) }
}
/// The editing copy of a PDF-compatible `.ai`'s `AIPrivateData` streams, joined, decompressed.
fn decode_ai(private: &[u8]) -> Option<Vec<u8>> {
unzstd(private.strip_prefix(ZSTD_MARK)?)
}
/// `packed` as a zlib stream decompressed (up to [`MAX_DECODED`] bytes).
fn unzlib(packed: &[u8]) -> Option<Vec<u8>> {
let mut data = vec![];
flate2::read::ZlibDecoder::new(packed).take(MAX_DECODED).read_to_end(&mut data).ok()?;
(data.len() as u64 != MAX_DECODED).then_some(data)
}
/// `packed` as a Zstandard stream decompressed (up to [`MAX_DECODED`] bytes).
fn unzstd(packed: &[u8]) -> Option<Vec<u8>> {
let mut zstd = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(packed, MAX_ZSTD_WINDOW).ok()?;
let mut data = vec![];
(&mut zstd).take(MAX_DECODED).read_to_end(&mut data).ok()?;
(data.len() as u64 != MAX_DECODED).then_some(data)
}
/// The stories that text objects written plainly (not as comments) name.
fn plain_stories(data: &[u8]) -> BTreeSet<u32> {
data.split(|b| matches!(b, b'\r' | b'\n'))
.filter_map(|raw| {
let line = String::from_utf8_lossy(raw);
line.trim().strip_suffix("/StoryIndex ,").and_then(|n| n.trim().parse().ok())
})
.collect()
}
/// The layers of the editing copy `data`.
fn parse(data: &[u8]) -> Option<Parsed> {
let (mut bbox, mut hires, mut artboard, mut template) = (None, None, None, None);
let mut stack: Vec<Layer> = vec![];
let mut done: Vec<Layer> = vec![];
let mut dict_depth = 0usize;
// In a text object's dictionary: the story it names, once the dictionary says.
let mut text_story: Option<Option<u32>> = None;
// Whether that text object is written as comments, which the file does for some of its stories
// twice (once plain, once commented) and for others only commented.
let mut text_commented = false;
let plain = plain_stories(data);
let mut count = 0usize;
for raw in data.split(|b| matches!(b, b'\r' | b'\n')) {
let line = String::from_utf8_lossy(raw);
let line = line.trim();
if line.is_empty() {
continue;
}
// A text object's dictionary may be written as comments (`%_`), which a reader that doesn't
// know text objects skips: what that reader skips is read here, for the text object only.
let line = match line.strip_prefix("%_") {
Some(rest) if dict_depth > 0 || rest.starts_with("/AI11Text") && rest.ends_with(':') => {
if dict_depth == 0 {
text_commented = true;
}
rest.trim()
}
_ => {
if dict_depth == 0 {
text_commented = false;
}
line
}
};
if line.starts_with('%') {
match line {
"%AI5_BeginLayer" => {
count += 1;
// Too many, too deep, or in the middle of a dictionary: not a file this reads.
if count > MAX_LAYERS || stack.len() > MAX_DEPTH || dict_depth > 0 {
return None;
}
if let Some(parent) = stack.last_mut() {
parent.flush();
}
stack.push(Layer { visible: true, ..Layer::default() });
}
"%AI5_EndLayer--" => {
if dict_depth > 0 {
return None;
}
let mut l = stack.pop()?;
l.flush();
match stack.last_mut() {
Some(parent) => parent.items.push(Item::Sub(l)),
None => done.push(l),
}
}
_ if stack.is_empty() => {
if let Some(v) = line.strip_prefix("%%HiResBoundingBox:") {
hires = box_of(v.trim()).or(hires);
} else if let Some(v) = line.strip_prefix("%%BoundingBox:") {
bbox = box_of(v.trim()).or(bbox);
} else if let Some(v) = line.strip_prefix("%AI3_Cropmarks:") {
artboard = box_of(v.trim()).or(artboard);
} else if let Some(v) = line.strip_prefix("%AI3_TemplateBox:") {
template = four_numbers(v).or(template);
}
}
_ => {}
}
continue;
}
let Some(layer) = stack.last_mut() else { continue };
// The app's dictionaries (`/Name :` … `;`): a text object's is a slot for its text.
if dict_depth > 0 {
// A dictionary ends with `;`, which may have its key after it (`; /ConfiningPath ,`).
if line == ";" || line.starts_with("; /") {
dict_depth -= 1;
if dict_depth == 0
&& let Some(story) = text_story.take()
{
// A commented copy of a story that has a plain text object is not another object.
if !(text_commented && story.is_some_and(|n| plain.contains(&n))) {
layer.code.push_str(&format!("{} __txt\n", story.map_or(-1, i64::from)));
}
}
} else if line.ends_with(':') {
dict_depth += 1;
} else if text_story.is_some()
&& let Some(n) = line.strip_suffix("/StoryIndex ,")
{
text_story = Some(n.trim().parse().ok());
}
continue;
}
if line.starts_with('/') {
if line.ends_with(':') {
dict_depth = 1;
if line.starts_with("/AI11Text") {
text_story = Some(None);
} else {
layer.unsupported.insert("an object with its own dictionary".into());
}
} else {
layer.unsupported.insert("an unknown object".into());
}
continue;
}
let Some(toks) = tokens(line) else {
layer.unsupported.insert("an unreadable line".into());
continue;
};
let last = toks.last().and_then(|t| if let Tok::Name(n) = t { Some(*n) } else { None });
match last {
Some("Lb") => {
let n: Vec<f64> = toks.iter().filter_map(|t| if let Tok::Num(v) = t { Some(*v) } else { None }).collect();
layer.visible = n.first().is_none_or(|v| *v != 0.0);
if let [r, g, b] = n.get(8..11).unwrap_or_default() {
let byte = |v: &f64| (v.is_finite() && (0.0..=255.0).contains(v)).then_some(*v as u8);
layer.color = byte(r).zip(byte(g)).zip(byte(b)).map(|((r, g), b)| [r, g, b]);
}
continue;
}
Some("Ln") => {
if let Some(Tok::Str(s)) = toks.first() {
layer.name = String::from_utf8_lossy(s).into_owned();
}
continue;
}
Some("LB") => continue,
_ => {}
}
let mut ignored = false;
let mut missing: Option<&str> = None;
for t in &toks {
if let Tok::Name(n) = t {
if IGNORE.contains(n) {
ignored = true;
} else if !KEEP.contains(n) {
missing = missing.or(Some(n));
}
}
}
if let Some(n) = missing {
let shown = n.len() <= 8 && n.chars().all(|c| c.is_ascii_graphic());
layer.unsupported.insert(if shown { format!("`{n}`") } else { "binary data".to_string() });
} else if !ignored {
layer.code.push_str(line);
layer.code.push('\n');
}
}
if dict_depth > 0 {
return None;
}
while let Some(mut l) = stack.pop() {
l.flush();
match stack.last_mut() {
Some(parent) => parent.items.push(Item::Sub(l)),
None => done.push(l),
}
}
Some(Parsed { bbox: hires.or(bbox)?, artboard, template, layers: done })
}
/// Four numbers (a box that may be a point).
fn four_numbers(v: &str) -> Option<[f64; 4]> {
let n: Vec<f64> = v.split_whitespace().map_while(|w| w.parse::<f64>().ok()).collect();
let [a, b, c, d] = n.as_slice() else { return None };
[a, b, c, d].iter().all(|v| v.is_finite() && v.abs() <= 1e7).then_some([*a, *b, *c, *d])
}
/// `names` for a note: the first few, and how many more.
fn few(names: BTreeSet<String>) -> String {
const SHOWN: usize = 5;
@@ -471,19 +61,6 @@ fn few(names: BTreeSet<String>) -> String {
list.join(", ")
}
/// What the operators not read are, on layers that show and on layers that don't.
fn unread(layers: &[Layer], shown: bool, on: &mut BTreeSet<String>, off: &mut BTreeSet<String>) {
for l in layers {
let shown = shown && l.visible;
let set = if shown { &mut *on } else { &mut *off };
set.extend(l.unsupported.iter().cloned());
let subs: Vec<&Layer> = l.items.iter().filter_map(|i| if let Item::Sub(s) = i { Some(s) } else { None }).collect();
for s in subs {
unread(std::slice::from_ref(s), shown, on, off);
}
}
}
/// A text slot of the layers.
struct SlotInfo {
story: Option<u32>,
@@ -503,54 +80,6 @@ fn collect_slots(nodes: &[Arc<Node>], shown: bool, out: &mut Vec<SlotInfo>) {
}
}
/// Builds the layers' nodes by running each stretch of their code.
struct Builder {
out: Option<Out>,
}
impl Builder {
/// What `code` draws, as the nodes of a layer.
fn run(&mut self, code: &str) -> Vec<Arc<Node>> {
let Some(out) = self.out.take() else { return vec![] };
let src = format!("{PROLOGUE}{code}");
let mut out = out;
out.hidden = false;
let mut it = Interp::new(src.as_bytes(), GState::default(), out);
it.illustrator = true;
it.native = true;
let result = it.run();
it.release();
let fault = it.fault.take();
let mut out = it.out;
if let Err(e) = result {
out.warn(&format!("{PART_LEFT_OUT}: {}", reason(&e, fault.as_ref())));
}
let drawn = std::mem::take(&mut out.drawn);
let nodes = nest(&mut out.doc, drawn);
self.out = Some(out);
nodes
}
fn layer(&mut self, l: &Layer, depth: usize) -> Option<Arc<Node>> {
let mut children = vec![];
for item in &l.items {
match item {
Item::Code(code) => children.extend(self.run(code)),
Item::Sub(sub) if depth < MAX_DEPTH => children.extend(self.layer(sub, depth + 1)),
Item::Sub(_) => {}
}
}
let out = self.out.as_mut()?;
let name = if l.name.is_empty() { "Layer" } else { &l.name };
let mut node = Node::layer(out.doc.alloc_id(), name, l.color.map_or(LayerColor::Preset(0), LayerColor::Custom));
node.visible = l.visible;
if let Some(c) = node.children_mut() {
*c = children;
}
Some(Arc::new(node))
}
}
/// How far from a text object's own extent the strokes drawn around it reach (points).
const OUTLINE_REACH: f64 = 6.0;
@@ -657,24 +186,46 @@ enum Content {
}
/// Replace the text slots in `nodes` with what `content` has for each, in order (a slot given none is
/// left out). `empty` counts those.
fn fill_slots(doc: &mut Document, nodes: &mut Vec<Arc<Node>>, content: &mut std::vec::IntoIter<Content>, empty: &mut usize) {
/// left out): the page's type takes its place as it is, made type keeps the slot's name. `empty`
/// counts those left out.
fn fill_slots(
doc: &mut Document,
nodes: &mut Vec<Arc<Node>>,
content: &mut std::vec::IntoIter<Content>,
names: &std::collections::HashMap<NodeId, String>,
empty: &mut usize,
) {
let mut i = 0;
while i < nodes.len() {
let Some(n) = nodes.get(i) else { break };
if slot_of(n.name.as_deref()).is_some() {
let visible = n.visible;
let name = names.get(&n.id);
let replacement = match content.next() {
Some(Content::Page(parts)) if !parts.is_empty() => {
let children = parts.iter().map(|p| reid(doc, p)).collect();
let mut group = Node::group(doc.alloc_id(), children);
group.visible = visible;
Some(group)
// The page's type (and the strokes round it) take the slot's place as they are.
let parts: Vec<Arc<Node>> = parts
.iter()
.map(|p| {
let mut p = reid(doc, p);
if !visible {
Arc::make_mut(&mut p).visible = false;
}
if let Some(name) = name.filter(|_| matches!(p.kind, NodeKind::Text(_))) {
Arc::make_mut(&mut p).name = Some(name.clone());
}
p
})
.collect();
let n = parts.len();
nodes.splice(i..=i, parts);
i += n;
continue;
}
Some(Content::Made(mut text)) => {
text.id = doc.alloc_id();
text.visible = visible;
text.name = Some(MADE.into());
text.name = Some(format!("{MADE}{}", name.map_or("", String::as_str)));
Some(*text)
}
Some(Content::Merged) => {
@@ -700,7 +251,7 @@ fn fill_slots(doc: &mut Document, nodes: &mut Vec<Arc<Node>>, content: &mut std:
&& !matches!(n.kind, NodeKind::Compound { .. })
&& let Some(c) = nodes.get_mut(i).map(Arc::make_mut).and_then(Node::children_mut)
{
fill_slots(doc, c, content, empty);
fill_slots(doc, c, content, names, empty);
}
i += 1;
}
@@ -976,22 +527,50 @@ fn plan(
content
}
/// How much of `page` two documents draw differently (0 to 1), each seen on white.
fn difference(a: &Document, b: &Document, page: Rect) -> f64 {
let scale = (400.0 / page.width().max(page.height()).max(1.0)).min(4.0);
/// `doc` with its layers that don't print hidden, when it has any.
fn as_printed(doc: &Document) -> Option<Document> {
fn hide(nodes: &mut [Arc<Node>]) -> bool {
let mut any = false;
for n in nodes {
if matches!(n.kind, NodeKind::Layer { printable: false, .. }) && n.visible {
Arc::make_mut(n).visible = false;
any = true;
} else if matches!(n.kind, NodeKind::Layer { .. })
&& let Some(c) = Arc::make_mut(n).children_mut()
{
any |= hide(c);
}
}
any
}
let mut d = doc.clone();
hide(&mut d.layers).then_some(d)
}
/// The lightness (0 to 255, Rec. 601 weights) of a premultiplied pixel seen on white.
fn lightness(p: &[u8; 4]) -> u8 {
let on_white = |c: u8| u32::from(c) + 255 - u32::from(p[3]).min(255);
((on_white(p[0]) * 299 + on_white(p[1]) * 587 + on_white(p[2]) * 114) / 1000).min(255) as u8
}
/// How much two documents draw differently (0 to 1), `a` on `ra` and `b` on `rb` (rectangles of
/// the same size), each seen on white. Pixels are compared by lightness: the page of an RGB
/// document is written in the colours' CMYK equivalents, and what the comparison looks for is art
/// that is missing or out of place.
fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> f64 {
// The page doesn't print what isn't printed.
let printed = as_printed(b);
let b = printed.as_ref().unwrap_or(b);
let scale = (400.0 / ra.width().max(ra.height()).max(1.0)).min(4.0);
let mut r = vectorcraft_render::Renderer::new();
let (x, y) = (r.render_region(a, page, scale, true), r.render_region(b, page, scale, true));
// Drawn on nothing and then seen on white: blending modes have no page to blend with, as in
// the app that wrote the file.
let (x, y) = (r.render_region(a, ra, scale, false), r.render_region(b, rb, scale, false));
if x.width != y.width || x.height != y.height {
return 1.0;
}
let differing = x
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(y.pixels.as_chunks::<4>().0)
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(a, b)| a.abs_diff(*b) > 48))
.count();
let differing =
x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| lightness(p).abs_diff(lightness(q)) > 48).count();
differing as f64 / (f64::from(x.width) * f64::from(x.height)).max(1.0)
}
@@ -1010,49 +589,45 @@ enum Page {
Artboard,
}
/// What [`build`] made: the layers, and the drawing state they were made in (swatches, warnings).
struct Built {
out: Out,
layers: Vec<Arc<Node>>,
/// The page (`visible`, the file read as its page) and the place of its area in the layers'
/// document.
struct Frame<'a> {
page: &'a Document,
/// The page's area in `page`.
page_rect: Rect,
/// The same area in the layers' document.
rect: Rect,
}
/// The layers of the editing copy `data`, with the text (and strokes round it) of `visible`, the
/// file read as its page, put on them. `base` is the document they go in.
fn build(data: &[u8], visible: &Document, base: Document, page: Page) -> Result<Built, String> {
let parsed = parse(data).ok_or("its editing data is damaged")?;
if parsed.layers.is_empty() {
return Err("its editing data has no layers".into());
}
let [llx, lly, urx, ury] = match page {
Page::Art => parsed.bbox,
Page::Artboard => parsed.artboard.ok_or("its editing data doesn't say where its artboard is")?,
/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the
/// file read as its page, put in its text objects → the document, its notes and where the page is
/// in it.
fn build(data: &[u8], visible: &Document, page: Page) -> Result<(Document, Vec<String>, Rect), String> {
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data)?;
let art_box = match page {
Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?,
Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?,
};
let artboard = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?;
if matches!(page, Page::Artboard) && visible.artboards.len() != 1 {
return Err("it has more than one artboard".into());
}
if ((urx - llx) - artboard.width()).abs() > 0.5 || ((ury - lly) - artboard.height()).abs() > 0.5 {
let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?;
if (art_box.width() - page_rect.width()).abs() > 0.5 || (art_box.height() - page_rect.height()).abs() > 0.5 {
return Err("its editing data is for a different page".into());
}
let (mut on, mut off) = (BTreeSet::new(), BTreeSet::new());
unread(&parsed.layers, true, &mut on, &mut off);
if !on.is_empty() {
return Err(format!("its layers use {}, which aren't read yet", few(on)));
}
// The page's area in the layers' document, and the page's type moved there.
let rect = to_doc.transform_rect_bbox(art_box);
let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0));
let (mut shown, mut hidden) = (vec![], vec![]);
for l in &visible.layers {
if let Some(c) = l.children() {
text_objects(c, l.visible, &mut shown, &mut hidden);
}
}
let mut doc = base;
doc.layers.clear();
let to_doc = Affine::translate((artboard.x0, artboard.y0)) * Affine::new([1.0, 0.0, 0.0, -1.0, -llx, ury]);
let mut b = Builder { out: Some(Out::new(doc, to_doc, artboard)) };
let mut layers: Vec<Arc<Node>> = parsed.layers.iter().filter_map(|l| b.layer(l, 0)).collect();
let mut out = b.out.ok_or("a layer couldn't be read")?;
for object in shown.iter_mut().chain(hidden.iter_mut()) {
for n in object.iter_mut() {
Arc::make_mut(n).transform(shift, false);
}
}
let mut infos = vec![];
collect_slots(&layers, true, &mut infos);
collect_slots(&doc.layers, true, &mut infos);
if infos.iter().all(|i| !i.shown) && !shown.is_empty() {
return Err("its page has text, and its layers have no text objects to put it in".into());
}
@@ -1073,21 +648,26 @@ fn build(data: &[u8], visible: &Document, base: Document, page: Page) -> Result<
if stories.iter().flatten().map(|s| s.len()).sum::<usize>() > MAX_TEXT {
return Err("it has too much text".into());
}
let template = parsed.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
let mut notes = vec![];
let template = template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
let mut notes = warnings;
let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes);
let mut empty = 0;
fill_slots(&mut out.doc, &mut layers, &mut content.into_iter(), &mut empty);
for n in notes {
out.warn(&n);
}
let mut layers = std::mem::take(&mut doc.layers);
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty);
doc.layers = layers;
if empty > 0 {
out.warn(&format!("{empty} {TEXT_LEFT_OUT}"));
notes.push(format!("{empty} {TEXT_LEFT_OUT}"));
}
if !off.is_empty() {
out.warn(&format!("{ART_LEFT_OUT} ({}), left out of them", few(off)));
if !hidden_unread.is_empty() {
notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread)));
}
Ok(Built { out, layers })
let frame = Frame { page: visible, page_rect, rect };
let unseen = prune_unseen(&frame, &mut doc);
if unseen > 0 {
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
}
notes.extend(compare(&frame, &doc)?);
Ok((doc, notes, rect))
}
/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's
@@ -1104,11 +684,12 @@ const UNSEEN_MARGIN: f64 = 5e-5;
fn unmark(nodes: &mut [Arc<Node>], shown: bool, found: &mut Vec<(NodeId, Option<Rect>)>) {
for n in nodes {
let shown = shown && n.visible;
if n.name.as_deref() == Some(MADE) {
if let Some(name) = n.name.as_deref().and_then(|m| m.strip_prefix(MADE)) {
if shown {
found.push((n.id, n.visual_bounds()));
}
Arc::make_mut(n).name = None;
let name = (!name.is_empty()).then(|| name.to_string());
Arc::make_mut(n).name = name;
} else if !matches!(n.kind, NodeKind::Compound { .. })
&& let Some(c) = Arc::make_mut(n).children_mut()
{
@@ -1136,14 +717,14 @@ fn take_out(nodes: &mut Vec<Arc<Node>>, id: NodeId, hide: bool) -> bool {
/// has no type to match them, and that the layers look no worse without, are not drawn by the app
/// that wrote the file (which paints every shown text object; it keeps the type of what was turned
/// to outlines): they are taken out. How many.
fn prune_unseen(page: &Document, doc: &mut Document) -> usize {
fn prune_unseen(frame: &Frame<'_>, doc: &mut Document) -> usize {
let mut found = vec![];
unmark(&mut doc.layers, true, &mut found);
let Some(rect) = page.artboards.first().map(|a| a.rect) else { return 0 };
let rect = frame.rect;
if found.is_empty() || found.len() > MAX_WEIGHED {
return 0;
}
let mut with = difference(page, doc, rect);
let mut with = difference(frame.page, frame.page_rect, doc, rect);
let mut removed = 0;
for (id, bounds) in found {
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
@@ -1153,7 +734,7 @@ fn prune_unseen(page: &Document, doc: &mut Document) -> usize {
if !take_out(&mut without.layers, id, true) {
continue;
}
let d = difference(page, &without, rect);
let d = difference(frame.page, frame.page_rect, &without, rect);
if d - with <= UNSEEN_MARGIN {
take_out(&mut doc.layers, id, false);
with = d;
@@ -1165,9 +746,8 @@ fn prune_unseen(page: &Document, doc: &mut Document) -> usize {
/// Do the layers' art and the page's own look alike? An error says they don't; `Ok` has the note
/// to give when they differ a little.
fn compare(page: &Document, layered: &Document) -> Result<Option<String>, String> {
let Some(rect) = page.artboards.first().map(|a| a.rect) else { return Ok(None) };
let diff = difference(page, layered, rect);
fn compare(frame: &Frame<'_>, layered: &Document) -> Result<Option<String>, String> {
let diff = difference(frame.page, frame.page_rect, layered, frame.rect);
if diff > MAX_DIFFERENCE {
return Err(format!("the art on them differs from the page's in {:.0}% of it", diff * 100.0));
}
@@ -1180,38 +760,20 @@ fn compare(page: &Document, layered: &Document) -> Result<Option<String>, String
}
/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the
/// hidden ones too), groups, compound paths and clipping groups, with the text and the strokes
/// around it of the page. When the file has no editing copy this reads, `visible` as it was; when
/// it has one that can't be used, `visible` with a warning that says why.
pub(super) fn layered(ps: &[u8], mut visible: Imported) -> Imported {
let Some(data) = decode(ps) else {
// A file that says it has the editing copy but has none that can be read is a loss too.
let has = |n: &[u8]| ps.windows(n.len()).any(|w| w == n);
if has(BEGIN) && (has(STREAM) || has(STREAM_ZLIB)) {
visible.warnings.push(format!("{LAYERS_UNREAD} (its editing data is damaged or too large): it comes in as its page, in one layer"));
}
return visible;
};
let Some(page) = visible.document.artboards.first().map(|a| a.rect) else { return visible };
let base = Document::new(page.width(), page.height());
let result = build(&data, &visible.document, base, Page::Art).and_then(|built| {
let mut done = finish_with(built.out, built.layers);
let unseen = prune_unseen(&visible.document, &mut done.document);
if unseen > 0 {
done.warnings.push(format!("{unseen} {UNSEEN_TEXT}"));
}
let note = compare(&visible.document, &done.document)?;
done.warnings.extend(note);
Ok(done)
});
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
/// has one that can't be used, `visible` with a warning that says why.
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
let Some(data) = ai::eps_data(ps) else { return visible };
let result = data.and_then(|data| build(&data, &visible.document, Page::Art));
match result {
Ok(mut done) => {
Ok((document, mut warnings, _)) => {
for w in &visible.warnings {
if !done.warnings.contains(w) {
done.warnings.push(w.clone());
if !warnings.contains(w) {
warnings.push(w.clone());
}
}
done
Imported { document, warnings, preview: false }
}
Err(why) => {
let mut v = visible;
@@ -1221,25 +783,17 @@ pub(super) fn layered(ps: &[u8], mut visible: Imported) -> Imported {
}
}
/// The PDF-compatible `.ai` `visible` (read as its PDF part) read through its editing copy
/// (`private`, the joined `AIPrivateData` streams) instead, as [`layered`] reads an EPS: with the
/// layers and objects of the file, and the art that lies outside its artboard, which its PDF part
/// doesn't have. `warnings` are the notes of reading the PDF part; the notes returned are those,
/// and the layers'.
/// The `.ai` `visible` (read as its PDF part) read through its editing copy (`private`, the joined
/// `AIPrivateData` streams) instead, as [`layered`] reads an EPS: with the layers and objects of the
/// file, its artboards, and the art that lies outside them, which its PDF part doesn't have.
/// `warnings` are the notes of reading the PDF part; the notes returned are those, and the layers'.
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
let Some(data) = decode_ai(private) else { return (visible, warnings) };
let result = build(&data, &visible, visible.clone(), Page::Artboard).and_then(|built| {
let mut notes = built.out.warnings.clone();
let mut done = finish_layers(built.out, built.layers);
let unseen = prune_unseen(&visible, &mut done);
if unseen > 0 {
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
}
notes.extend(compare(&visible, &done)?);
Ok((done, notes))
});
match result {
Ok((done, notes)) => {
let Ok(data) = ai::decode_private(private) else { return (visible, warnings) };
if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") {
return (visible, warnings);
}
match build(&data, &visible, Page::Artboard) {
Ok((done, notes, _)) => {
let mut all = warnings;
all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::<Vec<_>>());
(done, all)
@@ -1252,12 +806,6 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (
}
}
fn finish_layers(out: Out, layers: Vec<Arc<Node>>) -> Document {
let mut doc = out.doc;
doc.layers = layers;
doc
}
#[cfg(test)]
mod tests {
use super::*;
+18 -18
View File
@@ -95,7 +95,8 @@ fn layers_come_in_with_their_names_colours_and_hidden_state() {
assert_eq!([d.layers[0].visible, d.layers[1].visible], [true, false]);
// What is on a hidden layer stays: the page doesn't have it.
assert_eq!(kinds(&d.layers[1]), ["path"]);
assert!(matches!(&d.layers[0].kind, NodeKind::Layer { color: LayerColor::Custom([79, 128, 255]), .. }));
// Colour 0 of the layer colours (light blue), by its place in the list.
assert!(matches!(&d.layers[0].kind, NodeKind::Layer { color: LayerColor::Preset(0), .. }));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
// The page is where the page's art was.
let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap();
@@ -143,13 +144,13 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
#[test]
fn what_the_layers_cant_say_keeps_the_page_when_it_shows_and_not_when_it_doesnt() {
// A gradient (`Bg`) on a layer that shows: the layers would draw it as a flat colour.
let shown = native(&layer("Art", true, &format!("{}\n0 0 0 1 0 0 Bg", square(10, 10))));
// An operator that isn't read (`Zq`) on a layer that shows: the layers would draw without it.
let shown = native(&layer("Art", true, &format!("{}\n0 0 0 1 0 0 Zq", square(10, 10))));
let r = import(&eps(&page_square(10, 10), &shown)).unwrap();
assert_eq!(names(&r.document), ["Layer 1"]);
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("`Bg`")), "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("`Zq`")), "{:?}", r.warnings);
// On a hidden layer it only costs that layer's look.
let hidden = native(&(layer("Art", true, &square(10, 10)) + &layer("Old", false, "0 0 0 1 0 0 Bg")));
let hidden = native(&(layer("Art", true, &square(10, 10)) + &layer("Old", false, "0 0 0 1 0 0 Zq")));
let r = import(&eps(&page_square(10, 10), &hidden)).unwrap();
assert_eq!(names(&r.document), ["Art", "Old"]);
assert!(r.warnings.iter().any(|w| w.contains("hidden layers have art this can't read")), "{:?}", r.warnings);
@@ -166,9 +167,8 @@ fn text_and_its_strokes_come_from_the_page_into_the_layer_of_its_text_object() {
let r = import(&eps(&page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
assert_eq!(kinds(&r.document.layers[0]), ["path"]);
// The text and the path of its stroke, together where the text object is.
let group = &r.document.layers[1].children().unwrap()[0];
assert_eq!(kinds(group), ["text", "path"], "{:?}", r.warnings);
// The text and the path of its stroke, where the text object is (no group of their own).
assert_eq!(kinds(&r.document.layers[1]), ["text", "path"], "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
@@ -238,14 +238,15 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
}
#[test]
fn a_gray_is_the_black_ink_as_in_a_cmyk_document() {
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says.
fn a_gray_is_a_grayscale_colour() {
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour
// stays in its grayscale model.
let art = native(&layer("Art", true, "0.25 g\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"));
let page = "0 0 0 0.75 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let fill = r.document.layers[0].children().unwrap()[0].appearance.fill().and_then(|f| f.paint.color());
assert_eq!(fill, Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 0.75)));
assert_eq!(fill, Some(vectorcraft_color::Color::gray(0.75)));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
@@ -378,7 +379,7 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let texts = text_of(&r.document.layers[0]);
// The page's own object is a group (its type and strokes), and the other is made from the file.
// The page's own object, and the other made from the file.
let all: Vec<String> = {
let mut v = vec![];
r.document.walk(|n| {
@@ -390,7 +391,7 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document
};
assert_eq!(all.len(), 2, "{all:?} {:?}", r.warnings);
assert!(all.contains(&"Hi there".to_string()) && all.contains(&"Away".to_string()), "{all:?}");
assert_eq!(texts.len(), 1, "the type of the page sits in a group of its own");
assert_eq!(texts.len(), 2, "the page's type is in the layer as it is, not in a group");
assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings);
}
@@ -422,13 +423,12 @@ fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() {
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show";
let r = import(&eps(page, &art)).unwrap();
// One text, with the line's space between the pieces.
let group = &r.document.layers[0].children().unwrap()[0];
assert_eq!(kinds(group), ["text"], "{:?}", r.warnings);
let texts: Vec<String> =
group.children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
let art = &r.document.layers[0];
assert_eq!(kinds(art), ["text"], "{:?}", r.warnings);
let texts: Vec<String> = text_of(art).iter().map(|t| t.plain_text()).collect();
assert_eq!(texts, ["Hi there"]);
// Its bounds are laid out afresh for the whole text, not those of the first piece.
let NodeKind::Text(t) = &group.children().unwrap()[0].kind else { panic!("text") };
let NodeKind::Text(t) = &art.children().unwrap()[0].kind else { panic!("text") };
assert!(t.cached_bounds.is_none() && t.cached_baselines.is_empty());
}
+1 -1
View File
@@ -700,7 +700,7 @@ impl Player {
return self.warn(&deep_clips());
}
self.next_clip += 1;
self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)), hidden: false }));
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).
+142
View File
@@ -0,0 +1,142 @@
//! Hand-written Illustrator editing data (the format `vectorcraft_eps`'s reader documents) and the
//! EPS and `.ai` files that carry it, for import tests. Nothing here comes from files another app
//! wrote.
/// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop
/// marks are the page), `body` (setup and layers), the trailer.
pub fn editing_data(width: f64, height: f64, body: &str) -> String {
format!(
"{PS_HEADER}\n%%Creator: VectorCraft tests\n%%BoundingBox: 0 0 {width} {height}\n%%HiResBoundingBox: 0 0 {width} {height}\n\
%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 {width} {height}\n%%EndComments\n\
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"
)
}
/// The first line of a PostScript file (the DSC version comment).
const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment
/// The `PieceInfo` key the private data streams are under.
const PIECE: &str = "Illustrator"; // brand-ok: the key the files use
/// A layer named `name` (shown, unlocked, printing, light blue) holding `art`.
pub fn layer(name: &str, art: &str) -> String {
format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}\nLB\n%AI5_EndLayer--\n")
}
/// The path of the rectangle `x0 y0 x1 y1` (art space: y up).
pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
format!("{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\n")
}
/// `name` given to the object before it (its XML id).
pub fn named(name: &str) -> String {
format!("%_/ArtDictionary :\n%_/XMLUID : ({}) ; (AI10_ArtUID) ,\n%_;\n%_\n", name.replace(' ', "_"))
}
/// A document of 200 × 100 pt with a bit of everything the reader reads. A layer `Art` that shows:
/// a group `Pair` (a red square `Red` and a hidden one), a compound path `Ring`, a clipping group
/// `Window` (a grey square seen through a smaller one), what [`page_ps`] and [`page_pdf`] draw. A
/// hidden layer `Hidden`: a radially shaded square `Shaded`, a screened square at half opacity, a
/// locked sublayer `Sub`, and a 2 × 1 image with an alpha channel.
pub fn sample_data() -> Vec<u8> {
let red = "0 1 1 0 1 0 0 Xa\n";
let gradient =
"%AI5_BeginGradient: (Glow)\n(Glow) 1 2 Bd\n[\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n%_0 0 0 1 0 0 0 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let group = format!(
"0 Ae\nu\n{red}{}f\n{}1 Xw\n0 0 1 0 0 0 1 XA\n2 w\n{}b\n0 Xw\nU\n{}9 () XW\n",
rect(10.0, 50.0, 40.0, 90.0),
named("Red"),
rect(50.0, 50.0, 80.0, 90.0),
named("Pair")
);
let compound = format!("0 Ae\n*u\n{red}1 XR\n{}f\n{}f\n*U\n{}", rect(100.0, 50.0, 140.0, 90.0), rect(110.0, 60.0, 130.0, 80.0), named("Ring"));
let clip =
format!("0 Ae\nq\n0.5 g\n{}f\n{}h\nW\nn\nQ\n{}9 () XW\n", rect(150.0, 50.0, 190.0, 90.0), rect(160.0, 60.0, 180.0, 80.0), named("Window"));
let shaded = format!(
"{}Bb\n1 (Glow) 0 0 0 1 1 0 0 1 0 0 1 Bg\n20 0 0 -20 30 25 Bm\nf\n0 BB\n{}2 0.5 0 0 0 Xy\n{red}{}f\n0 1 0 0 0 Xy\n",
rect(10.0, 5.0, 50.0, 45.0),
named("Shaded"),
rect(60.0, 5.0, 90.0, 45.0)
);
let sub = "%AI5_BeginLayer\n1 1 0 1 0 0 1 2 79 255 79 0 50 0 Lb\n(Sub) Ln\n0 g\n10 10 m\n30 30 L\nS\nLB\n%AI5_EndLayer--\n";
let mut image = b"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n\
[ 20 0 0 20 100 40 ] 2 1 0 Xh\n[ 20 0 0 20 100 40 ] 0 0 2 1 2 1 8 3 1 17 1 0 4 4 0 0\n%%BeginData: 10\rXI\n"
.to_vec();
image.extend_from_slice(&[255, 0, 0, 0, 0, 255, 255, 128]);
image.extend_from_slice(b"%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n");
let head = format!(
"{gradient}{}%AI5_BeginLayer\n0 1 1 1 0 0 0 1 255 79 79 0 50 0 Lb\n(Hidden) Ln\n{shaded}{sub}",
layer("Art", &format!("{group}{compound}{clip}"))
);
let mut body = head.into_bytes();
body.extend_from_slice(&image);
body.extend_from_slice(b"LB\n%AI5_EndLayer--\n");
let mut data = editing_data(200.0, 100.0, "").into_bytes();
let at = data.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap_or(data.len());
data.splice(at..at, body);
data
}
/// The sample's page in PostScript (what shows of it).
pub fn page_ps() -> &'static str {
"1 0 0 setrgbcolor 10 50 30 40 rectfill\n\
newpath 100 50 moveto 140 50 lineto 140 90 lineto 100 90 lineto closepath 110 60 moveto 130 60 lineto 130 80 lineto 110 80 lineto closepath eofill\n\
0.5 setgray 160 60 20 20 rectfill\n"
}
/// The sample's page as PDF content (what shows of it).
pub fn page_pdf() -> &'static str {
"1 0 0 rg 10 50 30 40 re f 100 50 40 40 re 110 60 20 20 re f* 0.5 g 160 60 20 20 re f"
}
/// An Illustrator EPS: its page drawing `page` (PostScript, 200 × 100 pt), and the editing data
/// `data` after it, not compressed.
pub fn eps(data: &[u8], page: &str) -> Vec<u8> {
let mut out = format!(
"{PS_HEADER} EPSF-3.0\n%%Creator: VectorCraft tests\n%%BoundingBox: 0 0 200 100\n%%HiResBoundingBox: 0 0 200 100\n%%EndComments\n{page}showpage\n%%EOF\n%AI9_PrivateDataBegin\n"
)
.into_bytes();
out.extend_from_slice(data);
out.extend_from_slice(b"\n%AI9_PrivateDataEnd\n");
out
}
/// An Illustrator `.ai` file: a 200 × 100 pt PDF page drawing `page` (PDF content), carrying
/// `private` (the private data streams' contents, such as `%AI12_CompressedData` and zlib data) in
/// two streams.
pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
let (first, second) = private.split_at(private.len() / 2);
let stream = |part: &[u8]| {
let mut s = format!("<< /Length {} >>\nstream\n", part.len()).into_bytes();
s.extend_from_slice(part);
s.extend_from_slice(b"\nendstream");
s
};
let objects: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R \
/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> \
/PieceInfo << /{PIECE} << /Private << /AIPrivateData1 5 0 R /AIPrivateData2 6 0 R /NumBlock 2 >> >> >> >>"
)
.into_bytes(),
stream(page.as_bytes()),
stream(first),
stream(second),
];
let mut out = b"%PDF-1.5\n".to_vec();
let mut offsets = vec![];
for (i, o) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend(format!("{} 0 obj\n", i + 1).bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
for off in offsets {
out.extend(format!("{off:010} 00000 n \n").bytes());
}
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
out
}
+2
View File
@@ -10,6 +10,7 @@
//! - [`raster`]: rendering helpers and image comparison with a perceptual tolerance.
//! - [`geom`]: geometry assertions (approximate equality, curve sampling, Hausdorff distance).
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
//! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it.
//! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests.
//!
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
@@ -19,6 +20,7 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#![forbid(unsafe_code)]
pub mod ai;
pub mod ase;
pub mod fixtures;
pub mod geom;
+16
View File
@@ -3660,6 +3660,22 @@ axis Both Ambos
@msg x and y must both be numbers x e y deben ser números
@msg "{0}" is not a colour: write it as #rrggbb "{0}" no es un color: escríbelo como #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color
@msg `{what}` closes nothing `{what}` no cierra nada
@msg `{what}` closes something it didn't open `{what}` cierra algo que no abrió
@msg a path has too many points un trazado tiene demasiados puntos
@msg it ends inside a layer terminan dentro de una capa
@msg it has art outside its layers tienen ilustraciones fuera de sus capas
@msg it has more than {MAX_NODES} objects tienen más de {MAX_NODES} objetos
@msg it has no layers no tienen capas
@msg it has too many values in a row tienen demasiados valores seguidos
@msg it has {_1}, which VectorCraft doesn't read from it yet tienen {_1}, que VectorCraft aún no lee de ellos
@msg its objects are nested too deeply sus objetos están anidados a demasiada profundidad
@msg its section ending with `%{end}` doesn't end su sección que termina con `%{end}` no termina
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet los objetos con varios rellenos o trazos, efectos o pinceles se importaron con su aspecto dibujado ({_1}): su aspecto aún no se puede editar
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it el degradado “{name}” no está definido en el archivo: lo sustituye un color sólido
@msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer
@msg an image whose data is cut short was left out se omitió una imagen con datos incompletos
@msg its editing data doesn't say where its art is sus datos de edición no dicen dónde está su ilustración
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Servicios
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -3657,6 +3657,22 @@ axis Both Les deux
@msg x and y must both be numbers x et y doivent tous deux être des nombres
@msg "{0}" is not a colour: write it as #rrggbb « {0} » n’est pas une couleur : écrivez-la sous la forme #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur
@msg `{what}` closes nothing `{what}` ne ferme rien
@msg `{what}` closes something it didn't open `{what}` ferme quelque chose qu’il n’a pas ouvert
@msg a path has too many points un tracé a trop de points
@msg it ends inside a layer elles se terminent à l’intérieur d’un calque
@msg it has art outside its layers elles contiennent des illustrations hors de leurs calques
@msg it has more than {MAX_NODES} objects elles contiennent plus de {MAX_NODES} objets
@msg it has no layers elles n’ont aucun calque
@msg it has too many values in a row elles contiennent trop de valeurs à la suite
@msg it has {_1}, which VectorCraft doesn't read from it yet elles contiennent {_1}, que VectorCraft n’y lit pas encore
@msg its objects are nested too deeply leurs objets sont imbriqués trop profondément
@msg its section ending with `%{end}` doesn't end leur section qui se termine par `%{end}` ne se termine pas
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet les objets à plusieurs fonds ou contours, effets ou formes ont été importés tels qu’ils sont dessinés ({_1}) : leur aspect n’est pas encore modifiable
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it le dégradé « {name} » n’est pas défini dans le fichier : une couleur unie le remplace
@msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée
@msg an image whose data is cut short was left out une image aux données tronquées a été ignorée
@msg its editing data doesn't say where its art is ses données d’édition ne disent pas où se trouve son illustration
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Services
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -3660,6 +3660,22 @@ axis Both Entrambi
@msg x and y must both be numbers x e y devono essere entrambi numeri
@msg "{0}" is not a colour: write it as #rrggbb "{0}" non è un colore: scrivilo come #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore
@msg `{what}` closes nothing `{what}` non chiude nulla
@msg `{what}` closes something it didn't open `{what}` chiude qualcosa che non ha aperto
@msg a path has too many points un tracciato ha troppi punti
@msg it ends inside a layer terminano all'interno di un livello
@msg it has art outside its layers contengono grafica al di fuori dei loro livelli
@msg it has more than {MAX_NODES} objects contengono più di {MAX_NODES} oggetti
@msg it has no layers non hanno livelli
@msg it has too many values in a row contengono troppi valori di seguito
@msg it has {_1}, which VectorCraft doesn't read from it yet contengono {_1}, che VectorCraft non vi legge ancora
@msg its objects are nested too deeply i loro oggetti sono annidati troppo in profondità
@msg its section ending with `%{end}` doesn't end la loro sezione che termina con `%{end}` non termina
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet gli oggetti con più riempimenti o tratti, effetti o pennelli sono stati importati come appaiono disegnati ({_1}): il loro aspetto non è ancora modificabile
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it la sfumatura “{name}” non è definita nel file: la sostituisce un colore pieno
@msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa
@msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa
@msg its editing data doesn't say where its art is i suoi dati di modifica non dicono dove si trova la grafica
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Servizi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -3656,6 +3656,22 @@ axis Both Оба
@msg x and y must both be numbers x и y должны быть числами
@msg "{0}" is not a colour: write it as #rrggbb «{0}» не является цветом: запишите его как #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет»
@msg `{what}` closes nothing `{what}` ничего не закрывает
@msg `{what}` closes something it didn't open `{what}` закрывает то, что не открывал
@msg a path has too many points в контуре слишком много точек
@msg it ends inside a layer они заканчиваются внутри слоя
@msg it has art outside its layers в них есть графика вне слоёв
@msg it has more than {MAX_NODES} objects в них больше {MAX_NODES} объектов
@msg it has no layers в них нет слоёв
@msg it has too many values in a row в них слишком много значений подряд
@msg it has {_1}, which VectorCraft doesn't read from it yet в них есть {_1}, которые VectorCraft пока не читает
@msg its objects are nested too deeply объекты в них вложены слишком глубоко
@msg its section ending with `%{end}` doesn't end раздел, заканчивающийся на `%{end}`, не заканчивается
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet объекты с несколькими заливками или обводками, эффектами или кистями импортированы в нарисованном виде ({_1}): их оформление пока нельзя редактировать
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиент «{name}» не определён в файле: вместо него используется сплошной цвет
@msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено
@msg an image whose data is cut short was left out изображение с обрезанными данными пропущено
@msg its editing data doesn't say where its art is в его данных редактирования не указано, где находится графика
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Службы
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12 -3
View File
@@ -407,6 +407,17 @@ and `flattener` options over it (as `object.flattenTransparency` takes them); im
transparency and rasterized areas are clipped to their regions. A file that would still have transparency fails the
export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too.
An Illustrator EPS (version 9 on) or `.ai` file opens from the editing data it carries (after the EPS page; in the
`.ai` file's PDF private data): layers and sublayers (name, order, visibility, lock, printing, preview, dimming,
colour), groups as they were nested, compound paths, clipping groups, object names, hidden and locked objects, fills and
strokes (spot colours as spot swatches), linear and radial gradients, opacity, blend modes, isolation and knockout,
embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or
strokes, effects or a brush comes in as its drawn look (a group named after it). Type that shows is the page's, in its
text object's place; hidden point type is made from the file's text document. A file whose editing data has symbols,
pattern fills, placed files or anything else the reader doesn't read on a layer that shows, or whose layers look
different from its page, opens as before: an EPS as its printed page, a `.ai` file from its PDF content (where plain
groups open ungrouped), with a warning saying why.
Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based),
`cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets)
and `password` for an encrypted file. `document.pdfInfo` reads a file without opening it: the page count, each page's
@@ -2128,9 +2139,7 @@ runs their data; `flushfile` skips it), axial and radial shadings and shading pa
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). 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`
`U`) come in as groups, nested as they were; clips and groups nest at most 128 deep. 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