mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
+67
-40
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user