mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Images: set SVG text in the shared fonts when the bundled ones lack it (#116)
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user