Images: set SVG text in the shared fonts when the bundled ones lack it (#116)

This commit is contained in:
Haseom Shin
2026-10-10 01:13:17 -04:00
committed by GitHub
parent d730d8fa27
commit 788e0eac66
3 changed files with 105 additions and 7 deletions
+5 -4
View File
@@ -29,14 +29,15 @@ pub fn font_mapping(family: &str, glyphs: &[(char, char)]) -> Option<Vec<u8>> {
let mut tables = tables_of(base)?;
tables.retain(|(tag, _)| tag != b"name" && tag != b"cmap");
// `name`: format 0, family (1) and subfamily (2), Windows Unicode English.
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect();
// `name`: format 0, family (1), subfamily (2) and PostScript name (6), Windows Unicode English.
let ps: String = family.chars().filter(char::is_ascii_alphanumeric).chain("-Regular".chars()).collect();
let strings: Vec<Vec<u8>> = [family, "Regular", &ps].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect();
let mut name = Vec::new();
for v in [0u16, 2, 6 + 2 * 12] {
for v in [0u16, 3, 6 + 3 * 12] {
name.extend_from_slice(&v.to_be_bytes());
}
let mut at = 0usize;
for (id, s) in [1u16, 2].iter().zip(&strings) {
for (id, s) in [1u16, 2, 6].iter().zip(&strings) {
for v in [3u16, 1, 0x409, *id, u16::try_from(s.len()).ok()?, u16::try_from(at).ok()?] {
name.extend_from_slice(&v.to_be_bytes());
}
+3
View File
@@ -14,3 +14,6 @@ designcraft-fonts = { workspace = true }
image = { workspace = true }
tiff = { workspace = true }
resvg = { workspace = true }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
+97 -3
View File
@@ -1,7 +1,7 @@
//! Placed graphics: format sniffing, sizes and decoding for the renderer and PDF export.
//! Rasters go through `image` (PNG, JPEG, GIF, WebP, TIFF, BMP) or [`psd`] (Photoshop's merged
//! composite); SVG is parsed with usvg (text set in the bundled fonts) and rasterised with resvg
//! for the screen — PDF export draws the same tree as vectors.
//! composite); SVG is parsed with usvg (text set in the bundled fonts, then the shared ones) and
//! rasterised with resvg for the screen — PDF export draws the same tree as vectors.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::sync::{Arc, OnceLock};
@@ -67,10 +67,67 @@ fn svg_options() -> &'static usvg::Options<'static> {
db.set_serif_family("Source Serif 4");
db.set_sans_serif_family("Source Sans 3");
db.set_monospace_family("JetBrains Mono");
usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), ..Default::default() }
let font_resolver = usvg::FontResolver { select_font: select_font(), select_fallback: select_fallback() };
usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, ..Default::default() }
})
}
/// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too),
/// as it would in a text frame.
fn select_font() -> usvg::FontSelectionFn<'static> {
let bundled = usvg::FontResolver::default_font_selector();
Box::new(move |font, db| {
let fonts = designcraft_fonts::FontDb::global();
for family in font.families() {
if let usvg::FontFamily::Named(name) = family
&& !db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)))
&& fonts.has_family(name)
{
for style in fonts.styles(name) {
add_face(db, fonts.face(name, &style));
}
}
}
bundled(font, db)
})
}
/// Characters the chosen fonts lack (Korean, Arabic…) fall back to a shared face that has them.
fn select_fallback() -> usvg::FallbackSelectionFn<'static> {
let loaded = usvg::FontResolver::default_fallback_selector();
Box::new(move |c, exclude, db| {
loaded(c, exclude, db).or_else(|| {
let id = add_face(db, designcraft_fonts::FontDb::global().fallback_for(c, 0, None)?)?;
// usvg asks again until we run out: a face it already tried ends the search.
(!exclude.contains(&id)).then_some(id)
})
})
}
/// A shared face's bytes, handed to usvg without a copy.
struct FaceBytes(Arc<designcraft_fonts::FontFace>);
impl AsRef<[u8]> for FaceBytes {
fn as_ref(&self) -> &[u8] {
self.0.data()
}
}
/// Add `face` to the SVG's font database (once) and return its id there.
fn add_face(db: &mut Arc<usvg::fontdb::Database>, face: Arc<designcraft_fonts::FontFace>) -> Option<usvg::fontdb::ID> {
let same = |f: &usvg::fontdb::FaceInfo| {
f.index == face.index()
&& matches!(&f.source, usvg::fontdb::Source::Binary(b) if std::ptr::eq(b.as_ref().as_ref().as_ptr(), face.data().as_ptr()))
};
if let Some(f) = db.faces().find(|f| same(f)) {
return Some(f.id);
}
let index = face.index();
let ids = Arc::make_mut(db).load_font_source(usvg::fontdb::Source::Binary(Arc::new(FaceBytes(face))));
// A collection loads all its faces (skipping any it can't read): pick ours by its index.
ids.into_iter().find(|id| db.face(*id).is_some_and(|f| f.index == index))
}
/// The parsed SVG.
pub fn svg_tree(bytes: &[u8]) -> Option<usvg::Tree> {
usvg::Tree::from_data(bytes, svg_options()).ok()
@@ -211,6 +268,43 @@ mod tests {
assert!(!is_svg(b"\x89PNG"));
}
/// The family of the font that sets each text glyph of `svg`, and the glyph's id.
fn glyph_families(svg: &str) -> Vec<(String, u16)> {
fn walk(g: &usvg::Group, tree: &usvg::Tree, out: &mut Vec<(String, u16)>) {
for n in g.children() {
match n {
usvg::Node::Group(g) => walk(g, tree, out),
usvg::Node::Text(t) => {
for g in t.layouted().iter().flat_map(|s| &s.positioned_glyphs) {
out.extend(tree.fontdb().face(g.font).map(|f| (f.families[0].0.clone(), g.id.0)));
}
}
_ => {}
}
}
}
let tree = svg_tree(svg.as_bytes()).unwrap();
let mut out = Vec::new();
walk(tree.root(), &tree, &mut out);
out
}
#[test]
fn svg_text_uses_the_shared_fonts() {
let font = designcraft_fonts::testing::font_with("DC Test SVG Hangul", &['한', '글']).unwrap();
designcraft_fonts::FontDb::global().add_font(font);
let text = |attrs: &str, body: &str| {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40"><text y="30" font-size="20"{attrs}>{body}</text></svg>"#)
};
let named = r#" font-family="DC Test SVG Hangul""#;
// A family named in the SVG.
let set = glyph_families(&text(named, "한글"));
assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Hangul" && *gid != 0), "{set:?}");
// Characters the bundled default lacks: a shared face that has them, not the default.
let set = glyph_families(&text("", "한글"));
assert!(set.len() == 2 && set.iter().all(|(f, gid)| f != "Source Sans 3" && *gid != 0), "{set:?}");
}
/// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image.
fn tiny_psd() -> Vec<u8> {
let mut b = Vec::new();