mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
Fix #550: an SVG of chosen artboards holds only their art
Exporting one artboard of many to SVG wrote the whole document's art into that artboard's file, the other artboards' art outside its viewBox: an editor opening it showed every artboard's art. A file for an artboard named in the export (Use Artboards with a range or All, Export for Screens) now holds the objects over that artboard only, at any depth of layers, as DXF's crop leaves them; objects reaching into it stay whole and a clipping layer keeps all of its objects. An SVG with no artboard named (Save As) still holds the whole document. Part of #546: Export As with a range writes the chosen artboard alone in SVG, DXF, PDF and PNG, each file with that artboard's art (tested from the dialogs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
af7a5239be
commit
b832599a47
+1
-1
File diff suppressed because one or more lines are too long
@@ -87,6 +87,26 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
|
||||
/// out, at any depth of layers and sublayers, and objects that reach it stay whole (the file's
|
||||
/// bounds clip them), as DXF's crop leaves them. A clipping layer keeps every object, its clipping
|
||||
/// path among them. An artboard's file then holds its own art, not a whole document of artboards.
|
||||
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document {
|
||||
fn kept(n: &Arc<Node>, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
|
||||
if matches!(n.kind, NodeKind::Layer { clip: false, .. }) {
|
||||
let mut layer = Node::clone(n);
|
||||
let children = layer.children_mut()?;
|
||||
*children = children.iter().filter_map(|c| kept(c, rect)).collect();
|
||||
return Some(Arc::new(layer));
|
||||
}
|
||||
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
|
||||
n.visual_bounds().is_none_or(over).then(|| n.clone())
|
||||
}
|
||||
let mut d = doc.clone();
|
||||
d.layers = doc.layers.iter().filter_map(|l| kept(l, rect)).collect();
|
||||
d
|
||||
}
|
||||
|
||||
/// `p` without its artboard choice, also inside its SVG options (for documents made of one
|
||||
/// synthetic artboard, and for callers that pick the artboard themselves).
|
||||
pub(super) fn without_artboards(p: &Value) -> Value {
|
||||
|
||||
@@ -19,7 +19,7 @@ pub const OPTIONS: &[FormatOption] = &[
|
||||
name: "useArtboards",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each)",
|
||||
description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each; a named artboard's file holds only the art over it)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "styling",
|
||||
@@ -125,8 +125,10 @@ pub fn options_map(p: &Value) -> Result<Map<String, Value>, String> {
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers.
|
||||
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>), String> {
|
||||
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers,
|
||||
/// and whether `p` named those artboards (else the first stands for the whole document, as Save
|
||||
/// writes it).
|
||||
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>, bool), String> {
|
||||
let mut m = options_map(p)?;
|
||||
// Fonts: unless chosen, Document Setup → Type → Export decides (appearance = outlines).
|
||||
if !m.contains_key("outlineText") && doc.setup.export_text == vectorcraft_doc::ExportText::Appearance {
|
||||
@@ -138,21 +140,24 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>)
|
||||
opts.check().map_err(|e| format!("SVG options: {e}"))?;
|
||||
let n = doc.artboards.len();
|
||||
if n == 0 || boards.use_artboards == Some(false) {
|
||||
return Ok((opts, vec![None]));
|
||||
return Ok((opts, vec![None], false));
|
||||
}
|
||||
let picked = boards.pick.resolve(n)?.unwrap_or_else(|| vec![0]);
|
||||
Ok((opts, picked.into_iter().map(Some).collect()))
|
||||
let named = boards.pick.resolve(n)?;
|
||||
let chosen = named.is_some();
|
||||
Ok((opts, named.unwrap_or_else(|| vec![0]).into_iter().map(Some).collect(), chosen))
|
||||
}
|
||||
|
||||
/// Encode `doc` as SVG (or gzipped, SVGZ): one file per chosen artboard, with the images they
|
||||
/// link to and the writer's warnings.
|
||||
pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Encoded, String> {
|
||||
let (opts, boards) = plan(doc, p)?;
|
||||
let (opts, boards, chosen) = plan(doc, p)?;
|
||||
// Preserve editing embeds the native document (once, shared by every file).
|
||||
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
|
||||
let mut enc = Encoded::default();
|
||||
for artboard in boards {
|
||||
let mut out = vectorcraft_svg::export_full(doc, &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
|
||||
// A chosen artboard's file holds the art over it (#550).
|
||||
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
|
||||
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
|
||||
let bytes = out.take_bytes();
|
||||
for l in out.linked {
|
||||
if !enc.linked.iter().any(|e| e.name == l.name) {
|
||||
|
||||
@@ -49,3 +49,28 @@ fn profile_and_embedded_fonts_reach_the_writer() {
|
||||
assert!(s.execute("document.serialize", &json!({"format": "svg", "svg": {"profile": "svg2"}})).is_err(), "unknown profiles are refused");
|
||||
assert!(s.execute("document.serialize", &json!({"format": "svg", "encoding": "ebcdic"})).is_err(), "unknown encodings are refused");
|
||||
}
|
||||
|
||||
/// #550: an SVG of chosen artboards holds the art over each, not every artboard's art; with no
|
||||
/// artboard named (as Save writes it) the whole document's art is there.
|
||||
#[test]
|
||||
fn a_chosen_artboard_s_svg_holds_only_its_own_art() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3})).unwrap();
|
||||
let x = |s: &Session, b: usize| s.doc().unwrap().doc.artboards[b].rect.x0;
|
||||
let (first, third) = (x(&s, 0), x(&s, 2));
|
||||
s.execute("shape.rectangle", &json!({"x": first + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": third + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
// A rectangle has straight sides only; the ellipse is curves.
|
||||
let shapes = |svg: &str| (svg.matches("<path").count() + svg.matches("<rect").count(), svg.contains(" C") || svg.contains("<ellipse"));
|
||||
let text = |v: &serde_json::Value| v["text"].as_str().unwrap().to_string();
|
||||
let third_only = text(&s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "3"})).unwrap());
|
||||
assert_eq!(shapes(&third_only), (1, true), "{third_only}");
|
||||
let all = s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "all"})).unwrap();
|
||||
let files: Vec<String> = all["files"].as_array().unwrap().iter().map(text).collect();
|
||||
assert_eq!(files.iter().map(|f| shapes(f)).collect::<Vec<_>>(), [(1, false), (0, false), (1, true)]);
|
||||
// Export for Screens names one artboard at a time the same way.
|
||||
let screen = text(&s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap());
|
||||
assert_eq!(shapes(&screen), (1, false));
|
||||
let whole = text(&s.execute("document.serialize", &json!({"format": "svg"})).unwrap());
|
||||
assert_eq!(shapes(&whole).0, 2, "nothing named: the whole document's art");
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ mod tests {
|
||||
let f = &mut app.ui.dialog.as_mut().unwrap().fields;
|
||||
f.insert("decimals".into(), json!(1));
|
||||
f.insert("allArtboards".into(), json!(false));
|
||||
f.insert("range".into(), json!("2"));
|
||||
f.insert("range".into(), json!("1"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let svg = text(&written, 0);
|
||||
|
||||
@@ -240,3 +240,29 @@ fn pdf_artboards_continue_in_the_save_pdf_dialog() {
|
||||
let pdf = vectorcraft_pdf::import(&written.borrow()[0].1).unwrap();
|
||||
assert_eq!(pdf.artboards.len(), 2, "one page per artboard in the range");
|
||||
}
|
||||
|
||||
/// #546, #550: Export As with Use Artboards and a range writes the chosen artboard alone in every
|
||||
/// format, each file holding that artboard's art (the ellipse is on artboard 1).
|
||||
#[test]
|
||||
fn export_as_writes_only_the_chosen_artboard_in_every_format() {
|
||||
for (format, ellipse) in [("svg", "<path"), ("dxf", "HATCH"), ("pdf", ""), ("png", "")] {
|
||||
for (range, on) in [("1", true), ("3", false)] {
|
||||
let (mut app, written) = app(3);
|
||||
app.run("file.exportAs", json!({"format": format})).unwrap();
|
||||
set(&mut app, "useArtboards", json!(true));
|
||||
set(&mut app, "all", json!(false));
|
||||
set(&mut app, "range", json!(range));
|
||||
confirm(&mut app).unwrap();
|
||||
// The format's own options, then the file.
|
||||
while app.ui.dialog.is_some() {
|
||||
frame(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
}
|
||||
assert_eq!(names(&written), [format!("/out/Untitled-1.{format}")], "{format} {range}");
|
||||
if !ellipse.is_empty() {
|
||||
let text = String::from_utf8_lossy(&written.borrow()[0].1).into_owned();
|
||||
assert_eq!(text.contains(ellipse), on, "{format} {range}: {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -251,7 +251,7 @@ colour group (one undo step).
|
||||
SVG Options: `export` to SVG takes them in `options`, flat or as `{"svg": {…}}`: `styling` (`presentation`,
|
||||
`style`, `entities`, `css`), `outlineText`, `images` (`embed`, or `link`: embedded images are written next to the
|
||||
SVG, or returned as `linked`), `objectIds` (`layerNames`, `minimal`, `unique`), `decimals` (1–7), `minify`,
|
||||
`responsive`, `useArtboards`, `range: "all"` (one SVG per artboard, listed in `files`), `preserveEditing` (the SVG
|
||||
`responsive`, `useArtboards`, `range: "all"` (one SVG per artboard, listed in `files`, each holding only the art over its artboard), `preserveEditing` (the SVG
|
||||
reopens as the full document), `metadata` and `fewerTspans` (one `<tspan>` per line of type). Unknown keys inside `svg` are rejected; `run_command document.formats`
|
||||
lists every option with its default. `encoding` is `utf8`, `utf16` (big-endian after a byte order mark) or `latin1`
|
||||
(ISO 8859-1, other characters as `&#x…;` references); `document.serialize` still answers `text`, plus `dataBase64`
|
||||
|
||||
Reference in New Issue
Block a user