Merge pull request #3804 from lehni/fix/svg-glyph-coordinates

Type: SVG glyph documents follow the SVG table's coordinate rules
This commit is contained in:
BFlat
2026-10-24 02:43:53 +07:00
committed by GitHub
3 changed files with 116 additions and 42 deletions
+67 -40
View File
@@ -2,10 +2,9 @@
//! it, made as the importer makes art of any SVG file. The text crate asks for it through the
//! reader the app registers ([`vectorcraft_text::set_svg_glyph_reader`]).
//!
//! Where it differs from the `SVG ` table's rules: a size on the root (`width`, `height`) counts
//! for nothing; a non-square `viewBox` is scaled to fit the em by its larger side, not centred in
//! it; and a glyph element nested in others keeps what they apply (transforms, opacity).
//! Where it differs from the `SVG ` table's rules: images are left out (see [`LEFT_OUT`]).
use std::ops::Range;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
@@ -26,10 +25,11 @@ const LEFT_OUT: [&str; 4] = ["text", "image", "foreignObject", "script"];
/// Glyph `gid`'s art from its SVG `document` (possibly gzipped), in a font of `upem` units per em:
/// its element `glyph<gid>` (or the whole document when that is its root), in font units (y down),
/// a `viewBox` mapped onto the em square. The text's colour is `fill`: `currentColor`, and the
/// legacy `context-fill` and `context-stroke` (none without a `stroke`). Its nodes have no ids
/// (they aren't a document's). With the art, whether it uses the text's colours. `None` when the
/// document doesn't read, is too large or has no such element.
/// the root's `viewBox` (or else its size) fitted into the em square by its larger side, centred.
/// The text's colour is `fill`: `currentColor`, and the legacy `context-fill` and `context-stroke`
/// (none without a `stroke`). Its nodes have no ids (they aren't a document's). With the art,
/// whether it uses the text's colours. `None` when the document doesn't read, is too large or has
/// no such element.
pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Option<Color>) -> Option<(Node, bool)> {
// The SVGZ budget left is the glyph's cap.
let unpacked = AtomicU64::new(crate::MAX_SVGZ.saturating_sub(MAX_SVG_GLYPH_BYTES));
@@ -42,7 +42,7 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti
let [r, g, b, _] = c.to_rgba8(1.0);
format!("#{r:02x}{g:02x}{b:02x}")
};
let Glyph { svg, whole, view_box } = glyph(&text, &id, &hex(fill))?;
let Glyph { svg, whole, frame } = glyph(&text, &id, &hex(fill))?;
let coloured = ["currentColor", "currentcolor", "context-fill", "context-stroke"].iter().any(|k| svg.contains(k));
// usvg resolves `context-fill` and `context-stroke` only for markers.
let svg = svg.replace("context-fill", &hex(fill)).replace("context-stroke", &stroke.map_or_else(|| "none".into(), hex));
@@ -50,7 +50,7 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti
let mut art = if whole {
Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), l.children().cloned().unwrap_or_default()))).collect())
} else {
// A top-level element is a layer, others an object, named after its id.
// The glyph, top-level, is a layer when a `<g>`, else an object named after its id.
let named = |n: &Node| n.name.as_deref() == Some(id.as_str());
match doc.layers.iter().find(|l| named(l)) {
Some(layer) => Node::group(NodeId(0), layer.children().cloned().unwrap_or_default()),
@@ -67,10 +67,10 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti
}
}
};
// Without a size of its own, the document's viewport is its view box at one unit per unit:
// the em square is that box.
if let Some(side) = view_box.filter(|s| *s > 0.0 && s.is_finite()) {
art.transform(Affine::scale(upem / side), true);
// Without a size of its own, the document's viewport is its frame at one unit per unit.
if let Some((w, h)) = frame.filter(|(w, h)| *w > 0.0 && *h > 0.0 && w.is_finite() && h.is_finite()) {
let s = upem / w.max(h);
art.transform(Affine::translate(((upem - w * s) / 2.0, (upem - h * s) / 2.0)) * Affine::scale(s), true);
}
Some((anonymous(&art), coloured))
}
@@ -80,27 +80,23 @@ struct Glyph {
svg: String,
/// The glyph is the whole document (its root has the glyph's id).
whole: bool,
/// The larger side of the root's `viewBox`, if it has one.
view_box: Option<f64>,
/// The width and height of the root's `viewBox`, or else of its size, if it has either.
frame: Option<(f64, f64)>,
}
/// `svg` with only what glyph `id` draws: without the other glyphs' top-level elements (a font
/// may keep all its glyphs in one document), the [`LEFT_OUT`] elements and event attributes, the
/// root without a size (the em square is its view box) and in the text's colour `color`. `None`
/// when it doesn't read or has no element `id`.
/// `svg` with only what glyph `id` draws, as if the root `use`d it: the rest of the document (a
/// font may keep all its glyphs in one) only defines what it refers to, and what its parents apply
/// doesn't reach it. Without the [`LEFT_OUT`] elements and event attributes, the root without a
/// size (the em square is its frame) and in the text's colour `color`. `None` when it doesn't read
/// or has no element `id`.
fn glyph(svg: &str, id: &str, color: &str) -> Option<Glyph> {
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).ok()?;
let root = xml.root_element();
let whole = root.attribute("id") == Some(id);
if !whole && !xml.descendants().any(|n| n.attribute("id") == Some(id)) {
return None;
}
let is_glyph = |v: &str| v.strip_prefix("glyph").is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()));
let target = if whole { root } else { xml.descendants().find(|n| n.is_element() && n.attribute("id") == Some(id))? };
let at = target.range();
// Edits: byte ranges to cut and what to put in their place, in order.
let mut edits: Vec<(std::ops::Range<usize>, String)> = vec![];
for n in root.children().filter(|n| n.attribute("id").is_some_and(|v| v != id && is_glyph(v))) {
edits.push((n.range(), String::new()));
}
let mut edits: Vec<(Range<usize>, String)> = vec![];
for n in root.descendants().filter(|n| n.is_element() && LEFT_OUT.contains(&n.tag_name().name())) {
edits.push((n.range(), String::new()));
}
@@ -115,27 +111,58 @@ fn glyph(svg: &str, id: &str, color: &str) -> Option<Glyph> {
let start = root.range().start + 1;
let name = start + svg.get(start..)?.find(|c: char| c.is_whitespace() || c == '>' || c == '/')?;
edits.push((name..name, format!(" color=\"{color}\"")));
// The namespaces the glyph's parents declare, which it leaves: the root declares them too.
for ns in target.namespaces() {
if let Some(prefix) = ns.name().filter(|p| root.lookup_namespace_uri(Some(p)).is_none()) {
edits.push((name..name, format!(" xmlns:{prefix}=\"{}\"", crate::xml_escape(ns.uri()))));
}
}
edits.sort_by_key(|(r, _)| (r.start, r.end));
let mut out = String::with_capacity(svg.len());
let mut at = 0;
for (r, with) in edits {
let out = if whole {
edited(svg, 0..svg.len(), &edits)?
} else {
// The root's content goes in `defs`, and the glyph after it, on its own.
let content = root.first_child()?.range().start;
let close = svg.get(..root.range().end)?.rfind("</")?;
[
edited(svg, 0..content, &edits)?,
"<defs>".into(),
edited(svg, content..at.start, &edits)?,
edited(svg, at.end..close, &edits)?,
"</defs>".into(),
edited(svg, at.clone(), &edits)?,
edited(svg, close..svg.len(), &edits)?,
]
.concat()
};
let number = |v: &str| v.trim().trim_end_matches("px").parse::<f64>().ok();
let view_box = root.attribute("viewBox").and_then(|v| {
let n: Vec<f64> = v.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).filter_map(|s| s.parse().ok()).collect();
match n.as_slice() {
[_, _, w, h] => Some((*w, *h)),
_ => None,
}
});
let size = || Some((number(root.attribute("width")?)?, number(root.attribute("height")?)?));
Some(Glyph { svg: out, whole, frame: view_box.or_else(size) })
}
/// `svg[range]` with the `edits` (in order) that lie in it made: one around the range (a left-out
/// element holding the glyph) isn't, so it stays whole, defining only.
fn edited(svg: &str, range: Range<usize>, edits: &[(Range<usize>, String)]) -> Option<String> {
let mut out = String::new();
let mut at = range.start;
for (r, with) in edits.iter().filter(|(r, _)| range.start <= r.start && r.end <= range.end) {
// An element inside one already cut goes with it.
if r.start < at {
continue;
}
out.push_str(svg.get(at..r.start)?);
out.push_str(&with);
out.push_str(with);
at = r.end;
}
out.push_str(svg.get(at..)?);
let view_box = root.attribute("viewBox").and_then(|v| {
let n: Vec<f64> = v.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).filter_map(|s| s.parse().ok()).collect();
match n.as_slice() {
[_, _, w, h] => Some(w.max(*h)),
_ => None,
}
});
Some(Glyph { svg: out, whole, view_box })
out.push_str(svg.get(at..range.end)?);
Some(out)
}
/// `n` with no ids, its members' neither.
+46
View File
@@ -139,3 +139,49 @@ fn event_attributes_are_left_out() {
art.walk(&mut |n| events.extend(n.extras().attrs.as_ref().map(|a| a.events.clone()).unwrap_or_default()));
assert!(events.is_empty(), "{events:?}");
}
/// A size on the root without a `viewBox` sets up the em square as a `viewBox` would.
#[test]
fn a_root_size_is_the_em_square_without_a_view_box() {
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="2000">
<g id="glyph2"><path fill="#000" d="M500 500h500v500h-500z"/></g></svg>"##;
let b = art(doc, 2).unwrap().geometric_bounds().unwrap();
assert!((b.x0 - 250.0).abs() < 1e-6 && (b.width() - 250.0).abs() < 1e-6, "{b:?}");
}
/// A `viewBox` that isn't square fits the em square by its larger side, centred along the other.
#[test]
fn a_view_box_that_isnt_square_is_centred_in_the_em_square() {
// 2000 wide, 1000 high: half a unit per unit, the box 500 high centred in the em's 1000.
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2000 1000">
<g id="glyph2"><path fill="#000" d="M0 0h2000v1000h-2000z"/></g></svg>"##;
let b = art(doc, 2).unwrap().geometric_bounds().unwrap();
assert!(b.x0.abs() < 1e-6 && (b.y0 - 250.0).abs() < 1e-6 && (b.height() - 500.0).abs() < 1e-6, "{b:?}");
}
/// A glyph element inside others is drawn on its own, as if used: what its parents apply doesn't
/// reach it, and what it refers to still does.
#[test]
fn a_nested_glyph_is_drawn_on_its_own() {
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="g"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient></defs>
<g transform="translate(1000 0)" opacity="0.5"><g id="glyph3"><path fill="url(#g)" d="M0 0h500v-500z"/></g></g>
</svg>"##;
let art = art(doc, 3).unwrap();
let b = art.geometric_bounds().unwrap();
assert!(b.x0.abs() < 1e-6 && (b.x1 - 500.0).abs() < 1e-6, "not moved by its parent: {b:?}");
let mut opacities = vec![];
art.walk(&mut |n| opacities.push(n.opacity));
assert!(opacities.iter().all(|o| *o == 1.0), "not faded by its parent: {opacities:?}");
assert!(fills(&art).iter().any(|p| matches!(p, Paint::Gradient(_))), "its gradient");
}
/// A namespace the glyph's parents declare still binds it when it is drawn on its own.
#[test]
fn a_nested_glyph_keeps_the_namespaces_its_parents_declare() {
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg">
<g xmlns:l="http://www.w3.org/1999/xlink"><defs><path id="a" d="M0 0h500v-500z"/></defs>
<g id="glyph3"><use fill="#f00" l:href="#a"/></g></g>
</svg>"##;
assert!(art(doc, 3).is_some_and(|a| fills(&a).contains(&Paint::solid(Color::rgb8(255, 0, 0)))));
}
+3 -2
View File
File diff suppressed because one or more lines are too long