From 3585ef654c0b1377a7156f349979fa5c50585b67 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 06:24:43 +0700 Subject: [PATCH] Part of #372: PDF/.ai import keeps strokes live and hidden and nested layers An object Illustrator writes as a fill of its outline left open and then a stroke of it closed imported as two objects: a filled path and a separate stroke-only path. A fill and a stroke of the same outline (equal once every subpath is closed) now become one path with the fill and a live stroke (width, cap, join, miter limit, dash, paint), taking the stroke's outline. Layers: a group whose view state is off (/Usage /View /ViewState /OFF, with or without an /AS view event) comes in as a hidden layer with its art, as groups off in the default configuration already did. The configuration's /Order nesting (inline or as its own object, labels skipped) makes sublayers, which take their top-level layer's colour; nesting depth, entries and groups are capped. Splitting a page's art by group is no longer quadratic, so thousands of layers import quickly. The 6D art pen (rotation-aware brushes) is a separate feature, now on the ROADMAP gap list. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 9 +- crates/pdf/src/import.rs | 132 ++++++++++++++++++++---- crates/pdf/src/import_scan.rs | 97 ++++++++++++++--- crates/pdf/src/tests_import_fidelity.rs | 58 ++++++++++- crates/pdf/src/tests_import_layers.rs | 110 +++++++++++++++++++- docs/mcp.md | 9 +- 6 files changed, 371 insertions(+), 44 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 27f67414..faf375a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319) and open paths in the Shape Builder (#374); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374) and PDF/`.ai` import keeping strokes live and hidden and nested layers (#372); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -63,7 +63,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have initial support. 35–48 h across type core and advanced. 5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries - generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h. + generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by pen tilt, bearing and + barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every platform. 20–32 h. 6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h. 7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video rulers. 15–25 h. @@ -144,7 +145,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. - - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). + - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. @@ -192,7 +193,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, Touch Type | 10–15 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection and options | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | -| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 | +| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index a4f59ff3..e15e6d0f 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -11,7 +11,7 @@ use hayro_interpret::{ MaskType, PathDrawMode, SoftMask, StrokeProps, interpret_page, }; use hayro_syntax::object::Name; -use kurbo::{Affine, BezPath, Rect, Shape}; +use kurbo::{Affine, BezPath, PathEl, Rect, Shape}; use vectorcraft_color::{BlendMode, Color, Paint, Swatch}; use vectorcraft_doc::{ Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin, @@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover}; -use crate::import_scan::{Ocgs, Scan, all_on, hides_forms, scan_page}; +use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page}; use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient}; use crate::import_text::{Families, Look, Placement, TextLine, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; @@ -46,6 +46,60 @@ enum Slot { Group(usize), } +/// What each group's layer holds, in paint order: its sublayers (their groups) and, where its +/// own group is listed, its art. +type Nesting = HashMap>; + +/// Group `g` paints (its first art): note where its art goes in its layer, and where that layer +/// goes when it is new: in its parent's layer (placed the same way) or, for a top-level group, in +/// `slots`. +fn place_group(g: usize, ocgs: &Ocgs, slots: &mut Vec, nesting: &mut Nesting) { + let placed = nesting.contains_key(&g); + nesting.entry(g).or_default().push(g); + if placed { + return; + } + let mut child = g; + // Each parent is placed before its sublayers, so the chain ends (within the nesting depth). + for _ in 0..=MAX_NESTING { + let Some(parent) = ocgs.list.get(child).and_then(|o| o.parent) else { break }; + let placed = nesting.contains_key(&parent); + nesting.entry(parent).or_default().push(child); + if placed { + return; + } + child = parent; + } + slots.push(Slot::Group(child)); +} + +/// The layer of group `g`, in `color`: its art and sublayers. `None` for a group that isn't read. +fn group_layer( + b: &mut Builder<'_>, + g: usize, + ocgs: &Ocgs, + nesting: &Nesting, + art: &mut HashMap>>, + color: LayerColor, +) -> Option { + let ocg = ocgs.list.get(g)?; + let mut l = Node::layer(b.id(), &ocg.name, color); + (l.visible, l.locked) = (ocg.on, ocg.locked); + let mut children = vec![]; + for &c in nesting.get(&g).into_iter().flatten() { + if c == g { + children.extend(art.remove(&g).unwrap_or_default()); + } else if let Some(sub) = group_layer(b, c, ocgs, nesting, art, color) { + children.push(Arc::new(sub)); + } + } + if let NodeKind::Layer { children: c, printable, .. } = &mut l.kind { + *c = children; + *printable = ocg.print; + } + Some(l) +} + /// Import a PDF, returning the document plus warnings about content that was approximated or skipped. pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { let original = crate::pages::open(bytes, opts.password.as_deref())?; @@ -54,7 +108,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result