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) <noreply@anthropic.com>
This commit is contained in:
Chris YehandClaude Opus 5.5 committed 2026-10-01 23:07:15 +08:00
1 parent 6cc53c1001
commit a8dc6534f9
6 files changed
+539 -22

No files matched your search

+28
View File
@@ -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<ttf_glyph::GlyphSource> {
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 {
+18
View File
@@ -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<crate::scene::text::ttf_glyph::GlyphSource>,
}
/// 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,
},
);
}
+139 -9
View File
@@ -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<u8>,
/// 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<FontBlob>,
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<FaceKey> {
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<Arc<FontBlob>> {
static BLOBS: OnceLock<Mutex<HashMap<FaceKey, Arc<FontBlob>>>> = 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<GlyphSource> {
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<GlyphSource> {
type SourceCache = HashMap<(String, char), Option<GlyphSource>>;
static CACHE: OnceLock<Mutex<SourceCache>> = 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<GlyphSource> {
static CACHE: OnceLock<Mutex<HashMap<char, Option<GlyphSource>>>> = 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<Arc<Glyph>> {
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<Glyph>, Option<GlyphSource>)> {
outline_from_fallback_face(ch).or_else(|| installed_family_glyph(ch))
}
@@ -582,7 +706,12 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
/// 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<Glyph> {
fn outline_char(
data: &[u8],
index: u32,
face: &ttf_parser::Face,
ch: char,
) -> Option<(Glyph, Option<GlyphSource>)> {
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<Arc<Glyph>> {
fn outline_from_fallback_face(ch: char) -> Option<(Arc<Glyph>, Option<GlyphSource>)> {
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<Arc<Glyph>> {
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<Arc<Glyph>> {
/// 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<Arc<Glyph>> {
fn installed_family_glyph(ch: char) -> Option<(Arc<Glyph>, Option<GlyphSource>)> {
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<Arc<Glyph>> {
// 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))
})
}