Merge pull request #146 from storytold/fix/issue-131

Fix #131: SVG import keeps non-scaling strokes at their screen width
This commit is contained in:
BFlat 2026-10-05 23:47:51 +07:00 committed by GitHub
commit 69dc539db5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 216 additions and 12 deletions

View File

@ -25,6 +25,7 @@ mod files;
mod fx;
mod spread;
mod text;
mod vector_effect;
pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
let (svg, found) = prepass(svg, opts);
@ -62,6 +63,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
symbols: HashMap::new(),
blends: fx::blends(&xml),
files,
non_scaling: found.non_scaling,
screen: (kx * ky).sqrt(),
};
if im.files.nested_text() {
im.warn("text inside an SVG image isn't imported".into());
@ -191,6 +194,11 @@ struct Importer {
blends: HashMap<String, String>,
/// The files `<image>` elements link to.
files: files::Files,
/// Ids of the shapes whose strokes don't scale ([`vector_effect`]).
non_scaling: HashSet<String>,
/// Points per screen pixel (the root's user unit before its `viewBox`): what a non-scaling
/// stroke's width is measured in.
screen: f64,
}
/// A `<symbol>` made a symbol: its name, its art with no object ids (what each `<use>` has to show
@ -296,6 +304,8 @@ struct Found {
uses: HashMap<String, String>,
/// The files `<image>` elements link to.
files: files::Files,
/// Ids of the shapes whose strokes don't scale ([`vector_effect`]).
non_scaling: HashSet<String>,
warnings: Vec<String>,
}
@ -361,11 +371,12 @@ impl Edits {
/// ([`MADE_UP_ID`]…), recorded with its URL or symbol;
/// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded,
/// to come back hidden; one something links to (a `<use>` template) stays as it is;
/// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]);
/// * linked files are read ([`files`]).
fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
let mut found = Found::default();
// (`:` for prefixed elements such as `<svg:image>`.)
if !["<a", "display", "<use", ":use", "<image", ":image"].iter().any(|t| svg.contains(t)) {
if !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING].iter().any(|t| svg.contains(t)) {
return (svg.into(), found);
}
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
@ -384,8 +395,15 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
found.uses.insert(edits.id(svg, u), sym.to_string());
}
}
if svg.contains("display") {
hidden_objects(svg, &xml, &mut edits, &mut found.hidden);
let (display, non_scaling) = (svg.contains("display"), svg.contains(vector_effect::NON_SCALING));
if display || non_scaling {
let css = css::Styles::new(&xml);
if display {
hidden_objects(svg, &xml, &css, &mut edits, &mut found.hidden);
}
if non_scaling {
vector_effect::find(svg, &xml, &css, &mut edits, &mut found.non_scaling, &mut found.warnings);
}
}
found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings);
(edits.apply(svg), found)
@ -395,8 +413,7 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"];
/// Show the undisplayed objects outside `<defs>` (and the like) for usvg, recording their ids.
fn hidden_objects(svg: &str, xml: &roxmltree::Document, edits: &mut Edits, ids: &mut HashSet<String>) {
let css = css::Styles::new(xml);
fn hidden_objects(svg: &str, xml: &roxmltree::Document, css: &css::Styles, edits: &mut Edits, ids: &mut HashSet<String>) {
let linked: HashSet<&str> = xml.descendants().filter_map(href).filter_map(|h| h.strip_prefix('#')).collect();
let mut todo: Vec<XNode> = xml.root_element().children().filter(XNode::is_element).collect();
while let Some(n) = todo.pop() {
@ -659,7 +676,7 @@ impl Importer {
if !matches!(kids.get(main), Some(usvg::Node::Path(p)) if p.is_visible()) {
return Some(vec![]);
}
let text::PendingText { name, mut obj, servers, paints, .. } = self.slots.texts.get(i)?.clone();
let text::PendingText { name, mut obj, servers, paints, non_scaling, .. } = self.slots.texts.get(i)?.clone();
// usvg resolved the `url(#…)` paints (the paths after the main one) in the element's user
// space; runs paint in text space.
let to_text = obj.xf.inverse();
@ -682,6 +699,10 @@ impl Importer {
}
}
obj.xf = ts * obj.xf;
if non_scaling && !vector_effect::unscale_text(&mut obj, self.screen) {
let label = if name.is_empty() || made_up(&name) { "a text".to_string() } else { format!("text '{name}'") };
self.warn(format!("non-scaling stroke on {label} approximated: the text is stretched or skewed"));
}
Some(vec![Arc::new(self.named(&name, NodeKind::Text(Box::new(obj))))])
}
@ -930,7 +951,9 @@ impl Importer {
}
let mut stroke = None;
if let Some(s) = p.stroke() {
let scale = m.determinant().abs().sqrt();
// A non-scaling stroke is as wide on screen whatever `m` does: the path takes `m`, so a
// stroke of that width in the document is exact.
let scale = if self.non_scaling.contains(p.id()) { self.screen } else { m.determinant().abs().sqrt() };
// The stroke reaches past the path (miters further: a generous margin).
let reach = s.width().get() as f64 * scale * (s.miterlimit().get() as f64).max(1.0);
let paint = self.paint(s.paint(), m, area.map(|a| a.inflate(reach, reach)));
@ -1025,10 +1048,15 @@ impl Importer {
/// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size.
/// Its ids are its own: they mean nothing in the SVG around it.
fn svg_image(&mut self, tree: &usvg::Tree, acc: Affine) -> Option<Node> {
let outer =
(std::mem::take(&mut self.links), std::mem::take(&mut self.hidden), std::mem::take(&mut self.uses), std::mem::take(&mut self.labels));
let outer = (
std::mem::take(&mut self.links),
std::mem::take(&mut self.hidden),
std::mem::take(&mut self.uses),
std::mem::take(&mut self.labels),
std::mem::take(&mut self.non_scaling),
);
let art = self.group_node(tree.root(), acc);
(self.links, self.hidden, self.uses, self.labels) = outer;
(self.links, self.hidden, self.uses, self.labels, self.non_scaling) = outer;
art
}
}

View File

@ -32,7 +32,7 @@ use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
use vectorcraft_text::{FontDb, TextLayout};
use super::css::{Styles, XNode};
use super::{DEFAULT_FONT_SIZE, PT_PER_IN, href};
use super::{DEFAULT_FONT_SIZE, PT_PER_IN, href, vector_effect};
use crate::xml_escape;
const SVG_NS: &str = "http://www.w3.org/2000/svg";
@ -123,6 +123,8 @@ pub(super) struct PendingText {
pub servers: Vec<(Option<usize>, Option<usize>)>,
/// Number of `url(#…)` paints (the placeholder paths after the main one, in order).
pub paints: usize,
/// `vector-effect="non-scaling-stroke"`: the character strokes keep their width on screen.
pub non_scaling: bool,
/// The placeholder rectangle in the element's user space.
marker: Rect,
}
@ -602,7 +604,8 @@ impl Ctx {
let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default();
let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0));
let name = t.attribute("id").unwrap_or("").to_string();
Some((PendingText { name, obj, servers, paints: paints.len(), marker }, paints))
let non_scaling = vector_effect::is_non_scaling(&self.css, t);
Some((PendingText { name, obj, servers, paints: paints.len(), non_scaling, marker }, paints))
}
}

View File

@ -0,0 +1,79 @@
//! `vector-effect="non-scaling-stroke"`: the stroke keeps its width (and dashes) in screen space
//! whatever the transforms above it. usvg knows the property but drops it, so [`find`] records
//! the elements that ask for it before usvg runs. Their transforms are baked into the path, so a
//! document-space stroke of the screen width is exact; type and the art usvg drops ids from
//! (patterns, markers, `<use>` copies) can't always keep it, and a warning says so.
use std::collections::HashSet;
use usvg::roxmltree;
use vectorcraft_doc::TextObject;
use vectorcraft_geom::Affine;
use super::css::{Styles, XNode};
use super::{Edits, href};
/// The property value this module reads.
pub(super) const NON_SCALING: &str = "non-scaling-stroke";
/// The shapes the property applies to (`<text>` is read by the text import).
const SHAPES: &[&str] = &["path", "rect", "circle", "ellipse", "line", "polyline", "polygon"];
/// Does element `n` ask for a non-scaling stroke?
pub(super) fn is_non_scaling(css: &Styles, n: XNode) -> bool {
css.own(n, "vector-effect").is_some_and(|v| v.trim() == NON_SCALING)
}
/// Record the ids of the shapes with a non-scaling stroke (made up for those without one) in
/// `ids`, and warn about those shown where usvg drops ids: their strokes scale like others.
pub(super) fn find(svg: &str, xml: &roxmltree::Document, css: &Styles, edits: &mut Edits, ids: &mut HashSet<String>, warnings: &mut Vec<String>) {
let reused: HashSet<&str> =
xml.descendants().filter(|n| n.tag_name().name() == "use").filter_map(href).filter_map(|h| h.trim().strip_prefix('#')).collect();
for n in xml.descendants().filter(|n| n.is_element() && SHAPES.contains(&n.tag_name().name()) && is_non_scaling(css, *n)) {
// usvg drops the ids of marker and `<use>` copies; a pattern's paint scales its art.
let placed = n.ancestors().any(|a| matches!(a.tag_name().name(), "pattern" | "marker" | "symbol"));
if placed || n.ancestors().any(|a| a.attribute("id").is_some_and(|id| reused.contains(id))) {
let label = n.attribute("id").map_or_else(|| format!("a {}", n.tag_name().name()), |id| format!("'{id}'"));
let w = format!("non-scaling stroke on {label} in a pattern, marker or reused element: it scales with the transform there");
if !warnings.contains(&w) {
warnings.push(w);
}
}
if !placed {
ids.insert(edits.id(svg, n));
}
}
}
/// Is `m` free of non-uniform scaling and skew (a uniform scale, rotation and reflection)?
pub(super) fn conformal(m: Affine) -> bool {
let [a, b, c, d, _, _] = m.as_coeffs();
let tol = 1e-9 * (a.abs() + b.abs() + c.abs() + d.abs()).max(1e-12);
((a - d).abs() <= tol && (b + c).abs() <= tol) || ((a + d).abs() <= tol && (b - c).abs() <= tol)
}
/// Give the character strokes of `t` (whose `xf` maps text space to the document) the widths and
/// dashes they have in screen space, `screen` points per screen pixel. `false` when the text is
/// stretched or skewed, so its strokes can only be approximated.
pub(super) fn unscale_text(t: &mut TextObject, screen: f64) -> bool {
let k = screen / t.xf.determinant().abs().sqrt();
if !k.is_finite() || k <= 0.0 {
return false;
}
t.scale_char_strokes(k);
conformal(t.xf) || !t.runs.iter().any(|r| r.style.has_stroke())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conformal_transforms_keep_circles_round() {
assert!(conformal(Affine::scale(3.0)));
assert!(conformal(Affine::rotate(0.7) * Affine::scale(2.0)));
assert!(conformal(Affine::scale_non_uniform(-2.0, 2.0)));
assert!(!conformal(Affine::scale_non_uniform(2.0, 1.0)));
assert!(!conformal(Affine::skew(0.5, 0.0)));
}
}

View File

@ -0,0 +1,94 @@
//! `vector-effect="non-scaling-stroke"` on import: the stroke keeps its screen width and dashes
//! whatever the transforms above it, on shapes and type; a warning where it can't be kept.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_doc::{Document, Node, NodeKind, TextObject};
use vectorcraft_svg::import_with_report;
/// The stroke width and dash pattern of the object named `name`.
fn stroke(d: &Document, name: &str) -> (f64, Option<Vec<f64>>) {
let mut found = None;
d.walk(|n: &Node| {
if n.name.as_deref() == Some(name) {
let s = n.appearance.stroke().expect("stroked");
found = Some((s.width, s.dash.as_ref().map(|d| d.pattern.clone())));
}
});
found.unwrap_or_else(|| panic!("no object '{name}'"))
}
fn text(d: &Document) -> TextObject {
let mut found = None;
d.walk(|n: &Node| {
if let NodeKind::Text(t) = &n.kind {
found = Some((**t).clone());
}
});
found.expect("a text")
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn a_non_scaling_stroke_keeps_its_width_under_a_scale() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
<g transform="scale(3)">
<path id="red" d="M10 15 H55" stroke="#e33" stroke-width="4"/>
<path id="green" d="M10 35 H55" stroke="#3a3" stroke-width="4" vector-effect="non-scaling-stroke"/>
</g>
</svg>"##;
let (d, warnings) = import_with_report(svg).unwrap();
assert!(close(stroke(&d, "red").0, 12.0));
assert!(close(stroke(&d, "green").0, 4.0), "{:?}", stroke(&d, "green"));
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn widths_and_dashes_are_in_screen_pixels_past_the_view_box() {
// The viewBox makes a user unit 2 px; a rule asks for the non-scaling stroke.
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 100 50">
<style>.thin { vector-effect: non-scaling-stroke }</style>
<rect id="box" class="thin" x="10" y="10" width="50" height="20" fill="none" stroke="#000" stroke-width="2" stroke-dasharray="6 3"/>
<circle id="ring" cx="80" cy="25" r="10" fill="none" stroke="#000" stroke-width="2"/>
</svg>"##;
let (d, _) = import_with_report(svg).unwrap();
let (w, dash) = stroke(&d, "box");
assert!(close(w, 2.0) && dash == Some(vec![6.0, 3.0]), "{w} {dash:?}");
assert!(close(stroke(&d, "ring").0, 4.0));
// In a root sized in millimetres a CSS pixel is 0.75 pt.
let mm = svg.replace(r#"width="200" height="100""#, r#"width="200mm" height="100mm""#);
let (d, _) = import_with_report(&mm).unwrap();
assert!(close(stroke(&d, "box").0, 1.5), "a 2 px stroke is 1.5 pt: {:?}", stroke(&d, "box"));
}
#[test]
fn a_reused_non_scaling_stroke_warns() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
<defs><path id="tick" d="M0 0 H20" stroke="#000" stroke-width="2" vector-effect="non-scaling-stroke"/></defs>
<use xlink:href="#tick" transform="scale(4)"/>
</svg>"##;
let (_, warnings) = import_with_report(svg).unwrap();
assert!(warnings.iter().any(|w| w.contains("non-scaling stroke on 'tick'")), "{warnings:?}");
}
#[test]
fn type_keeps_its_non_scaling_stroke_width() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200">
<text x="10" y="40" transform="scale(2)" font-size="20" stroke="#000" stroke-width="3" stroke-dasharray="4 2"
vector-effect="non-scaling-stroke">Ab</text>
</svg>"##;
let (d, warnings) = import_with_report(svg).unwrap();
let t = text(&d);
let st = &t.runs[0].style;
// Text space is scaled 2×: half the width there is the width on screen.
assert!(close(st.stroke_width * t.xf.determinant().sqrt(), 3.0), "{} {:?}", st.stroke_width, t.xf);
assert_eq!(st.stroke_dash.as_ref().map(|d| d.pattern.clone()), Some(vec![2.0, 1.0]));
assert!(warnings.is_empty(), "{warnings:?}");
let (_, warnings) = import_with_report(&svg.replace("scale(2)", "scale(2 1)")).unwrap();
assert!(warnings.iter().any(|w| w.contains("non-scaling stroke on a text approximated")), "{warnings:?}");
}