forked from 1b-agent/opencadstudio-upstream
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:
1 parent
6cc53c1001
commit
a8dc6534f9
6 files changed
+539
-22
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user