From a8dc6534f9568f5dd11c9335bfad46f5c9bf8829 Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Thu, 1 Oct 2026 23:07:15 +0800 Subject: [PATCH] fix(pdf): export TrueType text as embedded subset fonts, not triangles PDF export filled every TrueType glyph's tessellated outline, one triangle per triple. A stroke-heavy CJK face such as DFKai-SB tessellates into hundreds of triangles per character, so a 28-layout schematic set exported at 320.9 MB and viewers crawled through it. The atlas export table was also rebuilt for every text wire, which made the same export take 566 s. - ttf_glyph: GlyphSource records the face, glyph id and font-unit scale a filled outline came from; glyph_source / fallback_source resolve the same face as glyph / fallback_glyph (fallback picks both in one search). - Face::glyph_source mirrors Face::glyph; stroke glyphs (LFF, SHX, big font) return None and keep their polyline export. - GlyphExport carries the char and the source (filled glyphs only, so TEXTFILL 0 still exports hollow outlines). - pdf_export draws a sourced glyph as text (Tf 1 + a Tm that maps its em onto the SDF quad), subsets each face with allsorts after all pages (keeping cvt/fpgm/prep for tricky fonts), renumbers glyph ids, and snapshots the glyph table once per export. Same set: 566 s / 320.9 MB -> 27 s / 1.42 MB; text is searchable and lands on the same pixels as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 3 + src/io/pdf_export.rs | 363 ++++++++++++++++++++++++++++++++++-- src/scene/text/font_face.rs | 28 +++ src/scene/text/sdf_atlas.rs | 18 ++ src/scene/text/ttf_glyph.rs | 148 ++++++++++++++- 6 files changed, 539 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca168db01..f63b3ee31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "OpenCADStudio" version = "2026.39.0" dependencies = [ + "allsorts", "ashpd", "base64", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 17332155c..882e103eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,6 +103,9 @@ i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" open = "5" printpdf = { version = "0.9.1", default-features = false, features = ["images"] } +# Font subsetting for PDF text (printpdf 0.9 embeds whole fonts); same version +# and features as printpdf's own dependency, so nothing new is compiled. +allsorts = { version = "0.16.1", default-features = false, features = ["flate2_rust"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index f75a1a6ed..2499db8ff 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -16,10 +16,10 @@ use crate::scene::WireModel; use crate::scene::model::image_model::ImageModel; #[cfg(not(target_arch = "wasm32"))] use printpdf::{ - BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line, - LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, PdfDocument, - PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, TextItem, - WindingOrder, + BlendMode, BuiltinFont, Codepoint, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, + FontId, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, + ParsedFont, PdfDocument, PdfFont, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, + PolygonRing, Pt, Rgb, TextItem, TextMatrix, WindingOrder, }; use std::path::Path; @@ -428,10 +428,20 @@ fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) // Borrowing all pages keeps their pixel Arcs alive until this cache is dropped. // Allocation addresses cannot be reused by another source during this export. let mut image_resources = std::collections::HashMap::new(); + let mut text_fonts = TextFonts::default(); for (index, page) in pages.iter().enumerate() { - append_pdf_page(&mut doc, &mut image_resources, page, plot_style) - .map_err(|error| format!("Page {}: {error}", index + 1))?; + append_pdf_page( + &mut doc, + &mut image_resources, + &mut text_fonts, + page, + plot_style, + ) + .map_err(|error| format!("Page {}: {error}", index + 1))?; } + // Subset and embed the TrueType faces the text was drawn with, now that + // every page has recorded which glyphs it uses. + text_fonts.install(&mut doc); let mut warnings = Vec::new(); // printpdf 0.9's `optimize` is a no-op (its `doc.compress()` is commented // out), so every page's content stream — megabytes of vector operators @@ -449,6 +459,7 @@ fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) fn append_pdf_page( doc: &mut PdfDocument, image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, + text_fonts: &mut TextFonts, page: &PdfPageInput, fallback_plot_style: Option<&PlotStyleTable>, ) -> Result<(), String> { @@ -655,6 +666,7 @@ fn append_pdf_page( DrawItem::Text(wire) => { emit_text( &mut ops, + text_fonts, std::slice::from_ref(&wire.wire), ox, oy, @@ -1517,6 +1529,7 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] { #[cfg(not(target_arch = "wasm32"))] fn emit_text( ops: &mut Vec, + fonts: &mut TextFonts, wires: &[WireModel], ox: f64, oy: f64, @@ -1529,13 +1542,11 @@ fn emit_text( if wires.iter().all(|w| w.text_verts.is_empty()) { return; } - // Snapshot the atlas' baked-glyph geometry once; drop the lock before use. - let (table, solid_key) = { - let Ok(atlas) = sdf_atlas::text_atlas().lock() else { - return; - }; - (atlas.export_table(), sdf_atlas::uv_key(atlas.solid_uv())) + // The atlas' baked-glyph geometry, snapshotted once per export. + let Some(snapshot) = fonts.glyph_table() else { + return; }; + let (table, solid_key) = (&snapshot.0, snapshot.1); // `Op::SetLineDashPattern` is persistent graphics state and the wire pass // above only re-emits it on change, so whatever the last wire needed is @@ -1617,10 +1628,22 @@ fn emit_text( }; if !ge.fill_tris.is_empty() { - // Filled TrueType glyph: one filled triangle per triple. ops.push(Op::SetFillColor { col: Color::Rgb(Rgb { r, g, b, icc_profile: None }), }); + // Filled TrueType glyph from a face we can embed: draw it as + // text in that font. Its outline tessellates into hundreds + // of triangles for a CJK face, which made text-heavy sheets + // export at hundreds of MB and crawl in viewers. + if let Some(source) = &ge.source { + if let Some(font) = fonts.font_for(source, ge.ch) { + let matrix = + glyph_text_matrix(source, pmin, [sx, sy], bl, br, tl, ox, oy); + push_glyph_text(ops, font, source.gid, ge.ch, matrix); + continue; + } + } + // Otherwise one filled triangle per triple. for tri in ge.fill_tris.chunks_exact(3) { ops.push(Op::DrawPolygon { polygon: Polygon { @@ -1702,6 +1725,241 @@ fn emit_text( } } +// ── Embedded TrueType text ───────────────────────────────────────────────── + +/// The atlas export table plus its solid-tile key. +#[cfg(not(target_arch = "wasm32"))] +type GlyphTable = std::sync::Arc<( + std::collections::HashMap, + u64, +)>; + +/// The TrueType faces a PDF export draws text with, and the glyphs each uses. +/// +/// Pages register glyphs as they are drawn (each under a font id fixed up +/// front); once every page is done, [`TextFonts::install`] subsets each face to +/// the glyphs actually used, renumbers them in the page streams, and adds the +/// subset fonts to the document. printpdf 0.9 would otherwise embed whole +/// fonts (its own subsetting is disabled), and a CJK face is megabytes. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct TextFonts { + /// The atlas export table, snapshotted once: every page's text quads are + /// laid out before the export starts, so one snapshot serves the whole + /// document. Re-walking the atlas per text wire made a 28-sheet set with + /// ~4 000 text entities take over nine minutes. + glyphs: Option, + fonts: Vec, + /// Font file identity → index into `fonts`; `None` = printpdf can't parse + /// the face, so its glyphs keep the outline fill. + by_blob: std::collections::HashMap<(usize, u32, u32), Option>, +} + +#[cfg(not(target_arch = "wasm32"))] +struct EmbeddedFont { + blob: std::sync::Arc, + id: FontId, + /// Original glyph id → the character it draws (for ToUnicode). + used: std::collections::BTreeMap, +} + +#[cfg(not(target_arch = "wasm32"))] +impl TextFonts { + /// The atlas' glyph export table (and solid-tile key), built on first use. + fn glyph_table(&mut self) -> Option { + use crate::scene::text::sdf_atlas; + if self.glyphs.is_none() { + let atlas = sdf_atlas::text_atlas().lock().ok()?; + self.glyphs = Some(std::sync::Arc::new(( + atlas.export_table(), + sdf_atlas::uv_key(atlas.solid_uv()), + ))); + } + self.glyphs.clone() + } + + /// The font id to draw `source`'s glyph with, recording the glyph as used. + /// `None` when the face can't be embedded. + fn font_for( + &mut self, + source: &crate::scene::text::ttf_glyph::GlyphSource, + ch: char, + ) -> Option { + let slot = match self.by_blob.get(&source.font.id) { + Some(slot) => *slot, + None => { + let parses = + ParsedFont::from_bytes(&source.font.data, source.font.index, &mut Vec::new()) + .is_some(); + let slot = parses.then(|| { + self.fonts.push(EmbeddedFont { + blob: source.font.clone(), + id: FontId::new(), + used: std::collections::BTreeMap::new(), + }); + self.fonts.len() - 1 + }); + self.by_blob.insert(source.font.id, slot); + slot + } + }?; + let font = &mut self.fonts[slot]; + font.used.entry(source.gid).or_insert(ch); + Some(font.id.clone()) + } + + /// Subset each face to its used glyphs, renumber those glyphs in the page + /// streams, and register the fonts under the ids the pages already use. + /// A face that fails to subset is embedded whole, ids unchanged. + fn install(self, doc: &mut PdfDocument) { + for font in self.fonts { + // `.notdef` must stay glyph 0; the subset numbers glyphs in list order. + let gids: Vec = std::iter::once(0) + .chain(font.used.keys().copied().filter(|&gid| gid != 0)) + .collect(); + let subset = subset_font(&font.blob, &gids) + .and_then(|bytes| ParsedFont::from_bytes(&bytes, 0, &mut Vec::new())); + let parsed = match subset { + Some(parsed) => { + let remap: std::collections::HashMap = gids + .iter() + .enumerate() + .map(|(new, &old)| (old, new as u16)) + .collect(); + renumber_glyphs(&mut doc.pages, &font.id, &remap); + parsed + } + None => { + log::warn!("PDF export: font subsetting failed; embedding the whole face"); + let whole = + ParsedFont::from_bytes(&font.blob.data, font.blob.index, &mut Vec::new()); + match whole { + Some(parsed) => parsed, + None => continue, // `font_for` already checked it parses + } + } + }; + doc.resources + .fonts + .map + .insert(font.id, PdfFont::new(parsed)); + } + } +} + +/// `blob`'s face reduced to `gids` (glyph 0 first), keeping the hinting tables +/// a tricky CJK face (DFKai-SB, MingLiU) needs to draw its strokes in place. +#[cfg(not(target_arch = "wasm32"))] +fn subset_font(blob: &crate::scene::text::ttf_glyph::FontBlob, gids: &[u16]) -> Option> { + use allsorts::binary::read::ReadScope; + use allsorts::font_data::FontData; + use allsorts::subset::{subset, CmapTarget, SubsetProfile}; + + let font = ReadScope::new(&blob.data).read::>().ok()?; + let provider = font.table_provider(blob.index as usize).ok()?; + subset( + &provider, + gids, + &SubsetProfile::Pdf, + CmapTarget::Unrestricted, + ) + .ok() +} + +/// Rewrite the glyph ids shown in font `id` through `remap` (original → subset). +#[cfg(not(target_arch = "wasm32"))] +fn renumber_glyphs( + pages: &mut [PdfPage], + id: &FontId, + remap: &std::collections::HashMap, +) { + for page in pages { + let mut in_font = false; + for op in &mut page.ops { + match op { + Op::SetFont { font, .. } => { + in_font = matches!(font, PdfFontHandle::External(fid) if fid == id); + } + Op::ShowText { items } if in_font => { + for item in items { + if let TextItem::GlyphIds(codepoints) = item { + for codepoint in codepoints { + if let Some(&gid) = remap.get(&codepoint.gid) { + codepoint.gid = gid; + } + } + } + } + } + _ => {} + } + } + } +} + +/// Text matrix that lays the embedded glyph exactly over its SDF quad. +/// +/// The quad maps the glyph's 9-unit tile rect `pmin .. pmin + size` onto the +/// world corners `bl`/`br`/`tl`; the outline was normalised font-unit × `k`, +/// so one em (`units_per_em` font units) spans `k · units_per_em` 9-units. +/// With `Tf` size 1 a text-space unit is one em, which this matrix carries to +/// sheet points under the page's CTM — the same space the wire pass draws in. +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::too_many_arguments)] +fn glyph_text_matrix( + source: &crate::scene::text::ttf_glyph::GlyphSource, + pmin: [f32; 2], + size: [f32; 2], + bl: [f64; 2], + br: [f64; 2], + tl: [f64; 2], + ox: f64, + oy: f64, +) -> [f32; 6] { + let em = source.k as f64 * source.units_per_em.max(1) as f64; + let (sx, sy) = (size[0] as f64, size[1] as f64); + // World displacement per 9-unit along the glyph's x and y axes. + let ex = [(br[0] - bl[0]) / sx, (br[1] - bl[1]) / sx]; + let ey = [(tl[0] - bl[0]) / sy, (tl[1] - bl[1]) / sy]; + // Glyph origin (9-unit 0,0 = pen position on the baseline) in world space. + let (px, py) = (pmin[0] as f64, pmin[1] as f64); + let origin = [ + bl[0] - px * ex[0] - py * ey[0], + bl[1] - px * ex[1] - py * ey[1], + ]; + let pt = MM_TO_PT as f64; + [ + (ex[0] * em * pt) as f32, + (ex[1] * em * pt) as f32, + (ey[0] * em * pt) as f32, + (ey[1] * em * pt) as f32, + ((origin[0] + ox) * pt) as f32, + ((origin[1] + oy) * pt) as f32, + ] +} + +/// One glyph as a text object: font `font` at size 1, positioned by `matrix`. +/// The character rides along as the glyph's Unicode so the text is searchable. +#[cfg(not(target_arch = "wasm32"))] +fn push_glyph_text(ops: &mut Vec, font: FontId, gid: u16, ch: char, matrix: [f32; 6]) { + ops.push(Op::StartTextSection); + ops.push(Op::SetFont { + font: PdfFontHandle::External(font), + size: Pt(1.0), + }); + ops.push(Op::SetTextMatrix { + matrix: TextMatrix::Raw(matrix), + }); + ops.push(Op::ShowText { + items: vec![TextItem::GlyphIds(vec![Codepoint { + gid, + offset: 0.0, + cid: Some(ch.to_string()), + }])], + }); + ops.push(Op::EndTextSection); +} + /// Everything searchable in an exported file: the raw bytes (printpdf /// leaves small content streams uncompressed) plus any zlib streams that /// decode to mostly-printable text, so the assertions hold either way. @@ -1939,4 +2197,83 @@ mod tests { no_text.len() ); } + + // A filled TrueType glyph exports as text in an embedded subset of its + // face — not as its tessellated outline, which for a CJK face is hundreds + // of triangles per glyph (a 28-sheet DFKai-SB set came out at 320 MB). + #[test] + fn truetype_text_embeds_a_subset_font() { + use crate::scene::pipeline::text_gpu::push_glyph_vertices; + use crate::scene::text::font_face::Face; + use crate::scene::text::{glyph_quads::layout_glyph_quads, sdf_atlas, sysfont}; + + // Any installed TrueType family that draws 'H'; hosts without fonts skip. + let Some((family, source)) = sysfont::families().iter().find_map(|family| { + let face = Face::resolve(family); + matches!(face, Face::Ttf { .. }) + .then(|| face.glyph_source('H')) + .flatten() + .map(|source| (family.clone(), source)) + }) else { + eprintln!("no TrueType font installed; skipped"); + return; + }; + // The atlas and TEXTFILL are process-wide and other tests reset, grow + // or unfill them concurrently; a change between laying the quads out + // and exporting them invalidates their tile keys, so retry until one + // export runs against a stable, filled atlas. + let (bytes, text) = (0..20) + .find_map(|_| { + let generation = sdf_atlas::generation(); + if !sdf_atlas::textfill() { + std::thread::yield_now(); + return None; + } + let quads = { + let mut atlas = sdf_atlas::text_atlas().lock().unwrap(); + layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, &family, false, "HHH") + }; + assert_eq!(quads.len(), 3, "three glyphs laid out in {family}"); + let mut verts = Vec::new(); + push_glyph_vertices( + &mut verts, + &quads, + [20.0, 20.0, 0.0], + 1.0, + [0.0, 0.0, 0.0, 1.0], + 0.0, + ); + let wire = PlotWire { + wire: WireModel { + text_verts: verts, + ..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false) + }, + draw_depth: 0.0, + }; + let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap(); + let stable = sdf_atlas::generation() == generation && sdf_atlas::textfill(); + stable.then(|| { + let text = pdf_stream_text(&bytes); + (bytes, text) + }) + }) + .expect("the shared glyph atlas never held still for one export"); + assert!(text.contains(" Tf"), "glyphs drawn as text in a font"); + assert_eq!( + text.matches(" Tm").count(), + 3, + "one positioned text object per glyph" + ); + assert!( + String::from_utf8_lossy(&bytes).contains("/FontFile2"), + "the TrueType program is embedded" + ); + // A subset of a handful of glyphs, not the whole face. + assert!( + bytes.len() < source.font.data.len().max(200_000) / 2, + "PDF {} bytes vs font file {} bytes", + bytes.len(), + source.font.data.len() + ); + } } diff --git a/src/scene/text/font_face.rs b/src/scene/text/font_face.rs index 10041a874..317d226e2 100644 --- a/src/scene/text/font_face.rs +++ b/src/scene/text/font_face.rs @@ -146,6 +146,34 @@ impl Face { } } + /// The TrueType face [`glyph`](Self::glyph) takes `ch`'s outline from, or + /// `None` when that glyph is a stroke (LFF / SHX / big-font) glyph. Follows + /// the exact dispatch of `glyph` so an exporter that embeds the face draws + /// the same glyph the screen shows. + #[cfg(not(target_arch = "wasm32"))] + pub fn glyph_source(&self, ch: char) -> Option { + match self { + Face::Lff(f) => match f.glyph(ch) { + Some(_) => None, + None => ttf_glyph::fallback_source(ch), + }, + Face::Ttf { family, .. } => match ttf_glyph::glyph(family, ch) { + Some(_) => ttf_glyph::glyph_source(family, ch), + None => ttf_glyph::fallback_source(ch), + }, + Face::Shx { path, .. } => match shx::font_glyph(path, ch as u16) { + Some(_) => None, + None => ttf_glyph::fallback_source(ch), + }, + Face::WithBig { primary, big } => { + if ttf_glyph::is_full_width(ch) && shx::bigfont_glyph(big, ch).is_some() { + return None; + } + primary.glyph_source(ch) + } + } + } + /// Extra spacing added after every glyph (9-unit). TTF advances already /// include side bearings, so no extra tracking is added there. pub fn letter_spacing(&self) -> f32 { diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index dc87acdfa..465be8a2d 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -164,6 +164,12 @@ pub struct GlyphExport { pub fill_tris: Vec<[f32; 2]>, /// Baked with the 1.7× bold pen; the exporter widens its own pen to match. pub bold: bool, + /// The character this tile draws (for the PDF text layer / ToUnicode). + pub ch: char, + /// The TrueType face the filled outline came from, when there is one: the + /// PDF exporter embeds that face and draws the glyph as text instead of + /// filling `fill_tris`. `None` for stroke glyphs and hollow (TEXTFILL 0) ones. + pub source: Option, } /// Pack a glyph quad's `uv_min` corner into a stable hash key for the export @@ -309,6 +315,16 @@ impl GlyphAtlas { .entry(family.as_str()) .or_insert_with(|| Face::resolve(family)); let Some(g) = face.glyph(*ch) else { continue }; + // Only a filled outline is drawn as embedded-font text; a hollow + // (TEXTFILL 0) glyph keeps its stroked outline. + #[cfg(not(target_arch = "wasm32"))] + let source = if fill_on && !g.fill_tris.is_empty() { + face.glyph_source(*ch) + } else { + None + }; + #[cfg(target_arch = "wasm32")] + let source = None; out.insert( uv_key(entry.uv_min), GlyphExport { @@ -328,6 +344,8 @@ impl GlyphAtlas { // share the centrelines, so carry the flag and let the exporter // widen its own pen by the same factor. bold: *bold, + ch: *ch, + source, }, ); } diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index cb24878b3..0ed67b5a3 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -446,6 +446,122 @@ fn triangulate_contours(contours: &[Vec<[f32; 2]>]) -> Vec<[f32; 2]> { tris } +// ── Glyph source (for exporters that embed the font) ───────────────────────── + +/// One font file's bytes, shared by every glyph taken from it. +#[derive(Debug)] +pub struct FontBlob { + /// The whole font file (a collection keeps all its faces). + pub data: Vec, + /// Face index within `data` (0 for a plain .ttf/.otf). + pub index: u32, + /// Stable identity: byte length, face index, `head.checkSumAdjustment`. + pub id: (usize, u32, u32), +} + +/// The TrueType face and glyph a filled glyph was outlined from. +/// +/// The PDF exporter embeds `font` and draws the glyph as text instead of +/// filling the tessellated outline: a glyph of a stroke-heavy CJK face +/// (DFKai-SB) tessellates into hundreds of triangles, so a text-heavy sheet +/// exported as hundreds of megabytes that viewers crawl through. +#[derive(Clone, Debug)] +pub struct GlyphSource { + pub font: Arc, + pub gid: u16, + /// Font-unit -> 9-unit factor the outline was normalised with + /// ([`cap_scale`] or [`em_scale`]) -- the exporter needs it to place the + /// embedded glyph exactly over the outline's tile. + pub k: f32, + pub units_per_em: u16, +} + +#[cfg(not(target_arch = "wasm32"))] +fn face_key(data: &[u8], index: u32) -> Option { + use skrifa::raw::TableProvider; + let font = skrifa::FontRef::from_index(data, index).ok()?; + let head = font.head().ok()?; + Some(FaceKey { + len: data.len(), + index, + checksum_adjustment: head.checksum_adjustment(), + }) +} + +/// Copy each font file once per process, however many glyphs come from it. +#[cfg(not(target_arch = "wasm32"))] +fn blob_for(data: &[u8], index: u32) -> Option> { + static BLOBS: OnceLock>>> = OnceLock::new(); + let key = face_key(data, index)?; + let mut blobs = BLOBS + .get_or_init(|| Mutex::new(HashMap::default())) + .lock() + .unwrap(); + Some( + blobs + .entry(key) + .or_insert_with(|| { + Arc::new(FontBlob { + data: data.to_vec(), + index, + id: (key.len, key.index, key.checksum_adjustment), + }) + }) + .clone(), + ) +} + +#[cfg(not(target_arch = "wasm32"))] +fn source_for( + data: &[u8], + index: u32, + face: &ttf_parser::Face, + gid: ttf_parser::GlyphId, + k: f32, +) -> Option { + Some(GlyphSource { + font: blob_for(data, index)?, + gid: gid.0, + k, + units_per_em: face.units_per_em(), + }) +} + +/// Source of [`glyph`]`(family, ch)`: same face, same glyph, same scale. +/// Cached per `(family, char)`, like the glyph itself. +#[cfg(not(target_arch = "wasm32"))] +pub fn glyph_source(family: &str, ch: char) -> Option { + type SourceCache = HashMap<(String, char), Option>; + static CACHE: OnceLock> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::default())); + let key = (family.to_string(), ch); + if let Some(hit) = cache.lock().unwrap().get(&key) { + return hit.clone(); + } + let built = sysfont::with_face_data(family, |data, index| { + let face = ttf_parser::Face::parse(data, index).ok()?; + let gid = face.glyph_index(ch)?; + source_for(data, index, &face, gid, cap_scale(&face)) + }) + .flatten(); + cache.lock().unwrap().insert(key, built.clone()); + built +} + +/// Source of [`fallback_glyph`]`(ch)`: the face the fallback search settles on. +/// Cached per character, like the glyph itself. +#[cfg(not(target_arch = "wasm32"))] +pub fn fallback_source(ch: char) -> Option { + static CACHE: OnceLock>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::default())); + if let Some(hit) = cache.lock().unwrap().get(&ch) { + return hit.clone(); + } + let built = pick_fallback(ch).and_then(|(_, source)| source); + cache.lock().unwrap().insert(ch, built.clone()); + built +} + // ── Shaping ──────────────────────────────────────────────────────────────── /// One shaped glyph, positioned within its run. Strokes are in 9-unit space and @@ -572,6 +688,14 @@ mod fallback_tests { /// no stroke font provides. #[cfg(not(target_arch = "wasm32"))] fn build_fallback(ch: char) -> Option> { + pick_fallback(ch).map(|(glyph, _)| glyph) +} + +/// The fallback glyph for `ch` together with the face it came from -- one +/// search, so the glyph drawn on screen and the font an exporter embeds can +/// never disagree. +#[cfg(not(target_arch = "wasm32"))] +fn pick_fallback(ch: char) -> Option<(Arc, Option)> { outline_from_fallback_face(ch).or_else(|| installed_family_glyph(ch)) } @@ -582,7 +706,12 @@ fn build_fallback(ch: char) -> Option> { /// text height — so size the substitute by its em box. Everything else keeps the /// cap-height normalisation that lines it up with the Latin stroke glyphs. #[cfg(not(target_arch = "wasm32"))] -fn outline_char(data: &[u8], index: u32, face: &ttf_parser::Face, ch: char) -> Option { +fn outline_char( + data: &[u8], + index: u32, + face: &ttf_parser::Face, + ch: char, +) -> Option<(Glyph, Option)> { let gid = face.glyph_index(ch)?; let k = if is_full_width(ch) { em_scale(face) @@ -594,11 +723,12 @@ fn outline_char(data: &[u8], index: u32, face: &ttf_parser::Face, ch: char) -> O outline_glyph_into(data, index, face, gid, &mut fl); fl.flush(); let fill_tris = triangulate_contours(&fl.contours); - Some(Glyph { + let glyph = Glyph { strokes: fl.contours, advance, fill_tris, - }) + }; + Some((glyph, source_for(data, index, face, gid, k))) } /// Outline `ch` from the face cosmic-text picks for it: it knows the platform's @@ -608,7 +738,7 @@ fn outline_char(data: &[u8], index: u32, face: &ttf_parser::Face, ch: char) -> O /// does, for every character, because ttf-parser reads no contours out of it — /// so an empty outline is not an answer and the caller has to keep looking. #[cfg(not(target_arch = "wasm32"))] -fn outline_from_fallback_face(ch: char) -> Option> { +fn outline_from_fallback_face(ch: char) -> Option<(Arc, Option)> { use cosmic_text::{Attrs, Buffer, Metrics, Shaping}; let mut fs = font_system().lock().unwrap(); // Default family → cosmic's own fallback search chooses a covering font. @@ -627,13 +757,13 @@ fn outline_from_fallback_face(ch: char) -> Option> { let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0); let font = fs.get_font(g.font_id, g.font_weight)?; let face = ttf_parser::Face::parse(font.data(), face_index).ok()?; - let Some(glyph) = outline_char(font.data(), face_index, &face, ch) else { + let Some((glyph, source)) = outline_char(font.data(), face_index, &face, ch) else { continue; }; if glyph.strokes.is_empty() && glyph.fill_tris.is_empty() { continue; } - return Some(Arc::new(glyph)); + return Some((Arc::new(glyph), source)); } } None @@ -645,11 +775,11 @@ fn outline_from_fallback_face(ch: char) -> Option> { /// and covers neither Cyrillic nor Greek, however many fonts the machine has /// that do. #[cfg(not(target_arch = "wasm32"))] -fn installed_family_glyph(ch: char) -> Option> { +fn installed_family_glyph(ch: char) -> Option<(Arc, Option)> { crate::scene::text::sysfont::families() .iter() .find_map(|family| { - let glyph = sysfont::with_face_data(family, |data, index| { + let (glyph, source) = sysfont::with_face_data(family, |data, index| { let face = ttf_parser::Face::parse(data, index).ok()?; outline_char(data, index, &face, ch) }) @@ -657,7 +787,7 @@ fn installed_family_glyph(ch: char) -> Option> { // A face that yields no outline (the system font, a space) is not a // font that draws this character. (!glyph.strokes.is_empty() || !glyph.fill_tris.is_empty()) - .then(|| Arc::new(glyph)) + .then(|| (Arc::new(glyph), source)) }) }