From ce5fdfd3735a8ac3f174ca3696c11fcf583b40bf Mon Sep 17 00:00:00 2001 From: storybored379 Date: Sun, 4 Oct 2026 01:14:20 -0700 Subject: [PATCH] SVG import: root sizes in mm/cm/in/pt open at their physical size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VectorCraft writes and reads SVG at 1 user unit (CSS px) = 1 pt, but a root width/height in absolute units was taken in CSS pixels (96 per inch). An A4 document from Inkscape (width="210mm" height="297mm" viewBox="0 0 210 297") opened on a 793.7 × 1122.5 pt artboard, i.e. 280 × 396 mm, with all art 4/3 too large, and its PDF export came out on that page size; width="8.5in" gave an 11.3 inch artboard and width="300pt" a 400 pt one. When the root width or height has an absolute unit (in, cm, mm, pt, pc) the import now scales by 72/96 along that axis, so the artboard and the art keep their physical size (595.3 × 841.9 pt for A4); geometry, stroke widths and text follow through the same transform. Unitless, px, % and viewBox-only files are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/svg/src/import.rs | 31 ++++++++++++++++++++++++++----- crates/svg/src/lib.rs | 3 +++ crates/svg/tests/roundtrip.rs | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 8bc4dcee..fcf40439 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -18,12 +18,15 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { let opt = usvg::Options::default(); let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError::Parse(e.to_string()))?; let size = tree.size(); - let doc = Document::new(size.width() as f64, size.height() as f64); + // Document points from CSS pixels: 1 px = 1 pt (as we export), except that a root size in + // absolute units is that physical size (usvg resolves it to 96 px per inch, i.e. 4/3 px per pt). + let (kx, ky) = physical_scale(svg); + let doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky); let mut im = Importer { doc, warnings: Vec::new() }; // usvg wraps everything in an id-less group carrying the viewBox transform when needed. let mut top = tree.root(); - let mut base = aff(top.transform()); + let mut base = Affine::scale_non_uniform(kx, ky) * aff(top.transform()); if let [usvg::Node::Group(g)] = top.children() && g.id().is_empty() && is_plain(g) @@ -57,10 +60,28 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { } } - text_fallback(&mut im, svg, size.width() as f64, size.height() as f64, layer_mode); + text_fallback(&mut im, svg, size.width() as f64, size.height() as f64, (kx, ky), layer_mode); Ok((im.doc, im.warnings)) } +/// Points per CSS pixel along x and y for the root ``'s `width` / `height`: 0.75 (72 / 96) for +/// absolute units (in, cm, mm, pt, pc), so that `width="210mm"` is 595.3 pt wide; 1 otherwise +/// (unitless, px, %, em, or absent). +fn physical_scale(svg: &str) -> (f64, f64) { + let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { + return (1.0, 1.0); + }; + let root = xml.root_element(); + let k = |name: &str| { + use svgtypes::LengthUnit as U; + match root.attribute(name).and_then(|v| svgtypes::Length::from_str(v.trim()).ok()).map(|l| l.unit) { + Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc) => 0.75, + _ => 1.0, + } + }; + (k("width"), k("height")) +} + struct Importer { doc: Document, warnings: Vec, @@ -643,11 +664,11 @@ fn collect_runs(ctx: &TextCtx, el: XNode, rb: &mut RunBuilder) { } } -fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, layer_mode: bool) { +fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, (kx, ky): (f64, f64), layer_mode: bool) { let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { return }; let ctx = TextCtx::new(&xml); let root = xml.root_element(); - let vb = view_box_transform(root, w, h); + let vb = Affine::scale_non_uniform(kx, ky) * view_box_transform(root, w, h); const SKIP: [&str; 8] = ["defs", "clipPath", "mask", "pattern", "symbol", "marker", "title", "desc"]; for t in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "text") { if t.ancestors().skip(1).any(|a| a.is_element() && SKIP.contains(&a.tag_name().name())) { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 932ce101..a5917dea 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -19,6 +19,9 @@ //! //! ## Import approximations //! +//! * One CSS pixel (user unit) is one point, as on export; a root `width`/`height` in absolute units +//! (`in`, `cm`, `mm`, `pt`, `pc`) keeps its physical size (72 pt per inch), so a 210 mm SVG opens on +//! a 210 mm artboard. //! * Masks and filters are ignored (reported as warnings); nested clip paths use the outer clip only. //! * usvg only keeps `` when fonts are loaded; we don't load a font database (too expensive and //! unavailable on wasm), so `` elements are read directly from the XML as live point-type diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index f3159fe0..fabc5d15 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -623,6 +623,39 @@ fn import_invalid_is_error() { assert!(import(" + "#, + ) + .unwrap(); + let mm = 72.0 / 25.4; + assert!(close_rect(a4.artboards[0].rect, Rect::new(0.0, 0.0, 210.0 * mm, 297.0 * mm), 0.01), "{:?}", a4.artboards[0].rect); + let r = art(&a4)[0]; + assert!(close_rect(r.geometric_bounds().unwrap(), Rect::new(10.0 * mm, 20.0 * mm, 110.0 * mm, 70.0 * mm), 0.01), "{:?}", r.geometric_bounds()); + assert!((r.appearance.stroke_width() - 2.0 * mm).abs() < 0.01, "{}", r.appearance.stroke_width()); + // Inches without a viewBox: user units are CSS px (96 per inch). + let letter = import( + r#" + Hi"#, + ) + .unwrap(); + assert!(close_rect(letter.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0), 0.01), "{:?}", letter.artboards[0].rect); + let a = art(&letter); + assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(72.0, 72.0, 144.0, 108.0), 0.01), "{:?}", a[0].geometric_bounds()); + let NodeKind::Text(t) = &a[1].kind else { panic!() }; + assert!(t.xf.translation().to_point().distance(Point::new(72.0, 225.0)) < 0.01, "{:?}", t.xf); + // Pixels and unitless sizes stay 1 px = 1 pt. + for size in [r#"width="400px" height="300""#, r#"viewBox="0 0 400 300""#] { + let d = import(&format!(r#""#)).unwrap(); + assert!(close_rect(d.artboards[0].rect, Rect::new(0.0, 0.0, 400.0, 300.0), 1e-6), "{size}"); + } +} + #[test] fn full_document_roundtrip_is_stable() { // export → import → export produces identical SVG.