mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
3 files changed
+62
-5
No files matched your search
@@ -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())) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user