From bc17c1e6351a0c2993a5f3823ceffe201e70fe32 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:09:06 +0700 Subject: [PATCH] =?UTF-8?q?M4.18:=20SVG=20import=20units=20=E2=80=94=20abs?= =?UTF-8?q?olute=20lengths=20keep=20their=20physical=20size,=20px=20map=20?= =?UTF-8?q?1:1=20to=20pt,=20document=20units=20follow=20the=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pixel (and a unitless user unit) is a point and absolute lengths convert at 72 pt per inch (`font-size="12pt"` is 12 pt, `1in` is 72 pt); a root size in absolute units keeps the physical size main already gave it (`210mm` opens as a 595.28 pt artboard, user units being CSS pixels of it). The document's units follow the unit of the root width (pixels when it has none). Text font sizes honour their units (em/ex/% relative to the parent size, CSS size keywords), as do text positions, letter spacing, stroke widths and dashes. Co-Authored-By: Claude Opus 5.5 --- crates/svg/src/import.rs | 169 ++++++++++++++++++++++--------- crates/svg/src/lib.rs | 8 +- crates/svg/tests/import_units.rs | 135 ++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 53 deletions(-) create mode 100644 crates/svg/tests/import_units.rs diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 83ed4d8c..3a210df7 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -8,7 +8,7 @@ use usvg::roxmltree; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{ Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind, - StrokeLayer, TextKind, TextObject, TextRun, + StrokeLayer, TextKind, TextObject, TextRun, Unit, }; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Vec2}; @@ -17,13 +17,13 @@ use crate::SvgError; pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { let (svg, links) = link_ids(svg); let svg = svg.as_ref(); - let opt = usvg::Options::default(); + let units = RootUnits::of(svg); + let opt = usvg::Options { dpi: units.dpi as f32, font_size: DEFAULT_FONT_SIZE as f32, ..usvg::Options::default() }; let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError::Parse(e.to_string()))?; let size = tree.size(); - // 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 (kx, ky) = units.k; + let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky); + doc.units = units.unit; let mut im = Importer { doc, warnings: Vec::new(), mask_flags: mask_flags(svg), links }; // usvg wraps everything in an id-less group carrying the viewBox transform when needed. @@ -62,28 +62,55 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { } } - text_fallback(&mut im, svg, size.width() as f64, size.height() as f64, (kx, ky), layer_mode); + text_fallback(&mut im, svg, size.width() as f64, size.height() as f64, &units, 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")) +/// SVG's initial `font-size` (`medium`) in user units. +const DEFAULT_FONT_SIZE: f64 = 12.0; + +/// How lengths become points, from the root ``'s `width` / `height`. +/// +/// A pixel (and a unitless user unit) is a point, as we export, and absolute lengths keep their +/// physical size at 72 pt per inch: `font-size="12pt"` is 12 pt, `1in` is 72 pt. A root size in +/// absolute units (`width="210mm"`) is that physical size, and its user units are CSS pixels of it +/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions. +struct RootUnits { + /// Pixels per inch usvg (and the text pass) convert absolute lengths at. + dpi: f64, + /// Points per user unit of the root along x and y. + k: (f64, f64), + /// Document units: those of the root `width` (pixels when it has none). + unit: Unit, } +impl RootUnits { + fn of(svg: &str) -> Self { + let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }); + let root = xml.as_ref().ok().map(|x| x.root_element()); + let unit = |name: &str| root.and_then(|r| r.attribute(name)).and_then(|v| svgtypes::Length::from_str(v.trim()).ok()).map(|l| l.unit); + use svgtypes::LengthUnit as U; + let physical = |u: Option| matches!(u, Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc)); + let (w, h) = (unit("width"), unit("height")); + let k = |u| if physical(u) { PT_PER_IN / CSS_PX_PER_IN } else { 1.0 }; + let dpi = if physical(w) || physical(h) { CSS_PX_PER_IN } else { PT_PER_IN }; + let unit = match w { + Some(U::Mm) => Unit::Millimeters, + Some(U::Cm) => Unit::Centimeters, + Some(U::In) => Unit::Inches, + Some(U::Pt) => Unit::Points, + Some(U::Pc) => Unit::Picas, + _ => Unit::Pixels, + }; + Self { dpi, k: (k(w), k(h)), unit } + } +} + +/// Points per inch. +const PT_PER_IN: f64 = 72.0; +/// CSS pixels per inch. +const CSS_PX_PER_IN: f64 = 96.0; + struct Importer { doc: Document, warnings: Vec, @@ -544,6 +571,8 @@ type XNode<'a, 'i> = roxmltree::Node<'a, 'i>; struct TextCtx { /// `.class` → declarations from `