mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3585ef654c |
No files matched your search
+5
-4
@@ -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 |
|
||||
|
||||
+112
-20
@@ -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<usize, Vec<usize>>;
|
||||
|
||||
/// 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<Slot>, 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<usize, Vec<Arc<Node>>>,
|
||||
color: LayerColor,
|
||||
) -> Option<Node> {
|
||||
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<ImportReport, PdfError> {
|
||||
let original = crate::pages::open(bytes, opts.password.as_deref())?;
|
||||
@@ -54,7 +108,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let route = opts.layers && !ocgs.list.is_empty();
|
||||
// Content that is off imports as hidden layers: an update of the file turns every group on
|
||||
// (unless a form is hidden by its own group: its art couldn't be told apart).
|
||||
let hide = route && ocgs.any_off();
|
||||
let hide = route && ocgs.skips_any();
|
||||
let turned_on = (hide && !picked.iter().filter_map(|&n| original.pages().get(n)).any(|p| hides_forms(p, &mut ocgs)))
|
||||
.then(|| all_on(bytes, &original))
|
||||
.flatten()
|
||||
@@ -100,6 +154,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let mut placeholder = true;
|
||||
let mut slots: Vec<Slot> = vec![];
|
||||
let mut group_art: HashMap<usize, Vec<Arc<Node>>> = HashMap::new();
|
||||
let mut nesting = Nesting::new();
|
||||
for (i, &number) in picked.iter().enumerate() {
|
||||
let Some(page) = pages.get(number) else { continue };
|
||||
// The chosen box sits at (x, 0); the page draws round it.
|
||||
@@ -158,7 +213,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
None => slots.push(page_layer(&mut b, art)),
|
||||
Some(g) => {
|
||||
let into = group_art.entry(g).or_insert_with(|| {
|
||||
slots.push(Slot::Group(g));
|
||||
place_group(g, &ocgs, &mut slots, &mut nesting);
|
||||
vec![]
|
||||
});
|
||||
into.extend(art);
|
||||
@@ -172,16 +227,11 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
for (n, slot) in slots.into_iter().enumerate() {
|
||||
let layer = match slot {
|
||||
Slot::Page(l) => *l,
|
||||
Slot::Group(g) => {
|
||||
let Some(ocg) = ocgs.list.get(g) else { continue };
|
||||
let mut l = Node::layer(b.id(), &ocg.name, LayerColor::Preset((n % 27) as u8));
|
||||
(l.visible, l.locked) = (ocg.on, ocg.locked);
|
||||
if let NodeKind::Layer { children, printable, .. } = &mut l.kind {
|
||||
*children = group_art.remove(&g).unwrap_or_default();
|
||||
*printable = ocg.print;
|
||||
}
|
||||
l
|
||||
}
|
||||
// Sublayers take their top-level layer's colour.
|
||||
Slot::Group(g) => match group_layer(&mut b, g, &ocgs, &nesting, &mut group_art, LayerColor::Preset((n % 27) as u8)) {
|
||||
Some(l) => l,
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
doc.layers.push(Arc::new(layer));
|
||||
}
|
||||
@@ -497,6 +547,8 @@ struct Builder<'p> {
|
||||
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
|
||||
pending: bool,
|
||||
nested: u32,
|
||||
/// The last filled path: its node and its outline (a stroke of the same outline joins it).
|
||||
last_fill: Option<(NodeId, BezPath)>,
|
||||
}
|
||||
|
||||
fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
|
||||
@@ -528,6 +580,29 @@ fn fill_rule(r: hayro_interpret::FillRule) -> FillRule {
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` with every subpath closed: how a fill reads it.
|
||||
fn closed_subpaths(p: &BezPath) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
let mut open = false;
|
||||
for el in p.elements() {
|
||||
if open && matches!(el, PathEl::MoveTo(_)) {
|
||||
out.push(PathEl::ClosePath);
|
||||
}
|
||||
out.push(*el);
|
||||
open = !matches!(el, PathEl::ClosePath);
|
||||
}
|
||||
if open {
|
||||
out.push(PathEl::ClosePath);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Do a fill of `fill` and a stroke of `stroke` paint one object's outline? Applications write the
|
||||
/// fill without closing the path (filling closes it) and the stroke closed.
|
||||
fn same_outline(fill: &BezPath, stroke: &BezPath) -> bool {
|
||||
fill.elements() == stroke.elements() || closed_subpaths(fill).elements() == closed_subpaths(stroke).elements()
|
||||
}
|
||||
|
||||
fn mean_scale(a: Affine) -> f64 {
|
||||
a.determinant().abs().sqrt()
|
||||
}
|
||||
@@ -606,6 +681,7 @@ impl<'p> Builder<'p> {
|
||||
marked: vec![],
|
||||
pending: false,
|
||||
nested: 0,
|
||||
last_fill: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,10 +732,15 @@ impl<'p> Builder<'p> {
|
||||
let children = self.close_frames();
|
||||
let groups = std::mem::take(&mut self.root_groups);
|
||||
let mut parts: Vec<(Option<usize>, Vec<Arc<Node>>)> = vec![];
|
||||
// Thousands of groups: find each one's part by index.
|
||||
let mut at: HashMap<Option<usize>, usize> = HashMap::new();
|
||||
for (n, g) in children.into_iter().zip(groups.into_iter().chain(std::iter::repeat(None))) {
|
||||
match parts.iter_mut().find(|(k, _)| *k == g) {
|
||||
Some((_, v)) => v.push(n),
|
||||
None => parts.push((g, vec![n])),
|
||||
let i = *at.entry(g).or_insert_with(|| {
|
||||
parts.push((g, vec![]));
|
||||
parts.len() - 1
|
||||
});
|
||||
if let Some((_, v)) = parts.get_mut(i) {
|
||||
v.push(n);
|
||||
}
|
||||
}
|
||||
parts
|
||||
@@ -1219,24 +1300,35 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
|
||||
*r = fill_rule(*rule);
|
||||
}
|
||||
self.last_fill = Some((n.id, bp));
|
||||
let n = wrap(self, n);
|
||||
self.push_node(n);
|
||||
}
|
||||
PathDrawMode::Stroke(props) => {
|
||||
let st = stroke_layer(paint, opacity, props, mean_scale(transform));
|
||||
// Fill-then-stroke of the same path (the `B` operator) becomes one object.
|
||||
// Fill-then-stroke of the same path (the `B` operator, or a fill and a stroke of
|
||||
// the outline as Illustrator writes an object) becomes one object: a path with a
|
||||
// fill and a live stroke.
|
||||
let blend = self.blend;
|
||||
let fill = self.last_fill.take();
|
||||
let last = match self.mask {
|
||||
Some(_) => self.masked.last_mut(),
|
||||
None => self.stack.last_mut().and_then(|f| f.children.last_mut()),
|
||||
};
|
||||
if band.is_none()
|
||||
&& let Some(last) = last
|
||||
&& let Some((id, outline)) = fill
|
||||
&& last.id == id
|
||||
&& last.blend == blend
|
||||
&& last.path_data() == Some(&pd)
|
||||
&& last.appearance.stroke().is_none()
|
||||
&& same_outline(&outline, &bp)
|
||||
{
|
||||
Arc::make_mut(last).appearance.items.push(AppearanceItem::Stroke(st));
|
||||
let last = Arc::make_mut(last);
|
||||
// The stroke's outline: closing it changes the stroke, not the fill.
|
||||
if let Some(p) = last.path_data_mut() {
|
||||
*p = pd;
|
||||
}
|
||||
last.appearance.items.push(AppearanceItem::Stroke(st));
|
||||
return;
|
||||
}
|
||||
let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! What the interpreter doesn't tell the import, read from the file itself: the optional content
|
||||
//! groups (layers) with their states, which group each marked-content sequence belongs to, the
|
||||
//! isolate and knockout flags of transparency groups, and the names of the fonts.
|
||||
//! groups (layers) with their states and nesting, which group each marked-content sequence belongs
|
||||
//! to, the isolate and knockout flags of transparency groups, and the names of the fonts.
|
||||
//!
|
||||
//! hayro-interpret reports a marked-content sequence by its tag alone and a transparency group
|
||||
//! without its flags, and skips the content of groups that are off. So each page's content is
|
||||
@@ -16,7 +16,7 @@ use hayro_interpret::CacheKey;
|
||||
use hayro_syntax::Pdf;
|
||||
use hayro_syntax::content::TypedIter;
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Array, Dict, FromBytes, Name, Object, ObjectIdentifier, dict_or_stream};
|
||||
use hayro_syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, ObjectIdentifier, dict_or_stream};
|
||||
use hayro_syntax::page::{Page, Resources};
|
||||
|
||||
/// The interpreter's limit on nested form XObjects.
|
||||
@@ -25,6 +25,8 @@ const MAX_DEPTH: u32 = 50;
|
||||
const MAX_OPS: usize = 2_000_000;
|
||||
/// The most groups read.
|
||||
const MAX_GROUPS: usize = 10_000;
|
||||
/// The deepest layers nest in the configuration's `/Order`.
|
||||
pub(crate) const MAX_NESTING: usize = 32;
|
||||
|
||||
/// An optional content group: a layer.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -35,6 +37,8 @@ pub(crate) struct Ocg {
|
||||
/// Printed (its print usage isn't off).
|
||||
pub print: bool,
|
||||
pub locked: bool,
|
||||
/// The group it is listed under in the layer order (a sublayer of it).
|
||||
pub parent: Option<usize>,
|
||||
}
|
||||
|
||||
/// The file's optional content groups.
|
||||
@@ -44,6 +48,10 @@ pub(crate) struct Ocgs {
|
||||
index: HashMap<ObjectIdentifier, usize>,
|
||||
/// The groups that are off (as the interpreter decides it).
|
||||
off: HashSet<ObjectIdentifier>,
|
||||
/// The groups whose view state applies when the file opens (an `/AS` entry for viewing).
|
||||
view_auto: HashSet<ObjectIdentifier>,
|
||||
/// The groups placed in the layer order.
|
||||
placed: HashSet<usize>,
|
||||
}
|
||||
|
||||
impl Ocgs {
|
||||
@@ -68,35 +76,88 @@ impl Ocgs {
|
||||
o.off.remove(&id);
|
||||
}
|
||||
o.off.extend(refs(&config, b"OFF"));
|
||||
let auto = config.get::<Array<'_>>(b"AS").map(|a| a.iter::<Dict<'_>>().take(MAX_GROUPS).collect::<Vec<_>>()).unwrap_or_default();
|
||||
for d in auto.iter().filter(|d| d.get::<Name<'_>>(b"Event").is_some_and(|e| e.as_ref() == b"View")) {
|
||||
o.view_auto.extend(refs(d, b"OCGs"));
|
||||
}
|
||||
let locked: HashSet<_> = refs(&config, b"Locked").into_iter().collect();
|
||||
for id in all {
|
||||
if let Some(d) = xref.get::<Dict<'_>>(id) {
|
||||
o.add(id, &d, locked.contains(&id));
|
||||
}
|
||||
}
|
||||
if let Some(order) = config.get::<Array<'_>>(b"Order") {
|
||||
o.nest(&order, None, 0, xref, &mut 0);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Read the layer order `order` (listed under group `parent`): a group followed by an array
|
||||
/// lists that array's groups as its sublayers; an array starting with a label lists its
|
||||
/// groups under the label, which isn't a group (they go under `parent`). A group listed twice
|
||||
/// stays where it is first listed. `seen`: the entries read so far.
|
||||
fn nest(&mut self, order: &Array<'_>, parent: Option<usize>, depth: usize, xref: &hayro_syntax::xref::XRef, seen: &mut usize) {
|
||||
let mut prev = None;
|
||||
for item in order.raw_iter() {
|
||||
*seen += 1;
|
||||
if *seen > 4 * MAX_GROUPS {
|
||||
return;
|
||||
}
|
||||
let sub = match item {
|
||||
MaybeRef::Ref(r) => {
|
||||
let id = ObjectIdentifier::from(r);
|
||||
let sub = xref.get::<Array<'_>>(id);
|
||||
if sub.is_none() {
|
||||
prev = self.place(id, parent);
|
||||
}
|
||||
sub
|
||||
}
|
||||
MaybeRef::NotRef(o) => o.into_array(),
|
||||
};
|
||||
if let Some(sub) = sub
|
||||
&& depth < MAX_NESTING
|
||||
{
|
||||
let label = matches!(sub.raw_iter().next(), Some(MaybeRef::NotRef(Object::String(_))));
|
||||
let under = prev.take().filter(|_| !label).or(parent);
|
||||
self.nest(&sub, under, depth + 1, xref, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put group `id` under `parent` in the layer order → its index, unless it isn't a group or
|
||||
/// is already placed. Each group is placed after its parent, so the nesting has no cycles.
|
||||
fn place(&mut self, id: ObjectIdentifier, parent: Option<usize>) -> Option<usize> {
|
||||
let i = *self.index.get(&id)?;
|
||||
let placed = self.placed.insert(i);
|
||||
let g = self.list.get_mut(i).filter(|_| placed)?;
|
||||
g.parent = parent;
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// Note group `id` (its dictionary `d`) → its index.
|
||||
fn add(&mut self, id: ObjectIdentifier, d: &Dict<'_>, locked: bool) -> usize {
|
||||
if let Some(i) = self.index.get(&id) {
|
||||
return *i;
|
||||
}
|
||||
let name = d.get::<hayro_syntax::object::String<'_>>(b"Name").map(|s| text_string(s.as_bytes())).unwrap_or_default();
|
||||
let print = d
|
||||
.get::<Dict<'_>>(b"Usage")
|
||||
.and_then(|u| u.get::<Dict<'_>>(b"Print"))
|
||||
.and_then(|p| p.get::<Name<'_>>(b"PrintState"))
|
||||
.is_none_or(|s| s.as_ref() != b"OFF");
|
||||
let on = !self.off.contains(&id);
|
||||
self.list.push(Ocg { name: if name.is_empty() { format!("Layer {}", self.list.len() + 1) } else { name }, on, print, locked });
|
||||
let print = usage(d, b"Print", b"PrintState") != Some(false);
|
||||
// A view state applies when the configuration says so on opening; an off one hides the
|
||||
// group all the same (it says how the group is meant to be seen).
|
||||
let view = usage(d, b"View", b"ViewState");
|
||||
let on = match view {
|
||||
Some(v) if self.view_auto.contains(&id) => v,
|
||||
Some(false) => false,
|
||||
_ => !self.off.contains(&id),
|
||||
};
|
||||
let name = if name.is_empty() { format!("Layer {}", self.list.len() + 1) } else { name };
|
||||
self.list.push(Ocg { name, on, print, locked, parent: None });
|
||||
self.index.insert(id, self.list.len() - 1);
|
||||
self.list.len() - 1
|
||||
}
|
||||
|
||||
/// Is any group off?
|
||||
pub fn any_off(&self) -> bool {
|
||||
self.list.iter().any(|g| !g.on)
|
||||
/// Does the interpreter leave out the art of some group (off in the default configuration)?
|
||||
pub fn skips_any(&self) -> bool {
|
||||
self.index.keys().any(|id| self.off.contains(id))
|
||||
}
|
||||
|
||||
/// The group content marked with `/OC` (`d`: the group or membership dictionary, `id` its
|
||||
@@ -119,6 +180,16 @@ impl Ocgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// The state (`ON` → true, `OFF` → false) group `d`'s usage `category` gives in `key`.
|
||||
fn usage(d: &Dict<'_>, category: &[u8], key: &[u8]) -> Option<bool> {
|
||||
let state = d.get::<Dict<'_>>(b"Usage")?.get::<Dict<'_>>(category)?.get::<Name<'_>>(key)?;
|
||||
match state.as_ref() {
|
||||
b"ON" => Some(true),
|
||||
b"OFF" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF text string: UTF-16 (with its byte order mark), UTF-8 (with one) or PDFDocEncoding
|
||||
/// (read as Latin-1, which it matches for letters).
|
||||
pub(crate) fn text_string(b: &[u8]) -> String {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Import fidelity: soft masks → opacity masks, isolated and knockout groups, tiling patterns →
|
||||
//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity
|
||||
//! and shading Extend flags.
|
||||
//! and shading Extend flags; strokes stay live strokes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Knockout, Node, NodeId, NodeKind, OpacityMask};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Knockout, LineCap, LineJoin, Node, NodeId, NodeKind, OpacityMask};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
|
||||
|
||||
@@ -390,3 +390,57 @@ fn glyphs_turned_along_a_curve_become_type_on_a_path() {
|
||||
assert_eq!(straight.len(), 1);
|
||||
assert!(matches!(straight[0].kind, vectorcraft_doc::TextKind::Point), "{:?}", straight[0].kind);
|
||||
}
|
||||
|
||||
/// The leaves (objects that aren't groups or clipping paths) of a document.
|
||||
fn objects(d: &Document) -> Vec<Node> {
|
||||
all(d).into_iter().filter(|n| !n.is_container() && !matches!(n.kind, NodeKind::Path { clipping: true, .. })).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_written_as_a_fill_and_a_closed_stroke_stays_one_path_with_a_live_stroke() {
|
||||
// As Illustrator writes an object: the fill of the outline left open, then the stroke of it
|
||||
// closed, each in its own transform. Two subpaths: one ending on its start, one not.
|
||||
let outline = "0 0 m -20 1 l -30 -12 l -30 30 l 0 0 l 5 5 m 10 5 l 10 10 l";
|
||||
let content = format!(
|
||||
"0.2 0.2 0.2 rg q 1 0 0 1 50 50 cm {outline} f Q 1 1 1 RG 1.5 w 1 J 1 j 4 M [3 2] 1 d q 1 0 0 1 50 50 cm {} S Q",
|
||||
outline.replace(" 5 5 m", " h 5 5 m") + " h"
|
||||
);
|
||||
let d = open(&one_page(&content, "", &[]));
|
||||
let objs = objects(&d);
|
||||
assert_eq!(objs.len(), 1, "one object, not a fill and a separate stroke: {objs:#?}");
|
||||
let n = &objs[0];
|
||||
assert_eq!(fill_color(n), Some(Color::rgb(0.2, 0.2, 0.2)));
|
||||
let st = n.appearance.stroke().expect("a live stroke");
|
||||
assert_eq!(st.paint.color(), Some(Color::WHITE));
|
||||
assert!((st.width - 1.5).abs() < 1e-6);
|
||||
assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Round));
|
||||
assert!((st.miter_limit - 4.0).abs() < 1e-6);
|
||||
let dash = st.dash.as_ref().expect("dashed");
|
||||
assert_eq!((dash.pattern.as_slice(), dash.offset), (&[3.0, 2.0][..], 1.0));
|
||||
// The stroke's outline: both subpaths closed, as they were stroked.
|
||||
let p = n.path_data().unwrap();
|
||||
assert_eq!(p.subpaths.len(), 2);
|
||||
assert!(p.is_closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_outlines_filled_and_stroked_stay_apart() {
|
||||
let d = open(&one_page("1 0 0 rg 10 10 m 40 10 l 40 40 l f 0 0 1 RG 10 10 m 40 10 l 40 40 l 10 40 l h S 0 g 50 50 m 60 60 l S", "", &[]));
|
||||
let objs = objects(&d);
|
||||
assert_eq!(objs.len(), 3);
|
||||
assert!(objs[0].appearance.stroke().is_none() && objs[1].appearance.fill().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_under_a_stretched_transform_stays_a_stroke() {
|
||||
// Stretched twice as wide: the stroke keeps its average width (√2 × 2 pt) rather than
|
||||
// becoming a filled outline.
|
||||
let d = open(&one_page("0 0 1 RG 2 w q 2 0 0 1 0 0 cm 10 10 m 40 10 l 40 40 l S Q", "", &[]));
|
||||
let objs = objects(&d);
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert!(objs[0].appearance.fill().is_none());
|
||||
let st = objs[0].appearance.stroke().expect("a stroke");
|
||||
assert!((st.width - 2.0 * 2f64.sqrt()).abs() < 1e-6, "{}", st.width);
|
||||
let b = objs[0].geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Optional content groups become layers: name, visibility, print state and lock; art that is
|
||||
//! off comes in as a hidden layer.
|
||||
//! Optional content groups become layers: name, visibility (the default configuration's or the
|
||||
//! view state's), print state, lock and nesting; art that is off comes in as a hidden layer.
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
@@ -192,3 +192,109 @@ fn a_page_fill_that_is_art_stays() {
|
||||
assert_eq!(names(&d), ["Shapes", "Page 1"]);
|
||||
assert_eq!(path_count(&d), 3);
|
||||
}
|
||||
|
||||
/// A page drawing a red, a blue and a green square in groups `defs` (as `/MC0`–`/MC2`), the
|
||||
/// catalog listing them with default configuration `config`, plus `more` objects; in `config` and
|
||||
/// `more`, `{0}`–`{2}` stand for the groups.
|
||||
fn three_groups(defs: [&str; 3], more: &[&str], config: &str) -> Vec<u8> {
|
||||
let refs = [0, 1, 2].map(|i| format!("{} 0 R", first_extra(1) + i));
|
||||
let fill = |s: &str| (0..3).fold(s.to_string(), |s, i| s.replace(&format!("{{{i}}}"), &refs[i]));
|
||||
let content = "/OC /MC0 BDC 1 0 0 rg 10 10 20 20 re f EMC /OC /MC1 BDC 0 0 1 rg 40 40 20 20 re f EMC /OC /MC2 BDC 0 1 0 rg 70 70 20 20 re f EMC";
|
||||
let page = PdfPage { resources: fill("/Properties << /MC0 {0} /MC1 {1} /MC2 {2} >>"), ..PdfPage::new(100.0, 100.0, content) };
|
||||
let catalog = format!("/OCProperties << /OCGs [{}] /D << {} >> >> ", refs.join(" "), fill(config));
|
||||
let more: Vec<String> = more.iter().map(|m| fill(m)).collect();
|
||||
let objs: Vec<&str> = defs.iter().copied().chain(more.iter().map(String::as_str)).collect();
|
||||
pdf_with_catalog(&[page], &objs, &catalog, None)
|
||||
}
|
||||
|
||||
fn ocg(name: &str, usage: &str) -> String {
|
||||
format!("<< /Type /OCG /Name ({name}) {usage} >>")
|
||||
}
|
||||
|
||||
fn visibility(layers: &[std::sync::Arc<Node>]) -> Vec<bool> {
|
||||
layers.iter().map(|l| l.visible).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_state_that_is_off_hides_the_layer_with_its_art() {
|
||||
let off = ocg("Off", "/Usage << /View << /ViewState /OFF >> >>");
|
||||
// With an /AS entry for viewing, the view state wins over the default configuration.
|
||||
let on = ocg("On", "/Usage << /View << /ViewState /ON >> >>");
|
||||
let bytes = three_groups([&ocg("Plain", ""), &off, &on], &[], "/OFF [{2}] /AS [<< /Event /View /Category [/View] /OCGs [{1} {2}] >>]");
|
||||
let d = import(&bytes).unwrap();
|
||||
assert_eq!(names(&d), ["Plain", "Off", "On"]);
|
||||
assert_eq!(visibility(&d.layers), [true, false, true]);
|
||||
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
|
||||
assert_eq!(colors(&d.layers[2]), [Color::rgb(0.0, 1.0, 0.0)], "the art the default configuration hides is read");
|
||||
// Without one, a view state that is off still hides the layer.
|
||||
let d = import(&three_groups([&ocg("Plain", ""), &off, &ocg("Other", "")], &[], "")).unwrap();
|
||||
assert_eq!(visibility(&d.layers), [true, false, true]);
|
||||
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_groups_become_sublayers_and_hidden_ones_keep_their_art() {
|
||||
// The order as an object of its own (as Illustrator writes it): "Top" holds "Sub", which is
|
||||
// off; a label's groups stay at the level of the label.
|
||||
let order = first_extra(1) + 3;
|
||||
let bytes = three_groups(
|
||||
[&ocg("Top", ""), &ocg("Sub", ""), &ocg("Other", "")],
|
||||
&["[{0} [{1}] [(Label) {2}]]"],
|
||||
&format!("/OFF [{{1}}] /Order {order} 0 R"),
|
||||
);
|
||||
let d = import(&bytes).unwrap();
|
||||
assert_eq!(names(&d), ["Top", "Other"]);
|
||||
assert_eq!(visibility(&d.layers), [true, true]);
|
||||
let top = d.layers[0].children().unwrap();
|
||||
assert_eq!(top.len(), 2, "its art, then its sublayer");
|
||||
assert_eq!(top[0].appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let sub = &top[1];
|
||||
assert!(matches!(sub.kind, NodeKind::Layer { .. }) && sub.name.as_deref() == Some("Sub"));
|
||||
assert!(!sub.visible, "the sublayer that is off is hidden");
|
||||
assert_eq!(colors(sub), [Color::rgb(0.0, 0.0, 1.0)], "with its art");
|
||||
assert_eq!(d.layer_color(sub.id), d.layer_color(d.layers[0].id), "a sublayer takes its layer's colour");
|
||||
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 1.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sublayer_that_paints_first_brings_its_parent_layer() {
|
||||
// "Sub" paints first; "Top", its parent, paints its own art last.
|
||||
let bytes = three_groups([&ocg("Sub", ""), &ocg("Other", ""), &ocg("Top", "")], &[], "/Order [{2} [{0}] {1}]");
|
||||
let d = import(&bytes).unwrap();
|
||||
assert_eq!(names(&d), ["Top", "Other"]);
|
||||
let top = d.layers[0].children().unwrap();
|
||||
assert_eq!(top.len(), 2);
|
||||
assert_eq!(top[0].name.as_deref(), Some("Sub"));
|
||||
assert_eq!(colors(&top[0]), [Color::rgb(1.0, 0.0, 0.0)]);
|
||||
assert_eq!(top[1].appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(0.0, 1.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_layer_orders_stay_bounded() {
|
||||
// A group listed twice, under itself, and an order nested far deeper than layers go.
|
||||
let deep = format!("{}{{2}}{}", "[".repeat(100), "]".repeat(100));
|
||||
let order = format!("[{{0}} [{{0}} {{1}}] {{1}} [{{0}}] {deep}]");
|
||||
let d = import(&three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], &format!("/Order {order}"))).unwrap();
|
||||
assert_eq!(names(&d), ["A", "C"], "C, listed too deep, stays a top-level layer");
|
||||
assert_eq!(d.layers[0].children().unwrap()[1].name.as_deref(), Some("B"));
|
||||
let mut count = 0;
|
||||
d.walk(|n| count += usize::from(matches!(n.kind, NodeKind::Layer { .. })));
|
||||
assert_eq!(count, 3, "each group is one layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thousands_of_layers_hidden_ones_included_all_come_in() {
|
||||
const N: usize = 3000;
|
||||
let first = first_extra(1);
|
||||
let objs: Vec<String> = (0..N).map(|i| ocg(&format!("L{i}"), "")).collect();
|
||||
let refs: Vec<String> = (0..N).map(|i| format!("{} 0 R", first + i)).collect();
|
||||
let props: String = refs.iter().enumerate().map(|(i, r)| format!("/M{i} {r} ")).collect();
|
||||
let content: String = (0..N).map(|i| format!("/OC /M{i} BDC {} {} 1 1 re f EMC ", i % 100, i / 100)).collect();
|
||||
let off: Vec<&str> = refs.iter().step_by(2).map(String::as_str).collect();
|
||||
let catalog = format!("/OCProperties << /OCGs [{}] /D << /OFF [{}] >> >> ", refs.join(" "), off.join(" "));
|
||||
let page = PdfPage { resources: format!("/Properties << {props}>>"), ..PdfPage::new(100.0, 100.0, &content) };
|
||||
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||||
let d = import(&pdf_with_catalog(&[page], &objs, &catalog, None)).unwrap();
|
||||
assert_eq!(d.layers.len(), N);
|
||||
assert!(d.layers.iter().enumerate().all(|(i, l)| l.visible == (i % 2 == 1) && l.children().is_some_and(|c| c.len() == 1)));
|
||||
}
|
||||
+6
-3
@@ -373,9 +373,12 @@ their stop opacity and stop where the shading doesn't extend. Text becomes point
|
||||
the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font, and
|
||||
every export that draws that type — PDF, EPS, EMF/WMF, raster images, SVG with outlined or embedded fonts — says in
|
||||
its `warnings` that it wrote the fallback font) — `textAs: "outlines"` keeps the glyph outlines the file draws instead
|
||||
(its embedded fonts, installed or not). Optional content groups (the layers of PDF and PDF-compatible `.ai`
|
||||
files) become layers with their name, visibility, print state and lock, art that is off coming in as a hidden layer;
|
||||
art outside them goes to a layer per page (except the opaque white page a `.ai` paints under its layers, which isn't art). `layers: false` gives one layer per page of only what shows:
|
||||
(its embedded fonts, installed or not). Strokes stay live strokes (width, cap, join, miter limit, dash and paint), and
|
||||
an object written as a fill and then a stroke of the same outline is one path with both. Optional content groups (the
|
||||
layers of PDF and PDF-compatible `.ai` files) become layers with their name, visibility (the default configuration's,
|
||||
or a view state that is off), print state and lock, nested as sublayers the way the file's layer order nests them; art
|
||||
that is off comes in as a hidden layer. Art outside them goes to a layer per page (except the opaque white page a `.ai`
|
||||
paints under its layers, which isn't art). `layers: false` gives one layer per page of only what shows:
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/map.pdf","textAs":"outlines","layers":false}}}
|
||||
|
||||
Reference in new issue
Block a user