mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Fix #420: SVG import keeps clipped layers as layers with a clipping mask
A top-level <g id> becomes a layer only when it was plain, so an Inkscape layer (or our own export of a layer with a clipping mask) carrying a clip-path came in as a clip group, and a file whose layers were all clipped to one frame opened as one "Layer 1" of clip groups with the layer names lost. A top-level group that does nothing but clip its art (no mask, filter, opacity or blend) is now a layer too: its clip path becomes the layer's clipping mask (the clipping path first, as Make Clipping Mask in the Layers panel leaves it), a clip path clipped in turn nesting a clip group inside the layer as before. The clip building is shared with clip groups. Top-level text that joins a clipping layer (before or after it) isn't in its clip: the mask then becomes a clip group inside the layer and the text sits beside it. A clip path with no shape leaves the layer empty. Tests: the issue's Inkscape layers (names kept, each a clipping layer, drawn as resvg draws the SVG), a nested clip on a layer, the export -> import round trip of clipping layers, and text joining clipping layers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8b2be144cc
commit
55543da0cb
3 files changed
+150
-35
No files matched your search
+2
-2
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394) skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -147,7 +147,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 (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); 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).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers; 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; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398).
|
||||
- **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.
|
||||
|
||||
+74
-31
@@ -83,8 +83,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
}
|
||||
|
||||
// Top-level `<g id>` elements become layers (as in the reference app); otherwise all art goes
|
||||
// into "Layer 1". Top-level text joins the layer below it (the first layer if none is).
|
||||
let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && is_plain(g) && im.text_slot(g).is_none());
|
||||
// into "Layer 1". A clip path on one (as on an Inkscape layer, or our clipping layers) makes it
|
||||
// a clipping layer. Top-level text joins the layer below it (the first layer if none is).
|
||||
let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && only_clips(g) && im.text_slot(g).is_none());
|
||||
let is_text = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if im.text_slot(g).is_some());
|
||||
let layer_mode = top.children().iter().any(|c| is_layer(&im, c)) && top.children().iter().all(|c| is_layer(&im, c) || is_text(&im, c));
|
||||
if layer_mode {
|
||||
@@ -93,22 +94,35 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
for c in top.children() {
|
||||
let usvg::Node::Group(g) = c else { continue };
|
||||
if !is_layer(&im, c) {
|
||||
let n = im.node(c, base).map(Arc::new);
|
||||
match im.doc.layers.last_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
|
||||
Some(ch) => ch.extend(n),
|
||||
let n: Vec<_> = im.node(c, base).map(Arc::new).into_iter().collect();
|
||||
match im.doc.layers.pop() {
|
||||
Some(mut l) => {
|
||||
im.add_unclipped(Arc::make_mut(&mut l), usize::MAX, n);
|
||||
im.doc.layers.push(l);
|
||||
}
|
||||
None => loose.extend(n),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut children = std::mem::take(&mut loose);
|
||||
children.extend(im.children(g, base * aff(g.transform())));
|
||||
let ts = base * aff(g.transform());
|
||||
let name = im.name_of(g.id()).to_string();
|
||||
let mut art = im.children(g, ts);
|
||||
// A clip path with no shape shows nothing of the layer: it stays, empty.
|
||||
let clip = match g.clip_path() {
|
||||
Some(cp) => {
|
||||
art = im.clipped(cp, ts, art, &format!("layer '{name}'")).unwrap_or_default();
|
||||
!art.is_empty()
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
let id = im.doc.alloc_id();
|
||||
let preset = (im.doc.layers.len() % vectorcraft_doc::LAYER_COLORS.len()) as u8;
|
||||
let name = im.name_of(g.id()).to_string();
|
||||
let mut l = Node::layer(id, &name, LayerColor::Preset(preset));
|
||||
l.set_clips(clip);
|
||||
if let Some(ch) = l.children_mut() {
|
||||
*ch = children;
|
||||
*ch = art;
|
||||
}
|
||||
im.add_unclipped(&mut l, 0, std::mem::take(&mut loose));
|
||||
l.visible = !im.hidden.contains(g.id());
|
||||
im.doc.layers.push(Arc::new(l));
|
||||
}
|
||||
@@ -472,7 +486,12 @@ fn aff(t: usvg::Transform) -> Affine {
|
||||
}
|
||||
|
||||
fn is_plain(g: &usvg::Group) -> bool {
|
||||
g.clip_path().is_none() && g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal
|
||||
g.clip_path().is_none() && only_clips(g)
|
||||
}
|
||||
|
||||
/// Does group `g` leave its art as it is, but for a clip path (no mask, filter, opacity or blend)?
|
||||
fn only_clips(g: &usvg::Group) -> bool {
|
||||
g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal
|
||||
}
|
||||
|
||||
fn blend(b: usvg::BlendMode) -> BlendMode {
|
||||
@@ -758,27 +777,8 @@ impl Importer {
|
||||
inner.appearance.effects = effects.clone();
|
||||
children = vec![Arc::new(inner)];
|
||||
}
|
||||
// A clip path clipped in turn (`<clipPath clip-path>`): one clip group in another, so
|
||||
// the clips intersect.
|
||||
let mut clips = vec![self.clip_node(cp, ts)?];
|
||||
let mut next = cp.clip_path();
|
||||
while let Some(c) = next {
|
||||
if clips.len() > MAX_CLIP_NEST {
|
||||
self.warn(format!("clip paths nested more than {MAX_CLIP_NEST} deep on {label}: the deeper ones are ignored"));
|
||||
break;
|
||||
}
|
||||
clips.push(self.clip_node(c, ts)?);
|
||||
next = c.clip_path();
|
||||
}
|
||||
let outer = clips.pop()?;
|
||||
for clip in clips {
|
||||
let mut ch = vec![Arc::new(clip)];
|
||||
ch.extend(children);
|
||||
children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))];
|
||||
}
|
||||
let mut ch = vec![Arc::new(outer)];
|
||||
ch.extend(children);
|
||||
self.named(id, NodeKind::Group { children: ch, clip: true })
|
||||
let children = self.clipped(cp, ts, children, &label)?;
|
||||
self.named(id, NodeKind::Group { children, clip: true })
|
||||
} else {
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
@@ -849,6 +849,49 @@ impl Importer {
|
||||
Some(Box::new(mask))
|
||||
}
|
||||
|
||||
/// `children` clipped by `cp` (on an element whose user space `ts` maps to the document): the
|
||||
/// children of a clip group or clipping layer, its clipping path first. A clip path clipped in
|
||||
/// turn (`<clipPath clip-path>`) nests one clip group in another, so the clips intersect.
|
||||
/// `None` when the clip path has no shape (nothing shows).
|
||||
fn clipped(&mut self, cp: &usvg::ClipPath, ts: Affine, mut children: Vec<Arc<Node>>, label: &str) -> Option<Vec<Arc<Node>>> {
|
||||
let mut clips = vec![self.clip_node(cp, ts)?];
|
||||
let mut next = cp.clip_path();
|
||||
while let Some(c) = next {
|
||||
if clips.len() > MAX_CLIP_NEST {
|
||||
self.warn(format!("clip paths nested more than {MAX_CLIP_NEST} deep on {label}: the deeper ones are ignored"));
|
||||
break;
|
||||
}
|
||||
clips.push(self.clip_node(c, ts)?);
|
||||
next = c.clip_path();
|
||||
}
|
||||
let outer = clips.pop()?;
|
||||
for clip in clips {
|
||||
let mut ch = vec![Arc::new(clip)];
|
||||
ch.extend(children);
|
||||
children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))];
|
||||
}
|
||||
let mut ch = vec![Arc::new(outer)];
|
||||
ch.extend(children);
|
||||
Some(ch)
|
||||
}
|
||||
|
||||
/// Put `art` in layer `l` at `index` (at most its end) unclipped: a clipping mask on the layer
|
||||
/// becomes a clip group in it, holding the art it clipped.
|
||||
fn add_unclipped(&mut self, l: &mut Node, index: usize, art: Vec<Arc<Node>>) {
|
||||
if art.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind {
|
||||
*clip = false;
|
||||
let clipped = std::mem::take(children);
|
||||
children.push(Arc::new(self.named("", NodeKind::Group { children: clipped, clip: true })));
|
||||
}
|
||||
if let Some(ch) = l.children_mut() {
|
||||
let i = index.min(ch.len());
|
||||
ch.splice(i..i, art);
|
||||
}
|
||||
}
|
||||
|
||||
fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
|
||||
let shapes = clip_shapes(cp, ts);
|
||||
if shapes.is_empty() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Symbols export as one `<symbol>` def and a `<use>` per instance (instances the def can't stand
|
||||
//! for get their own art), hidden layers are kept hidden when asked and come back hidden, and the
|
||||
//! editing data notices edits made elsewhere.
|
||||
//! for get their own art), hidden layers are kept hidden when asked and come back hidden, clipped
|
||||
//! layers come in as clipping layers, and the editing data notices edits made elsewhere.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
@@ -205,6 +205,78 @@ fn hidden_layers_are_kept_hidden_when_asked() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each layer's art: clipping layer or not, and the kinds of its children.
|
||||
fn layer_art(d: &Document) -> Vec<(String, bool, Vec<&'static str>)> {
|
||||
d.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.clips(), l.children().unwrap().iter().map(|c| c.kind_label()).collect())).collect()
|
||||
}
|
||||
|
||||
/// Two Inkscape layers clipped to the same window (#420), in pixels so resvg draws them at our scale.
|
||||
const CLIPPED_LAYERS: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="200" height="100" viewBox="0 0 200 100">
|
||||
<defs><clipPath id="window"><rect x="5" y="5" width="190" height="90"/></clipPath></defs>
|
||||
<g id="CUT" inkscape:groupmode="layer" inkscape:label="Cut lines" clip-path="url(#window)"><rect x="0" y="0" width="100" height="100" fill="#f00"/></g>
|
||||
<g id="HATCH" inkscape:groupmode="layer" inkscape:label="HATCH" clip-path="url(#window)"><rect x="100" y="0" width="100" height="100" fill="#00f"/></g>
|
||||
</svg>"##;
|
||||
|
||||
#[test]
|
||||
fn clipped_layers_come_in_as_clipping_layers() {
|
||||
let d = import(CLIPPED_LAYERS).unwrap();
|
||||
assert_eq!(
|
||||
layer_art(&d),
|
||||
[("Cut lines".to_string(), true, vec!["Path", "Path"]), ("HATCH".to_string(), true, vec!["Path", "Path"])],
|
||||
"{:?}",
|
||||
d.layers
|
||||
);
|
||||
for l in &d.layers {
|
||||
let clip = &l.children().unwrap()[0];
|
||||
assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. }) && clip.name.as_deref() == Some("window"));
|
||||
assert_eq!(clip.geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 195.0, 95.0));
|
||||
}
|
||||
// Drawn as the SVG is: each layer cut to the window.
|
||||
assert_similar(&render_artboard(&d), &resvg_render(CLIPPED_LAYERS, 200, 100), 24.0, 0.002);
|
||||
// The issue's file (in points): two clipping layers, the names kept.
|
||||
let pt = CLIPPED_LAYERS.replace("width=\"200\" height=\"100\"", "width=\"200pt\" height=\"100pt\"");
|
||||
let d = import(&pt).unwrap();
|
||||
assert_eq!(d.layers.iter().map(|l| (l.name.as_deref().unwrap(), l.clips())).collect::<Vec<_>>(), [("Cut lines", true), ("HATCH", true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_layers_round_trip() {
|
||||
let mut d = import(CLIPPED_LAYERS).unwrap();
|
||||
// A clip path clipped in turn: the layer's own clip outermost, the other one inside it.
|
||||
let nested = CLIPPED_LAYERS.replace(
|
||||
"<defs>",
|
||||
"<defs><clipPath id=\"left\"><rect x=\"0\" y=\"0\" width=\"60\" height=\"100\"/></clipPath><clipPath id=\"inner\" clip-path=\"url(#left)\"><rect x=\"20\" y=\"20\" width=\"160\" height=\"60\"/></clipPath>",
|
||||
);
|
||||
let nested = nested.replacen("url(#window)", "url(#inner)", 1);
|
||||
let n = import(&nested).unwrap();
|
||||
assert_eq!(layer_art(&n)[0], ("Cut lines".to_string(), true, vec!["Path", "Clip Group"]));
|
||||
assert_similar(&render_artboard(&n), &resvg_render(&nested, 200, 100), 24.0, 0.002);
|
||||
d.layers.push(n.layers[0].clone());
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let back = import(&svg).unwrap();
|
||||
assert_eq!(layer_art(&back), layer_art(&d), "{svg}");
|
||||
assert_similar(&render_artboard(&back), &render_artboard(&d), 24.0, 0.002);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_joining_a_clipping_layer_stays_unclipped() {
|
||||
// Text beside clipped layers isn't in their clip: the layer keeps its clip as a clip group.
|
||||
let svg = CLIPPED_LAYERS
|
||||
.replace("<g id=\"CUT\"", "<text x=\"1\" y=\"99\">Before</text><g id=\"CUT\"")
|
||||
.replace("</svg>", "<text x=\"150\" y=\"99\">After</text></svg>");
|
||||
let d = import(&svg).unwrap();
|
||||
assert_eq!(
|
||||
layer_art(&d),
|
||||
[("Cut lines".to_string(), false, vec!["Type", "Clip Group"]), ("HATCH".to_string(), false, vec!["Clip Group", "Type"])],
|
||||
"{:?}",
|
||||
d.layers
|
||||
);
|
||||
for l in &d.layers {
|
||||
let g = l.children().unwrap().iter().find(|c| c.clips()).unwrap();
|
||||
assert_eq!(g.children().unwrap().iter().map(|c| c.kind_label()).collect::<Vec<_>>(), ["Path", "Path"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_groups_others_use_stay_hidden() {
|
||||
// A display:none group a <use> refers to is a template, not a hidden layer.
|
||||
|
||||
Reference in new issue
Block a user