diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index d7762b50..1d8cbfc3 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, @@ -656,11 +677,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 737dae84..a5937723 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 91392174..eb58a190 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -705,6 +705,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.