Merge pull request #11 from storybored379/pr/svg-import-physical-units

SVG import: root sizes in mm/cm/in/pt open at their physical size
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-04 10:16:45 -04:00
commit ead8f72208
3 files changed
+62 -5

No files matched your search

+26 -5
View File
@@ -18,12 +18,15 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), 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<String>), 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 `<svg>`'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<String>,
@@ -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())) {
+3
View File
@@ -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 `<text>` when fonts are loaded; we don't load a font database (too expensive and
//! unavailable on wasm), so `<text>` elements are read directly from the XML as live point-type
+33
View File
@@ -705,6 +705,39 @@ fn import_invalid_is_error() {
assert!(import("<svg xmlns=\"http://www.w3.org/2000/svg\"><rect").is_err());
}
#[test]
fn import_absolute_units_keep_their_physical_size() {
// An A4 drawing in millimetres (the usual Inkscape document) is 595.3 × 841.9 pt, not its CSS
// pixel size (793.7 × 1122.5, i.e. 280 × 396 mm): 1 px = 1 pt as we export, so absolute units go
// through 72 pt per inch.
let a4 = import(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="210mm" height="297mm" viewBox="0 0 210 297">
<rect x="10" y="20" width="100" height="50" fill="red" stroke="blue" stroke-width="2"/></svg>"#,
)
.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#"<svg xmlns="http://www.w3.org/2000/svg" width="8.5in" height="11in"><rect x="96" y="96" width="96" height="48"/>
<text x="96" y="300" font-size="24">Hi</text></svg>"#,
)
.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#"<svg xmlns="http://www.w3.org/2000/svg" {size}><rect width="10" height="10"/></svg>"#)).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.