diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 90d0f901b..8609fe4e0 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -18,10 +18,10 @@ use crate::scene::model::image_model::ImageModel; use crate::scene::model::wire_model::SearchableTextRun; #[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, - TextRenderingMode, 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, TextRenderingMode, WindingOrder, }; use std::path::Path; @@ -433,10 +433,21 @@ 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, &fonts) - .map_err(|error| format!("Page {}: {error}", index + 1))?; + append_pdf_page( + &mut doc, + &mut image_resources, + &mut text_fonts, + page, + plot_style, + &fonts, + ) + .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 @@ -454,6 +465,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>, fonts: &SearchableFontSet, @@ -659,15 +671,14 @@ fn append_pdf_page( continue; } DrawItem::Text(wire) => { - // Searchable layer + outlines coexist: the vector outlines - // always draw (pixel-identical plots, decorations, stroke - // fonts, shaped/mixed scripts all keep working) and the - // preserved runs add an *invisible* (`Tr 3`) text layer on - // top for searching, copying and screen readers. Zero - // visual change by construction — a gap in the text path - // can only affect extractability, never the rendered sheet. - emit_text( + // Outlined runs (stroke fonts, shaped/mixed scripts) keep + // their vector outlines and add an *invisible* (`Tr 3`) text + // layer on top for searching, copying and screen readers. + // A run that went out as embedded-font text is already + // searchable; only outlined runs need the invisible layer. + let embedded = emit_text( &mut ops, + text_fonts, std::slice::from_ref(&wire.wire), ox, oy, @@ -675,16 +686,18 @@ fn append_pdf_page( plot_style, options, ); - emit_searchable_text( - &mut ops, - std::slice::from_ref(&wire.wire), - ox, - oy, - clip, - plot_style, - options, - fonts, - ); + if !embedded { + emit_searchable_text( + &mut ops, + std::slice::from_ref(&wire.wire), + ox, + oy, + clip, + plot_style, + options, + fonts, + ); + } last_color = None; last_lw = None; last_dash = None; @@ -1916,25 +1929,24 @@ fn subset_family( #[cfg(not(target_arch = "wasm32"))] fn emit_text( ops: &mut Vec, + fonts: &mut TextFonts, wires: &[WireModel], ox: f64, oy: f64, scale: f32, plot_style: Option<&PlotStyleTable>, options: PdfPlotOptions, -) { +) -> bool { use crate::scene::text::sdf_atlas; if wires.iter().all(|w| w.text_verts.is_empty()) { - return; + return false; } - // 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 false; }; + 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 @@ -1944,11 +1956,18 @@ fn emit_text( dash: LineDashPattern::default(), }); + let mut embedded = true; for wire in wires { let verts = &wire.text_verts; if verts.is_empty() { + embedded = false; continue; } + // A run drawn wholly in faces the PDF can embed goes out as real text + // and is its own searchable layer. Any other run keeps its outlines and + // the invisible layer carries its text: one copy of the text either way. + let embed = fonts.embeds_all(verts, table, solid_key); + embedded &= embed; // Mirror the wire pass: indexed style color, screening, and pen width. let mut ctb_color: Option<[f32; 3]> = None; let mut lw_override: Option = None; @@ -2016,10 +2035,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.as_ref().filter(|_| embed) { + 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 { @@ -2099,6 +2130,281 @@ fn emit_text( } } } + embedded +} + +// ── 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 = self.slot(source)?; + let font = &mut self.fonts[slot]; + font.used.entry(source.gid).or_insert(ch); + Some(font.id.clone()) + } + + /// The `fonts` slot of `source`'s face; `None` when printpdf can't parse it. + fn slot(&mut self, source: &crate::scene::text::ttf_glyph::GlyphSource) -> Option { + 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 + } + } + } + + /// Whether every glyph of `verts` is a filled glyph of a face the PDF can + /// embed, so the whole run can go out as real text. + fn embeds_all( + &mut self, + verts: &[crate::scene::pipeline::text_gpu::TextVertex], + table: &std::collections::HashMap, + solid_key: u64, + ) -> bool { + use crate::scene::text::sdf_atlas; + let mut any = false; + for quad in verts.chunks_exact(6) { + if quad[0].color[3] < 0.01 { + continue; + } + let key = sdf_atlas::uv_key([quad[5].uv[0], quad[5].uv[1]]); + if key == solid_key { + continue; + } + let Some(ge) = table.get(&key) else { + continue; + }; + let Some(source) = ge.source.as_ref().filter(|_| !ge.fill_tris.is_empty()) else { + return false; + }; + if self.slot(source).is_none() { + return false; + } + any = true; + } + any + } + + /// 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) { + // A face only probed by `embeds_all` for a run that kept its outlines + // draws nothing. + for font in self.fonts.into_iter().filter(|font| !font.used.is_empty()) { + // `.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 @@ -2570,4 +2876,82 @@ mod tests { assert!(stream.contains("IN"), "inside run must survive the clip"); assert!(!stream.contains("OUT"), "outside run must be culled by the clip"); } + // 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)) }) }