diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs index e03ad1b6..830f3d35 100644 --- a/crates/fonts/src/testing.rs +++ b/crates/fonts/src/testing.rs @@ -29,14 +29,15 @@ pub fn font_mapping(family: &str, glyphs: &[(char, char)]) -> Option> { 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> = [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> = [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()); } diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index de878d59..0db9d190 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -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"] } diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index b770140f..1e81115c 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -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); + +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, face: Arc) -> Option { + 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::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#"{body}"#) + }; + 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 { let mut b = Vec::new();