From ef0ab7ba9dcc591b2a64409abae3e2ed91a40620 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 05:35:25 +0200 Subject: [PATCH] SVG import: Inkscape's pages become artboards Inkscape 1.2+ saves a multi-page document as elements in its ; VectorCraft opened it with one artboard the size of the root, leaving the other pages' art outside any. Each page with a size now becomes an artboard at its place (through the viewBox, as the art is), named by its inkscape:label or "Artboard N". Files without pages keep their one artboard. Fixes #1601 --- crates/svg/src/import.rs | 27 +++++++++++++++++++++++++++ crates/svg/tests/import_details.rs | 19 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index eb907d70..c800f871 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -83,6 +83,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec` elements become layers (as in the reference app), named from their // labels (`data-name`…) else their ids; otherwise all art goes into "Layer 1". A clip path on @@ -283,6 +286,30 @@ fn filter_names(filters: &[Arc]) -> String { names.join(", ") } +/// Inkscape's pages (`` in its ``, in user units) as artboards +/// through `user` (user units → document points), named by their labels; `None` without any. +fn inkscape_pages(xml: &roxmltree::Document, user: Affine) -> Option> { + let num = |n: roxmltree::Node, a: &str| n.attribute(a).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + let pages: Vec = xml + .descendants() + .filter(|n| n.is_element() && n.tag_name().name() == "page" && n.tag_name().namespace() == Some(INKSCAPE)) + .filter_map(|p| { + let (w, h) = (num(p, "width")?, num(p, "height")?); + let (x, y) = (num(p, "x").unwrap_or(0.0), num(p, "y").unwrap_or(0.0)); + (w > 0.0 && h > 0.0).then(|| (p, Rect::new(x, y, x + w, y + h))) + }) + .take(1000) + .enumerate() + .map(|(i, (p, r))| vectorcraft_doc::Artboard { + id: i as u32 + 1, + name: p.attribute((INKSCAPE, "label")).filter(|l| !l.trim().is_empty()).map_or_else(|| format!("Artboard {}", i + 1), str::to_string), + rect: user.transform_rect_bbox(r), + show_center_mark: false, + show_cross_hairs: false, + }) + .collect(); + (!pages.is_empty()).then_some(pages) +} /// How many clip paths deep (``) an object's clip is read. const MAX_CLIP_NEST: usize = 16; diff --git a/crates/svg/tests/import_details.rs b/crates/svg/tests/import_details.rs index 467e1a93..a4127bbd 100644 --- a/crates/svg/tests/import_details.rs +++ b/crates/svg/tests/import_details.rs @@ -229,3 +229,22 @@ fn unsupported_filters_are_named_in_the_warning() { assert!(w.iter().any(|w| w == "filter 't' (feTurbulence) on 'r' isn't supported; drawn without it"), "{w:?}"); assert_eq!(art(&d).iter().filter(|n| n.path_data().is_some()).count(), 2, "both drawn"); } + +/// #1601: Inkscape's pages become artboards, at their places (through the viewBox) and with their +/// labels. +#[test] +fn inkscape_pages_become_artboards() { + let svg = r#" + + "#; + let d = import_with(svg, &ImportOptions::default()).unwrap().0; + let b: Vec<(String, Rect)> = d.artboards.iter().map(|a| (a.name.clone(), a.rect)).collect(); + assert_eq!(b, vec![("Front".to_string(), Rect::new(0.0, 0.0, 200.0, 200.0)), ("Artboard 2".to_string(), Rect::new(240.0, 0.0, 360.0, 300.0))]); + // A file without pages keeps its one artboard. + let d = import_with(&svg_doc(), &ImportOptions::default()).unwrap().0; + assert_eq!(d.artboards.len(), 1); +} + +fn svg_doc() -> String { + svg(r#""#) +}