diff --git a/ROADMAP.md b/ROADMAP.md
index 1dcecc1b..5f70d2d0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -6,7 +6,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
+| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undo step (#1331). EPS import no longer hangs on shading functions fanned out through shared arrays (#1330), looped huge arcs, fine dashes and rectangle arrays (#1329), or very long dash patterns (#1332). Plug-in whole-number fields keep values past 2^53 exactly as typed (#1327). Large documents use far less memory: decoded images are shared and capped, undo history is bounded by memory, native files open in the background and save as a stream, and Data Recovery writes images once (#1245). Opacity mask commands (#1336), Simplify and Add Anchor Points (#1337) add no undo step when they change nothing. EPS files with thousands of tiling patterns, spot inks or clipped shadings open several times faster with the same result (#1341). A `.ai` or PDF saved from a CMYK document with transparency no longer opens blank in applications that read soft masks by luminosity (#1359, part of #1286). Gradient-filled shapes in a PDF or `.ai` open as one path with the gradient instead of a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on their outlines (#1351). A `.ai` saved with a bleed opens on its artboard with the bleed round it as the document's bleed, from its layers, and Crop To › Trim does the same for a PDF (#1349); a PDF saved with its editing data opens from it, with its layers, groups and art past the page, as a `.ai` does (#1345). Art moved into a hidden or locked layer (row drag, Merge Selected, Flatten Artwork, Collect in New Layer) or hidden by a data set is deselected (#1355), and hiding or locking a layer with a large selection deselects its objects in linear time (200,000 objects: 3.9 s to 24 ms) (#1353). Objects moved whole together with direct-selected anchors follow Preferences › Transform Pattern Tiles (#1354). Preferences › Appearance of Black › On Screen works: Display All Blacks as Rich Black shows 100K black as RGB black on the canvas (#1350). DXF: a closed control-point SPLINE opens as a closed path (#1318). Numeric fields: dragging over part of a value in an unfocused field selects that part instead of the whole value (#1339). Pathfinder Divide: an open line or curve across a filled shape cuts it into separate pieces (#1335); Unite, Minus Front, Minus Back, Intersect and Exclude results keep the source object's name, so exported SVG keeps its id (#1308). Object › Align follows the Align To choice (one object aligns to the artboard), and the choice is kept across restarts (#1347). Artboard tool: a click on an object creates an artboard fitted to it (#1252). Rulers: drag the ruler corner to move the zero point, double-click it to reset; the X/Y fields measure from it (#1250). Live Corners: with Direct Selection, a click on a corner widget selects that corner, so the next drag rounds it alone (#1239). A click with the Polar Grid tool opens a dialog for its size and divider counts, as the Rectangular Grid tool does (#1192). A `.ai` whose editing data holds an image on hidden lines is no longer misread (#1364, part of #1264). PDF export leaves out art placed beyond 1,000,000,000 pt instead of aborting debug and test builds (#1360). Affinity import: rectangles keep per-corner radii and straight (cut) corners (#1369, part of #666). Tools panel: the tool buttons are centred inside the panel border, in one and two columns (#1363). Spanish: 371 more interface strings translated (presets, shortcut editor, print and PDF options, Save for Web and more) (#1371). The Properties panel no longer widens and cuts off its labels when an Image Trace object is selected (#1374, part of #1358). |
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). The Selection tool's arrow is black and Direct Selection's white in every theme, as users know them: in the dark themes they looked swapped. The font menu keeps its highlight on the same font when the list changes under it (a family's styles listed above it, fonts that finish loading, a favourite unstarred), and ends the live preview when that font leaves the list (#1116). The Windows installer has a Shortcuts page with a ticked "Create a desktop shortcut" box, and a silent install adds the shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed (#1128). Image Trace's Noise merges specks until none under its size is left beside another colour, so a higher Noise never gives more paths (#1130). `cargo clippy -D warnings` passes for the wasm32 web build (#1132). Gradient stops with opacity blend colour and opacity apart on the canvas, as Illustrator and the PDF and SVG exports do: a transparent stop's colour still tints the fade (#1142). An SVG mask whose art is all hidden imports as an empty opacity mask, so its object stays hidden as browsers draw it (#1143). Shift-click with the Artboard tool, or in the Artboards panel, selects several artboards, which then move, duplicate (Alt-drag) and delete together (#1139). A shape far beyond any page (from a malformed file) is left undrawn instead of making the renderer abort the app. Live Corners round corners where curved segments meet too, trimming each curve to what the cut leaves of it (#1144). Effect › Distort & Transform › Zig Zag's dialog has sliders for Size and Ridges per segment, and Relative / Absolute and Smooth / Corner as radio buttons (#1133). Object › Path › Offset Path opens on the offset, joins and miter limit it was last applied with (#1152). A marquee selects only the objects whose paths it touches: one drawn over a background, in Preview or Outline, no longer selects the background too (#1103). On Linux, Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where files dropped from a file manager arrive (#1151). A selected gradient mesh shows its mesh lines and points with the Selection and Direct Selection tools too, not only with the Mesh tool (#1111). A blend's spine bends: the Anchor Point tool drags any of its segments into a curve, straight ones too, and Direct Selection reshapes its curved segments (`object.blend.spine.reshapeSegment`, #1111). Alt-click on a hidden layer's eye shows that layer as it hides the others, and on a locked layer's lock unlocks it as it locks the others: the layer clicked is the one left in view (#1158). Object › Compound Path › Release splits a path of several subpaths (an SVG `d` with several `M`s, a path drawn in one go over MCP) into one path per subpath, with its paint and stacking order, as it does a compound path (#1163). MCP `render_preview` takes an `artboard` index, so an agent can look at every page of a multi-artboard document (#1163). The MCP drawing tools say that omitted paint follows the new-art appearance (a white fill and 1 pt black stroke at first), and `export` says which formats take `outlineText` (#1148). The desktop app refuses a malformed `--automation-read-root` / `--automation-write-root` (no folder, an empty one, another option or the flag twice) before it starts, instead of dropping the root its environment variable set and leaving the control channel unconfined (#1157). PDF export warns, once per object, when the 64 million pixel cap renders its raster effects below the document's raster effects resolution, with the requested and the achieved ppi (#1147). MCP `command_batch` with `atomic: true` makes the whole batch one undo step, named by `label` (`edit.undoGroup.begin`/`end`, #1163). `file.list {path}` lists a folder's files and folders for agents, held to the automation read root (#1163). Thai typed in a font without Thai glyphs is set in an installed font made for Thai (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai…) instead of whichever face with Thai glyphs loaded first, and Lao, Khmer and Myanmar likewise. With the Type tool, the Left and Right arrows and Delete step over whole clusters (a Thai consonant with its vowel and tone marks, a letter with a combining accent, an emoji sequence), so the caret never lands inside one; Backspace still takes back one character at a time, a mistyped tone mark say. Thai, Lao, Khmer and Myanmar area type breaks its lines between words, which those scripts don't separate with spaces, instead of running on to the next space; the words come from ICU4X's line segmenter (its dictionaries on the desktop, its smaller LSTM models on the web). The font menu's script filter has Thai beside Japanese and Latin: the families whose character map has Thai. With craft-fonts' Noto Sans Thai, Thai is set in it where no Thai font is installed, and the web build fetches it before the app starts, so Thai no longer shows as empty boxes there. With the Type tool, Ctrl/Alt+arrows and double-click take Thai, Lao, Khmer and Myanmar text word by word (ภาษา, ไทย…) instead of a whole run without spaces, and a word's vowel and tone marks (and combining accents) stay part of it. Releases embed the current craft-fonts (Noto Sans Thai, Noto Sans Arabic and Noto Sans CJK SC beside the Japanese fonts), as the other craft apps' releases do. Object › Compound Path › Make cuts holes where the paths in front overlap the backmost one, also when they were drawn running the same way (#1197). On Windows, Direct3D 12 shaders compile with FXC, part of Windows, so a dxcompiler.dll another program left on the search path without its dxil.dll no longer loses the graphics device at start-up and drops the app to OpenGL (#1194). With anchor points selected by Direct Selection, the Align buttons align those points (to their own bounds or the artboard) instead of moving the whole path (#1180). With Smart Guides, an object grabbed by an anchor and dropped near another object's anchor lands exactly on it, instead of its bounds lining up a fraction of a point beside it (#955). .ai import: a copied area type or type-on-a-path object now sits where its own art puts it, not on top of the original (#1208). EPS/.ai import: a font is found by its exact PostScript name before the name is split, so families with hyphens keep their name (#1207). EPS/.ai import: type from the text document keeps its line spacing across paragraphs instead of applying the paragraph spacing twice (#1206). Brazilian Portuguese translates the remaining UI labels and engine messages (#1205). Czech joins the complete languages: panels, dialogs, plurals and status and error messages (#1189). Type: Digits (Character panel menu, with the Middle Eastern options) draws every digit in the Default, Arabic, Hindi or Farsi numeral system (#1201). Linux: under Wayland, Automatic draws on the GPU the compositor says it draws with, so a hybrid laptop whose compositor runs on the discrete GPU no longer opens an empty window (#1200). Large documents: editing a big selection takes time linear in the document (a 25,000-path nudge 3.5 s → 48 ms), live geometry effects are kept across frames, Pathfinder Trim, Merge, Crop and Outline are 9–20× faster with identical output, and undo history holds 41% less (#1173). |
diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs
index a5d396fa..85da8fa5 100644
--- a/crates/engine/src/cmd/links.rs
+++ b/crates/engine/src/cmd/links.rs
@@ -529,15 +529,19 @@ impl Resolved {
/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read
/// again (their images had the preview), modified ones are read again when `update`, links found
-/// away from their path take the new path. Nothing here is an undo step.
+/// away from their path take the new path. A link nothing is known of whose image shows a preview
+/// (a file another app wrote kept only that) was never read: its file is read now, into the
+/// image's bounds, which isn't a change to report. Nothing here is an undo step.
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved {
let mut out = Resolved::default();
for g in groups(d, None) {
let row = rows(std::iter::once(&g));
+ let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
match probe(&g, folder(doc_path)) {
(Status::Missing, _) => out.missing.extend(row),
- (Status::Modified, Some(mut f)) if update => {
- match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, false)) {
+ (Status::Modified, Some(mut f)) if update || unread => {
+ match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread)) {
+ Ok(()) if unread => {}
Ok(()) => out.updated.extend(row),
Err(_) => out.missing.extend(row),
}
diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs
index a1ca2592..664ac732 100644
--- a/crates/engine/src/tests_links.rs
+++ b/crates/engine/src/tests_links.rs
@@ -359,3 +359,40 @@ fn a_placed_document_brings_its_links_with_the_files_pixels() {
assert_eq!(im.link.as_ref().map(|l| l.path.as_str()), Some(pic.as_str()), "still linked");
assert!(!doc.images[&im.key].is_proxy(), "the file's pixels, not the preview");
}
+
+/// The placed (linked) files of a `.ai` file come in with the preview the file keeps of them: one
+/// not found shows it, one found is read at once into the same bounds, which isn't a change.
+#[test]
+fn links_of_an_imported_file_are_read_where_they_are_found() {
+ use vectorcraft_testkit::ai;
+ let dir = Folder::new("imported");
+ let pic = dir.file("photo.png").replace('\\', "/");
+ // A 2×1 preview (premultiplied ARGB: a blue at half opacity) of a file of 600 × 300 px placed at
+ // 60 × 30 pt, which the PDF part draws there too.
+ let place = format!(
+ "%AI5_BeginPlace\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n({pic})`\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 \x7f\x10\x10\x60\x7f\x10\x10\x60\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n"
+ );
+ let doc_path = dir.file("poster.ai");
+ write(&doc_path, &ai::ai(ai::editing_data(200.0, 100.0, &ai::layer("Art", &place)).as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f"));
+ let placed = |s: &Session| {
+ let mut found = vec![];
+ s.doc().unwrap().doc.visit_images(|n, im| found.push((n.geometric_bounds().unwrap(), im.clone())));
+ let [one] = &found[..] else { panic!("one image: {found:?}") };
+ one.clone()
+ };
+ let in_place = |b: Rect| [b.x0 - 20.0, b.y0 - 20.0, b.x1 - 80.0, b.y1 - 50.0].iter().all(|d| d.abs() < 1e-6);
+ let mut s = session();
+
+ let r = open(&mut s, &doc_path);
+ assert_eq!((r["missingLinks"][0]["name"].as_str(), r["missingLinks"][0]["path"].as_str()), (Some("photo.png"), Some(pic.as_str())), "{r}");
+ assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("low-resolution preview")), "{r}");
+ let (b, im) = placed(&s);
+ assert!(in_place(b) && im.width == 2 && s.doc().unwrap().doc.images[&im.key].is_proxy(), "{b:?} {im:?}");
+
+ write(&pic, &png(600, 300, RED));
+ let r = open(&mut s, &doc_path);
+ assert_eq!((&r["missingLinks"], &r["modifiedLinks"], &r["updatedLinks"]), (&json!([]), &json!([]), &json!([])), "{r}");
+ let (b, im) = placed(&s);
+ assert!(in_place(b) && (im.width, im.height) == (600, 300) && im.link.as_ref().is_some_and(|l| l.hash.is_some()), "{b:?} {im:?}");
+ assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy());
+}
diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs
index ea0fa08d..d36fce90 100644
--- a/crates/engine/tests/import_fuzz.rs
+++ b/crates/engine/tests/import_fuzz.rs
@@ -2060,6 +2060,14 @@ fn arb_ai_token() -> impl Strategy {
"\n%AI9_ColorModel: 2\n",
"\n%AI5_BeginPlace\n",
"\n%AI26_BeginPlacedObjectPreview\n",
+ "\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 abcdefgh",
+ "\n%AI26_BeginPlacedObjectPreview\n4096 4096 1e9 32 17 ",
+ "\n%AI26_EndPlacedObjectPreview\n",
+ "[1 0 0 -1 0 0] 0 0 2 1 4 4 0 (a.png) `",
+ "[1e308 0 0 1e308 0 0] 0 0 -1e308 1e308 4 4 0 (a.png) `",
+ "`",
+ "~",
+ "\n%AI5_EndPlace\n",
"\n%%PageTrailer\n",
])
.prop_map(str::to_string),
diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs
index 033e9561..fe89ed8c 100644
--- a/crates/eps/src/import/ai/lex.rs
+++ b/crates/eps/src/import/ai/lex.rs
@@ -4,8 +4,8 @@
//! 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.
+//! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary preview of a placed
+//! file is one [`Tok::Preview`].
use super::super::lex::{find, hex_decode};
@@ -25,6 +25,8 @@ pub(super) enum Tok<'a> {
Comment(&'a [u8]),
/// An image's samples: what follows `XI` in a `%%BeginData:` section.
Data(&'a [u8]),
+ /// A placed file's preview: what is between `%AI26_BeginPlacedObjectPreview` and its end.
+ Preview(&'a [u8]),
}
#[derive(Clone, Debug, PartialEq)]
@@ -34,8 +36,10 @@ pub(super) struct Token<'a> {
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")];
+/// A placed file's preview (binary): (begin, end) markers.
+const PREVIEW: (&[u8], &[u8]) = (b"%AI26_BeginPlacedObjectPreview", b"%AI26_EndPlacedObjectPreview");
+/// A binary section skipped whole: (begin, end) markers.
+const DOCUMENT: (&[u8], &[u8]) = (b"%%BeginDocument", b"%%EndDocument");
fn is_space(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'\0')
@@ -160,13 +164,15 @@ impl<'a> Lexer<'a> {
}
/// The token a comment line stands for when it starts a section read differently: image data
- /// (`%%BeginData:` followed by `XI`) and skipped binary sections.
+ /// (`%%BeginData:` followed by `XI`), a placed file's preview and skipped binary sections.
fn special(&mut self, line: &'a [u8]) -> Option> {
- if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| line.starts_with(b)) {
+ if let Some((_, end)) = [PREVIEW, DOCUMENT].iter().find(|(b, _)| line.starts_with(b)) {
+ let from = self.pos;
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 tok = if *end == PREVIEW.1 { Tok::Preview(self.slice(from, after)) } else { Tok::Comment(line.get(1..).unwrap_or_default()) };
+ return Some(Token { tok, hidden: false });
}
let n = line.strip_prefix(b"%%BeginData:")?;
let n: usize = std::str::from_utf8(n).ok()?.split_whitespace().next()?.parse().ok()?;
@@ -324,10 +330,13 @@ mod tests {
}
#[test]
- fn binary_previews_are_skipped() {
+ fn binary_sections_are_one_token() {
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);
+ assert_eq!(toks, vec![t(Tok::Preview(b"\r\n2 1 (\x00\xff\r\n"), false), t(Tok::Word(b"N"), false)]);
+ let toks = all(b"%%BeginDocument: a.eps\n(\x00 frobnicate\n%%EndDocument\nN\n");
+ assert_eq!(toks, vec![t(Tok::Comment(b"%BeginDocument: a.eps"), false), t(Tok::Word(b"N"), false)]);
+ // One that doesn't end takes the rest.
+ assert_eq!(all(b"%AI26_BeginPlacedObjectPreview\r1 (").len(), 1);
}
#[test]
diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs
index 072a7497..e82a534d 100644
--- a/crates/eps/src/import/ai/read.rs
+++ b/crates/eps/src/import/ai/read.rs
@@ -15,6 +15,12 @@
//! app wrote) the bounding box.
//! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …`
//! and the samples after `XI`.
+//! - Placed (linked) files, as written in files users sent: `%AI5_BeginPlace`, `[matrix] llx lly urx
+//! ury … (path)` and the operator `` ` ``, the file's preview between `%AI26_BeginPlacedObjectPreview`
+//! and its end (`w h rowBytes 32 n `, then rows of premultiplied ARGB, the top one first), `~`. The
+//! box is as large as the file has pixels; the matrix takes them into art space from the canvas
+//! (y down, its centre the template box's), where they start at the point of art space's origin,
+//! the first row the top one. Each is a linked image showing the preview until its file is found.
//! - Art with an appearance the format can't write as plain art (several fills or strokes,
//! effects, brushes) comes first as that look, drawn (a group), then the object itself on `%_`
//! lines and `1 (style) XW`: the drawn look is kept, named after the object.
@@ -25,8 +31,8 @@
//! taking the pattern's space to art space (the other operands are what it is made of).
//!
//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped;
-//! type, symbols, fills with a pattern it can't read, placed files, legacy type and operators it
-//! doesn't know are errors.
+//! type, symbols, fills with a pattern it can't read, placed files without a preview, legacy type
+//! and operators it doesn't know are errors.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::rc::Rc;
@@ -38,10 +44,11 @@ use vectorcraft_color::swatch::Swatch;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin,
- Node, NodeId, NodeKind, PatternDef, StrokeLayer,
+ LinkInfo, Node, NodeId, NodeKind, PatternDef, StrokeLayer,
};
use vectorcraft_geom::{FillRule, PathData};
+use super::super::ate::CANVAS_CENTRE;
use super::lex::{Lexer, Tok};
use super::obj::{self, Obj, V};
use super::paint::{self, GradientDef, Instance, Named};
@@ -234,6 +241,22 @@ struct Raster {
space: Option,
}
+/// A placed file's operands, from `` ` `` and its preview, until `~`.
+#[derive(Debug, Default)]
+struct Place {
+ /// The canvas, where the file's pixels are → art space.
+ matrix: Option,
+ /// The file's box (`llx lly urx ury`): its size is the file's, in pixels.
+ bounds: Option,
+ path: String,
+ /// The preview as a PNG, and its size in pixels.
+ preview: Option<(Vec, u32, u32)>,
+}
+
+/// What the open result says of placed files: they show the preview the file keeps of them.
+const LINKED_PREVIEWS: &str =
+ "linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel";
+
struct Reader<'a> {
lex: Lexer<'a>,
stack: Vec,
@@ -256,6 +279,8 @@ struct Reader<'a> {
pending_stroke: Option,
spots: Vec,
raster: Option,
+ /// The placed file being read (`%AI5_BeginPlace` … `~`).
+ place: Option,
/// The last object read is the last child of the innermost frame, and nothing started since.
after_object: bool,
nodes: usize,
@@ -393,6 +418,7 @@ impl<'a> Reader<'a> {
pending_stroke: None,
spots: Vec::new(),
raster: None,
+ place: None,
foreign_data: None,
after_object: false,
nodes: 0,
@@ -436,6 +462,11 @@ impl<'a> Reader<'a> {
}
Tok::Comment(c) => self.comment(c)?,
Tok::Data(d) => self.image(d)?,
+ Tok::Preview(d) => {
+ if let Some(p) = self.place.as_mut() {
+ p.preview = preview_png(d);
+ }
+ }
}
}
if self.frames.iter().any(|f| matches!(f.kind, Kind::Layer(_))) {
@@ -495,7 +526,12 @@ impl<'a> Reader<'a> {
} else if line.starts_with("AI5_EndRaster") {
self.raster = None;
} else if line.starts_with("AI5_BeginPlace") {
- self.unreadable("placed files");
+ self.place = Some(Place::default());
+ } else if line.starts_with("AI5_EndPlace") {
+ // One that `~` didn't end isn't read.
+ if self.place.take().is_some() {
+ self.unreadable("placed files");
+ }
} else if self.frames.is_empty()
&& let Some(v) = line.strip_prefix("%HiResBoundingBox:")
{
@@ -901,6 +937,18 @@ impl<'a> Reader<'a> {
r.space = vals.iter().rev().find_map(V::text);
}
}
+ // Placed files.
+ "`" if self.place.is_some() => {
+ let matrix = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None });
+ let path = vals.iter().rev().find_map(V::text).unwrap_or_default();
+ if let Some(p) = self.place.as_mut() {
+ p.matrix = matrix.and_then(|m| <[f64; 6]>::try_from(m).ok()).map(Affine::new);
+ p.bounds =
+ Self::nums(&vals).get(..4).and_then(|b| <[f64; 4]>::try_from(b).ok()).map(|[x0, y0, x1, y1]| Rect::new(x0, y0, x1, y1));
+ p.path = path;
+ }
+ }
+ "~" if self.place.is_some() => self.end_place(hidden)?,
// Type in the legacy format.
"To" | "TO" | "Tp" | "TP" | "Tx" | "TX" | "Tj" | "Tk" => self.unreadable("type in the legacy format"),
_ if IGNORED.contains(&w) => {}
@@ -1435,12 +1483,44 @@ impl<'a> Reader<'a> {
let blob = ImageBlob::new("image/png", png);
let key = blob.content_key();
self.doc.images.entry(key.clone()).or_insert(blob);
+ self.add_image(ImageObject { key, width: w as u32, height: h as u32, xf, link: None, placement: Default::default() }, false)
+ }
+
+ /// Add an image object with the state's transparency.
+ fn add_image(&mut self, im: ImageObject, hidden: bool) -> Result<(), String> {
let gs = self.gs.clone();
- let im = ImageObject { key, width: w as u32, height: h as u32, xf, link: None, placement: Default::default() };
let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs);
node.opacity = gs.opacity;
node.blend = gs.blend;
- self.add(node, false)
+ self.add(node, hidden)
+ }
+
+ /// `~`: the placed file read since `%AI5_BeginPlace` becomes a linked image in its place that
+ /// shows the preview the data keeps of it, as a linked image whose file is missing does. One
+ /// without a preview, a path or a matrix isn't read.
+ fn end_place(&mut self, hidden: bool) -> Result<(), String> {
+ let Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) = self.place.take().filter(|p| !p.path.is_empty())
+ else {
+ self.unreadable("placed files");
+ return Ok(());
+ };
+ // The file's pixels (y down) start at the canvas point of art space's origin (the box's own
+ // corner when the file doesn't say where the canvas is); the preview's are spread over them.
+ let centre = self.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
+ let origin = centre.map_or(b.origin(), |(x, y)| Point::new(CANVAS_CENTRE - x, CANVAS_CENTRE + y));
+ let over_file = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(b.width() / f64::from(w), b.height() / f64::from(h));
+ let xf = self.space() * m * over_file;
+ if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
+ self.unreadable("placed files");
+ return Ok(());
+ }
+ // The preview is all the document has of the file: what a missing link shows.
+ let bytes = Arc::new(png);
+ let blob = ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) };
+ let key = blob.content_key();
+ self.doc.images.entry(key.clone()).or_insert(blob);
+ self.warn(LINKED_PREVIEWS);
+ self.add_image(ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default() }, hidden)
}
// ---- the document ----------------------------------------------------------------------
@@ -1636,6 +1716,38 @@ fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> {
o.obj(key).or_else(|| o.entries.iter().flat_map(|(_, v)| v.iter()).filter_map(V::obj).find_map(|c| find_entry(c, key, depth + 1)))
}
+/// A placed file's preview as a PNG, with its size: `w h rowBytes 32 n ` (each number followed by
+/// a space), then `h` rows of `rowBytes` bytes, premultiplied ARGB. `None` when it isn't that, is
+/// larger than [`MAX_PIXELS`] or is cut short.
+fn preview_png(data: &[u8]) -> Option<(Vec, u32, u32)> {
+ let mut rest = data.trim_ascii_start();
+ let mut head = [0usize; 5];
+ for v in &mut head {
+ let n = rest.iter().position(|b| *b == b' ')?;
+ *v = std::str::from_utf8(rest.get(..n)?).ok()?.parse().ok()?;
+ rest = rest.get(n + 1..)?;
+ }
+ let [w, h, row, bits, _] = head;
+ let (width, height) = (u32::try_from(w).ok()?, u32::try_from(h).ok()?);
+ if w == 0 || h == 0 || u64::from(width) * u64::from(height) > MAX_PIXELS || bits != 32 || row < w * 4 {
+ return None;
+ }
+ // Every row is there before anything is allocated for them.
+ if rest.len() < (h - 1).checked_mul(row)?.checked_add(w * 4)? {
+ return None;
+ }
+ let mut rgba = Vec::with_capacity(w * h * 4);
+ for line in rest.chunks(row).take(h) {
+ for &[a, r, g, b] in line.get(..w * 4)?.as_chunks::<4>().0 {
+ let straight = |c: u8| if a == 0 { 0 } else { (u32::from(c) * 255 / u32::from(a)).min(255) as u8 };
+ rgba.extend_from_slice(&[straight(r), straight(g), straight(b), a]);
+ }
+ }
+ let mut png = vec![];
+ image::RgbaImage::from_raw(width, height, rgba)?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
+ Some((png, width, height))
+}
+
fn sane(r: Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE)
}
diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs
index 70c07b4c..3a468d8c 100644
--- a/crates/eps/src/import/ai/tests.rs
+++ b/crates/eps/src/import/ai/tests.rs
@@ -371,6 +371,81 @@ fn images_with_alpha_channels() {
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
}
+/// Editing data with one layer (showing or not) holding a placed file: its matrix, the file at
+/// `path` in the box 10 20 14 22, and `preview` (what its preview section holds), then `after`.
+fn placed(visible: bool, matrix: &str, path: &str, preview: Option<&[u8]>, after: &str) -> Vec {
+ let text = stream(&layer("@@").replacen("1 1 1 1", if visible { "1 1 1 1" } else { "0 1 1 1" }, 1));
+ let (head, tail) = text.split_once("@@").unwrap();
+ let mut s = format!(
+ "{head}%AI5_BeginPlace\r%%FileType: 1\r{matrix} 10 20 14 22 4 4 0\r({path})`\r%%IncludeFile:{path}\r\
+ %AI17_Begin_Content_if_version_gt:24 12\r%AI28_BeginPlacedRelativePath\r()\r%AI28_EndPlacedRelativePath\r%AI17_Alternate_Content\r%AI17_End_Versioned_Content\r\
+ %AI17_Begin_Content_if_version_gt:24 5\r"
+ )
+ .into_bytes();
+ if let Some(p) = preview {
+ s.extend_from_slice(b"%AI26_BeginPlacedObjectPreview\r");
+ s.extend_from_slice(p);
+ s.extend_from_slice(b"\r%AI26_EndPlacedObjectPreview\r");
+ }
+ s.extend_from_slice(format!("%AI17_End_Versioned_Content\r~\r%AI5_EndPlace\r{after}{tail}").as_bytes());
+ s
+}
+
+#[test]
+fn placed_files_are_linked_images_showing_their_preview() {
+ // A 2×1 preview (premultiplied ARGB, a padded row) of a file of 4×2 pixels.
+ let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
+ let name = "%_/ArtDictionary :\r%_(Photo) /String (AIArtName) ,\r%_;\r";
+ let r = read(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", Some(&preview), name)).unwrap();
+ let a = art(&r.doc);
+ let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
+ assert_eq!((im.link.as_ref().map(|l| l.path.as_str()), im.width, im.height), (Some("/pics/photo.png"), 2, 1));
+ assert_eq!(a[0].name.as_deref(), Some("Photo"));
+ assert!(r.doc.images[&im.key].is_proxy(), "the preview is what a missing link shows");
+ let (img, xf) = pixels(&r.doc);
+ assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 127, 0, 128]));
+ // Without a template box the pixels start at the box's corner, the first row the top one:
+ // (10, 20) is at (50, 100) in art space, the page's top.
+ assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(50.0, 0.0), Point::new(70.0, 10.0)));
+ assert!(r.warnings.iter().any(|w| w.contains("low-resolution preview")), "{:?}", r.warnings);
+ // With a template box, the pixels start at the canvas point of art space's origin (8091.5,
+ // 8241.5 here), wherever the box is.
+ let mut data = placed(true, "[5 0 0 -5 -40407.5 41307.5]", "a.png", Some(&preview), "");
+ let at = data.windows(15).position(|w| w == b"%AI5_BeginLayer").unwrap();
+ data.splice(at..at, *b"%AI3_TemplateBox: 100 50 100 50\n");
+ let xf = pixels(&read(&data).unwrap().doc).1;
+ assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(50.0, 0.0), Point::new(70.0, 10.0)));
+ // Turned a quarter: the box's rows run along x.
+ let r = read(&placed(true, "[0 5 5 0 0 0]", "a.png", Some(&preview), "")).unwrap();
+ let xf = pixels(&r.doc).1;
+ assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(100.0, 50.0), Point::new(110.0, 30.0)));
+
+ // Without a preview it can read (none, cut short, not 32 bits, absurd sizes), without a path or
+ // a matrix, the file isn't read from its editing data.
+ let cut = &preview[..preview.len() - 6];
+ let huge = b"70000 70000 280000 32 17 \0\0\0\0";
+ let overflow = b"1 2 18446744073709551615 32 17 \0\0\0\0\0\0\0\0";
+ for (matrix, path, preview) in [
+ ("[5 0 0 -5 0 200]", "a.png", None),
+ ("[5 0 0 -5 0 200]", "a.png", Some(cut)),
+ ("[5 0 0 -5 0 200]", "a.png", Some(&b"2 1 2 8 17 \0\0"[..])),
+ ("[5 0 0 -5 0 200]", "a.png", Some(&huge[..])),
+ ("[5 0 0 -5 0 200]", "a.png", Some(&overflow[..])),
+ ("[5 0 0 -5 0 200]", "a.png", Some(&b"2 1"[..])),
+ ("[5 0 0 -5 0 200]", "", Some(&preview[..])),
+ ("[0 0 0 0 0 0]", "a.png", Some(&preview[..])),
+ ("[1 2 3]", "a.png", Some(&preview[..])),
+ ] {
+ let e = read(&placed(true, matrix, path, preview, "")).unwrap_err();
+ assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}");
+ }
+ // On a layer that doesn't show, such a file is left out of it.
+ let r = read(&placed(false, "[5 0 0 -5 0 200]", "a.png", None, "")).unwrap();
+ assert!(r.hidden_unread.contains("placed files") && art(&r.doc).is_empty());
+ // `~` ends nothing else.
+ assert!(read(stream(&layer("~\n")).as_bytes()).unwrap_err().contains("`~`"));
+}
+
#[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";
diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs
index 189765a3..0e950176 100644
--- a/crates/eps/src/import/ate.rs
+++ b/crates/eps/src/import/ate.rs
@@ -34,7 +34,7 @@ const MAX_DEPTH: usize = 64;
/// Most values read.
const MAX_VALUES: usize = 4_000_000;
/// The centre of the app's canvas, in the units of its text document.
-const CANVAS_CENTRE: f64 = 8191.5;
+pub(super) const CANVAS_CENTRE: f64 = 8191.5;
/// A value of the text document.
#[derive(Debug, Clone, PartialEq)]
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index defd6191..c3fd1ecf 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -4128,6 +4128,7 @@ effect Regular Pravidelný
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it přechod „{name}“ není v souboru definován: zastupuje ho plná barva
@msg an image VectorCraft can't read was left out obrázek, který VectorCraft neumí přečíst, byl vynechán
@msg an image whose data is cut short was left out obrázek s useknutými daty byl vynechán
+@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel propojené obrázky se zobrazují jako náhled v nízkém rozlišení uložený v souboru, dokud se nenajdou jejich soubory: znovu je propojte v panelu Propojení
@msg its editing data doesn't say where its art is z jeho dat pro úpravy není zřejmé, kde je kresba
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) tento soubor byl uložen bez kompatibility s PDF a jeho data pro úpravy nelze přečíst ({why})
@msg No fonts are missing in this document V tomto dokumentu nechybí žádná písma
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 9f347288..ca5714ed 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -3843,6 +3843,7 @@ theme Appearance Erscheinungsbild
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it der Verlauf „{name}“ ist in der Datei nicht definiert: Eine einfache Farbe steht an seiner Stelle
@msg an image VectorCraft can't read was left out ein Bild, das VectorCraft nicht lesen kann, wurde ausgelassen
@msg an image whose data is cut short was left out ein Bild mit abgeschnittenen Daten wurde ausgelassen
+@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel verknüpfte Bilder zeigen die in der Datei gespeicherte Vorschau in niedriger Auflösung, bis ihre Dateien gefunden werden: Verknüpfen Sie sie im Bedienfeld „Verknüpfungen“ neu
@msg its editing data doesn't say where its art is seine Bearbeitungsdaten geben nicht an, wo seine Grafik liegt
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) diese Datei wurde ohne PDF-Kompatibilität gespeichert, und ihre Bearbeitungsdaten können nicht gelesen werden ({why})
@msg No fonts are missing in this document In diesem Dokument fehlen keine Schriften
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index f00f3b31..fe95a802 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -3943,6 +3943,7 @@ axis Both Ambos
@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 linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel las imágenes vinculadas muestran la vista previa de baja resolución guardada en el archivo hasta que se encuentren sus archivos: vuelva a vincularlas en el panel Vínculos
@msg its editing data doesn't say where its art is sus datos de edición no dicen dónde está su ilustración
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) este archivo se guardó sin compatibilidad con PDF y sus datos de edición no se pueden leer ({why})
@msg No fonts are missing in this document No faltan fuentes en este documento
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 49139253..eb0193ef 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -3940,6 +3940,7 @@ axis Both Les deux
@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 linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel les images liées affichent l’aperçu basse résolution enregistré dans le fichier tant que leurs fichiers ne sont pas trouvés : liez-les à nouveau dans le panneau Liens
@msg its editing data doesn't say where its art is ses données d’édition ne disent pas où se trouve son illustration
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) ce fichier a été enregistré sans compatibilité PDF et ses données d’édition sont illisibles ({why})
@msg No fonts are missing in this document Aucune police ne manque dans ce document
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 878605bc..6c52b398 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -4027,6 +4027,7 @@ theme Appearance Kinézet
@msg already editing a pattern már folyik egy mintázat szerkesztése
@msg an image VectorCraft can't read was left out kimaradt egy kép, amelyet a VectorCraft nem tud olvasni
@msg an image whose data is cut short was left out kimaradt egy csonka adatú kép
+@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel a csatolt képek a fájlban mentett kis felbontású előnézetet mutatják, amíg a fájljaik elő nem kerülnek: csatolja őket újra a Csatolások panelen
@msg an object can't be read egy objektum nem olvasható
@msg an object in perspective would leave the edited grid egy perspektivikus objektum kikerülne a szerkesztett rácsból
@msg anchor must name a registered panel az anchor egy regisztrált panelt kell megnevezzen
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 2e924593..8c3f3e86 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -3943,6 +3943,7 @@ axis Both Entrambi
@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 linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel le immagini collegate mostrano l'anteprima a bassa risoluzione salvata nel file finché i loro file non vengono trovati: ricollegale nel pannello Collegamenti
@msg its editing data doesn't say where its art is i suoi dati di modifica non dicono dove si trova la grafica
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) questo file è stato salvato senza compatibilità PDF e i suoi dati di modifica non si possono leggere ({why})
@msg No fonts are missing in this document In questo documento non mancano font
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 4125621d..37f98cda 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -3679,6 +3679,7 @@ theme Appearance 外観
@msg its section ending with `%{end}` doesn't end `%{end}` で終わるはずのセクションが終わっていません
@msg an image VectorCraft can't read was left out VectorCraft で読み込めない画像は除外されました
@msg an image whose data is cut short was left out データが途中で切れている画像は除外されました
+@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel リンク画像は、元のファイルが見つかるまで、ファイルに保存された低解像度のプレビューで表示されます:リンクパネルで再リンクしてください
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it グラデーション「{name}」はファイル内で定義されていません: 代わりに単色を使用しています
@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 patterns nested too deeply are filled with mid-grey 入れ子が深すぎるパターンは中間のグレーで塗りつぶされています
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
index cfd724e4..2b6924d2 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -3657,6 +3657,7 @@ theme Appearance 외관
@msg its section ending with `%{end}` doesn't end `%{end}`로 끝나는 구역이 끝나지 않습니다
@msg an image VectorCraft can't read was left out VectorCraft가 읽을 수 없는 이미지는 제외했습니다
@msg an image whose data is cut short was left out 데이터가 잘린 이미지는 제외했습니다
+@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 연결된 그림은 해당 파일을 찾을 때까지 파일에 저장된 저해상도 미리 보기로 표시됩니다: 링크 패널에서 다시 연결하세요
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 그레이디언트 “{name}”이(가) 파일에 정의되어 있지 않아 단색으로 대체했습니다
@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 patterns nested too deeply are filled with mid-grey 너무 깊게 중첩된 패턴은 중간 회색으로 채웁니다
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index f95584f4..75234821 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -3939,6 +3939,7 @@ axis Both Оба
@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 linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel связанные изображения показываются как сохранённый в файле просмотр низкого разрешения, пока их файлы не найдены: восстановите связи на панели «Связи»
@msg its editing data doesn't say where its art is в его данных редактирования не указано, где находится графика
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) этот файл сохранён без совместимости с PDF, и его данные редактирования не удаётся прочитать ({why})
@msg No fonts are missing in this document В этом документе нет отсутствующих шрифтов
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 489a369f..b55044ea 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -3927,6 +3927,7 @@ axis Both Обидві
@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 linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel пов’язані зображення показуються як збережений у файлі перегляд низької роздільності, доки їхні файли не знайдено: відновіть зв’язки на панелі «Зв’язки»
@msg its editing data doesn't say where its art is у даних редагування не зазначено, де знаходиться графіка
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) цей файл збережений без сумісності з PDF, і його дані редагування не вдається прочитати ({why})
@msg No fonts are missing in this document У цьому документі немає відсутніх шрифтів
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 836b4259..7165eb80 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2936,6 +2936,7 @@
Update Changed Links 更新已更改的链接
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 个链接图像已在磁盘上更改。要显示新版本吗?
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 个在磁盘上更改的链接文件
+@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 链接的图片在找到其文件之前显示文件中存储的低分辨率预览:请在“链接”面板中重新链接
Update Modified Links 更新已修改的链接
Updated {} image(s) 已更新 {} 张图像
UPPERCASE 大写
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 2c962956..3aac3bd2 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -3029,6 +3029,7 @@
Update Changed Links 更新已變更的連結
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 個連結影像已在磁碟上變更。要顯示新版本嗎?
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 個在磁碟上變更的連結檔案
+@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 連結的圖片在找到其檔案之前顯示檔案中儲存的低解析度預覽:請在「連結」面板中重新連結
Update Modified Links 更新已修改的連結
Updated {} image(s) 已更新 {} 張影像
UPPERCASE 全部大寫
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 7e5265dc..6cce866f 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File format parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public
@@ -47,7 +47,7 @@ private editing data. VectorCraft:
### Illustrator's private editing data
-the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), 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, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), 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 is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, 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, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). 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). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). 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.
+the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), 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, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), 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 is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of 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, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). 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). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). 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.
## Format by format
@@ -83,6 +83,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
+| 2026-10-12 | minor | `.ai` editing data: placed (linked) files come in as linked images showing the preview the file keeps, so a file with them opens through its layers, with its guides (#1264); scores unchanged |
| 2026-10-12 | minor | PDF and `.ai` import: a shape painted with a gradient opens as one path with the gradient (#1304). PDF and `.ai` export: the constant alpha soft mask of a CMYK page with transparency is white (#1286, in part) |
| 2026-10-12 | minor | DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves stay open and curves retained; rational/non-rational import and export versions regression-tested; scores/hours unchanged |
| 2026-10-12 | minor | A `.ai` opens on its trim boxes with its bleed boxes as the document's bleed; a PDF opened on its trim boxes too (#1266); a PDF saved with its editing data opens as the `.ai` does |
diff --git a/docs/gaps.md b/docs/gaps.md
index 3734deeb..29257a41 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
@@ -67,7 +67,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead
of 14), guides and non-printing construction
lines (#779), open speed on large files (#758), legacy type in `.ai` files in PostScript form (those without
- their prolog open since #1027), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025). Writing Illustrator's own editing data is out of scope
+ their prolog open since #1027), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope
(undocumented); Save As `.ai` stays PDF-compatible, and Illustrator 2018 opens such a file without its layers and
groups (#868).
- **Evidence:** the issues above; 28 of the 350 issues are about `.ai`/EPS (about 22 people, the most common single
@@ -234,6 +234,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| Date | Change | Summary |
|---|---|---|
+| 2026-10-12 | minor | G9: placed (linked) files in `.ai` editing data come in as linked images showing their saved preview, so such files open through their layers with their guides (#1264); estimate unchanged |
| 2026-10-12 | minor | G9: gradient-filled shapes from a PDF or `.ai` open as paths, not clip groups (#1304); CMYK files with transparency show in applications reading soft masks by luminosity (#1286, in part: the layer groups' bounding boxes remain); G15: hiding or locking a layer deselects in linear time (#1353); estimates unchanged |
| 2026-10-12 | minor | G7: custom document ruler origin (#1212) done; global/artboard mode switching and video rulers remain, estimate unchanged |
| 2026-10-12 | minor | G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 3c427319..b73ea3f9 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@@ -492,6 +492,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `