Merge pull request #3908 from lehni/fix/svg-glyph-images

Type: images in SVG glyph documents draw with the glyph
This commit is contained in:
BFlat
2026-10-24 06:23:18 +07:00
committed by GitHub
5 changed files with 88 additions and 27 deletions
+28
View File
@@ -236,3 +236,31 @@ fn an_envelope_shares_and_expands_the_emoji_of_its_type() {
assert!(images.iter().all(|im| im.pixels.is_none() && d.images.contains_key(&im.key)), "the document holds them");
vectorcraft_testkit::invariants::check_document(d).unwrap();
}
/// An image in an SVG glyph's document draws on the canvas, and Create Outlines takes its pixels
/// into the document.
#[test]
fn svg_glyph_images_draw_and_outline() {
let g = source_sans_gid(SVG_CHAR).unwrap();
let png = vectorcraft_text::test_fonts::solid_png(4, [0, 0, 255, 255]);
let doc = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image id="glyph{g}" y="-700" width="500" height="700" xlink:href="data:image/png;base64,{}"/></svg>"##,
vectorcraft_svg::base64_encode(&png)
);
let mut s = with_svg_type("Svgtest Images", &doc);
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 100, 100, vectorcraft_geom::Affine::IDENTITY, &opts);
// At 32 pt from x 10 on the baseline at y 50: 16 pt wide, 22.4 pt tall.
assert_eq!(img.pixel(18, 40), [0, 0, 255, 255], "the glyph's image");
let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap());
let mut images = vec![];
node(&s, r[0]).walk(&mut |n| {
if let NodeKind::Image(im) = &n.kind {
images.push((**im).clone());
}
});
let d = &s.doc().unwrap().doc;
assert_eq!(images.len(), 1);
assert!(images[0].pixels.is_none() && d.images[&images[0].key].bytes.as_slice() == png.as_slice(), "the document holds its pixels");
vectorcraft_testkit::invariants::check_document(d).unwrap();
}
+21 -17
View File
@@ -1,8 +1,6 @@
//! OpenType SVG glyphs: the art of a font's glyph from the SVG document its `SVG ` table holds for
//! 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: images are left out (see [`LEFT_OUT`]).
use std::ops::Range;
use std::sync::Arc;
@@ -10,7 +8,7 @@ use std::sync::atomic::AtomicU64;
use usvg::roxmltree;
use vectorcraft_doc::color::Color;
use vectorcraft_doc::{Node, NodeId};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::Affine;
use crate::ImportOptions;
@@ -18,17 +16,16 @@ use crate::ImportOptions;
/// The most bytes a glyph's document may have, unpacked.
const MAX_SVG_GLYPH_BYTES: u64 = 1 << 20;
/// Elements left out of a glyph: text (type inside type), images (a glyph's art has no document
/// to keep them in), embedded content and scripts. The `SVG ` table rules text, embedded content
/// and scripts out, and allows images.
const LEFT_OUT: [&str; 4] = ["text", "image", "foreignObject", "script"];
/// Elements left out of a glyph, as the `SVG ` table rules them out: text (type inside type),
/// embedded content and scripts.
const LEFT_OUT: [&str; 3] = ["text", "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),
/// 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
/// (none without a `stroke`). Its nodes have no ids and its images their own pixels
/// ([`own_art`]). 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.
@@ -72,7 +69,7 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti
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))
Some((own_art(&art, &doc)?, coloured))
}
/// A glyph's document made ready for the importer.
@@ -169,14 +166,21 @@ fn edited(svg: &str, range: Range<usize>, edits: &[(Range<usize>, String)]) -> O
Some(out)
}
/// `n` with no ids, its members' neither.
fn anonymous(n: &Node) -> Node {
/// `n` as art of its own, made from `doc` (the glyph's document): no ids (they aren't a document's),
/// its members' neither, and its images holding the pixels `doc` keeps for them, named by their
/// content. A linked image, or one whose pixels `doc` doesn't hold, is left out (`None` when `n`
/// is one).
fn own_art(n: &Node, doc: &Document) -> Option<Node> {
let mut n = n.clone();
n.id = NodeId(u64::MAX);
if let Some(children) = n.children_mut() {
for c in children.iter_mut() {
*c = Arc::new(anonymous(c));
}
if let NodeKind::Image(im) = &mut n.kind {
// A glyph's images are embedded: a link (to no file of the font's) shows nothing.
let blob = doc.images.get(&im.key).filter(|b| im.link.is_none() && !b.bytes.is_empty())?;
im.key = blob.content_key();
im.pixels = Some(Arc::new(blob.clone()));
}
n
if let Some(children) = n.children_mut() {
*children = children.iter().filter_map(|c| own_art(c, doc).map(Arc::new)).collect();
}
Some(n)
}
+31 -5
View File
@@ -75,14 +75,13 @@ fn a_view_box_is_the_em_square() {
}
}
/// Text (which would lay type out inside type), images, embedded content and scripts are left out
/// of a glyph, as the OpenType `SVG ` table asks.
/// Text (which would lay type out inside type), embedded content and scripts are left out of a
/// glyph, as the OpenType `SVG ` table asks.
#[test]
fn text_images_and_scripts_are_left_out() {
fn text_and_scripts_are_left_out() {
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="glyph1">
<path fill="#f00" d="M0 0h500v-500z"/>
<text font-family="Svgtest Glyphs" font-size="500">E<tspan>F</tspan></text>
<image width="10" height="10" xlink:href="data:image/png;base64,iVBORw0KGgo="/>
<script>alert(1)</script>
</g></svg>"##;
let art = art(doc, 1).unwrap();
@@ -90,7 +89,7 @@ fn text_images_and_scripts_are_left_out() {
art.walk(&mut |n| kinds.push(n.kind_label()));
assert!(art.children().is_some_and(|c| !c.is_empty()), "the path stays");
let mut other = false;
art.walk(&mut |n| other |= matches!(n.kind, NodeKind::Text(_) | NodeKind::Image(_)));
art.walk(&mut |n| other |= matches!(n.kind, NodeKind::Text(_)));
assert!(!other, "{kinds:?}");
}
@@ -185,3 +184,30 @@ fn a_nested_glyph_keeps_the_namespaces_its_parents_declare() {
</svg>"##;
assert!(art(doc, 3).is_some_and(|a| fills(&a).contains(&Paint::solid(Color::rgb8(255, 0, 0)))));
}
/// An image in a glyph's document is in its art, holding its pixels (the art has no document to
/// keep them in), named by their content; one whose pixels don't read is left out.
#[test]
fn images_are_in_the_art_with_their_pixels() {
let png = vectorcraft_text::test_fonts::solid_png(4, [0, 0, 255, 255]);
let doc = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="glyph1">
<image x="0" y="-500" width="500" height="500" xlink:href="data:image/png;base64,{}"/>
<image width="10" height="10" xlink:href="missing.png"/>
</g></svg>"##,
vectorcraft_svg::base64_encode(&png)
);
let art = art(&doc, 1).unwrap();
let mut images = vec![];
art.walk(&mut |n| {
if let NodeKind::Image(im) = &n.kind {
images.push((**im).clone());
}
});
assert_eq!(images.len(), 1, "the one that reads");
let pixels = images[0].pixels.as_ref().expect("its own pixels");
assert_eq!(pixels.bytes.as_slice(), png.as_slice());
assert_eq!(images[0].key, pixels.content_key());
let b = art.geometric_bounds().unwrap();
assert!(b.x0.abs() < 1e-6 && (b.width() - 500.0).abs() < 1e-6 && (b.y0 + 500.0).abs() < 1e-6, "in font units: {b:?}");
}
+5 -3
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long