forked from 1b-agent/opencadstudio-upstream
Merge PR #1580 from Chrisyeh1983/fix/pdf-embed-truetype-text: export TrueType text as embedded subset fonts
Conflicts with 02c470aa (invisible searchable text layer): main already
depends on allsorts 0.16 with the same features, so its line stays. Both
layers would put the text into the PDF twice, so a run drawn wholly in
faces the PDF can embed now goes out as embedded-font text and skips the
invisible layer; any other run (stroke fonts, mixed scripts) keeps its
outlines and the invisible layer. Faces only probed for such a run are
not installed. The PR's test follows layout_glyph_quads' new tuple.
This commit is contained in:
commit
c492539fde
4 files changed
+602
-42
No files matched your search
+417
-33
@@ -18,10 +18,10 @@ use crate::scene::model::image_model::ImageModel;
|
|||||||
use crate::scene::model::wire_model::SearchableTextRun;
|
use crate::scene::model::wire_model::SearchableTextRun;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use printpdf::{
|
use printpdf::{
|
||||||
BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line,
|
BlendMode, BuiltinFont, Codepoint, Color, ExtendedGraphicsState, ExtendedGraphicsStateId,
|
||||||
LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, PdfDocument,
|
FontId, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode,
|
||||||
PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, TextItem,
|
ParsedFont, PdfDocument, PdfFont, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon,
|
||||||
TextRenderingMode, WindingOrder,
|
PolygonRing, Pt, Rgb, TextItem, TextMatrix, TextRenderingMode, WindingOrder,
|
||||||
};
|
};
|
||||||
use std::path::Path;
|
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.
|
// Borrowing all pages keeps their pixel Arcs alive until this cache is dropped.
|
||||||
// Allocation addresses cannot be reused by another source during this export.
|
// Allocation addresses cannot be reused by another source during this export.
|
||||||
let mut image_resources = std::collections::HashMap::new();
|
let mut image_resources = std::collections::HashMap::new();
|
||||||
|
let mut text_fonts = TextFonts::default();
|
||||||
for (index, page) in pages.iter().enumerate() {
|
for (index, page) in pages.iter().enumerate() {
|
||||||
append_pdf_page(&mut doc, &mut image_resources, page, plot_style, &fonts)
|
append_pdf_page(
|
||||||
.map_err(|error| format!("Page {}: {error}", index + 1))?;
|
&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();
|
let mut warnings = Vec::new();
|
||||||
// printpdf 0.9's `optimize` is a no-op (its `doc.compress()` is commented
|
// 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
|
// 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(
|
fn append_pdf_page(
|
||||||
doc: &mut PdfDocument,
|
doc: &mut PdfDocument,
|
||||||
image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
|
image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
|
||||||
|
text_fonts: &mut TextFonts,
|
||||||
page: &PdfPageInput,
|
page: &PdfPageInput,
|
||||||
fallback_plot_style: Option<&PlotStyleTable>,
|
fallback_plot_style: Option<&PlotStyleTable>,
|
||||||
fonts: &SearchableFontSet,
|
fonts: &SearchableFontSet,
|
||||||
@@ -659,15 +671,14 @@ fn append_pdf_page(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
DrawItem::Text(wire) => {
|
DrawItem::Text(wire) => {
|
||||||
// Searchable layer + outlines coexist: the vector outlines
|
// Outlined runs (stroke fonts, shaped/mixed scripts) keep
|
||||||
// always draw (pixel-identical plots, decorations, stroke
|
// their vector outlines and add an *invisible* (`Tr 3`) text
|
||||||
// fonts, shaped/mixed scripts all keep working) and the
|
// layer on top for searching, copying and screen readers.
|
||||||
// preserved runs add an *invisible* (`Tr 3`) text layer on
|
// A run that went out as embedded-font text is already
|
||||||
// top for searching, copying and screen readers. Zero
|
// searchable; only outlined runs need the invisible layer.
|
||||||
// visual change by construction — a gap in the text path
|
let embedded = emit_text(
|
||||||
// can only affect extractability, never the rendered sheet.
|
|
||||||
emit_text(
|
|
||||||
&mut ops,
|
&mut ops,
|
||||||
|
text_fonts,
|
||||||
std::slice::from_ref(&wire.wire),
|
std::slice::from_ref(&wire.wire),
|
||||||
ox,
|
ox,
|
||||||
oy,
|
oy,
|
||||||
@@ -675,16 +686,18 @@ fn append_pdf_page(
|
|||||||
plot_style,
|
plot_style,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
emit_searchable_text(
|
if !embedded {
|
||||||
&mut ops,
|
emit_searchable_text(
|
||||||
std::slice::from_ref(&wire.wire),
|
&mut ops,
|
||||||
ox,
|
std::slice::from_ref(&wire.wire),
|
||||||
oy,
|
ox,
|
||||||
clip,
|
oy,
|
||||||
plot_style,
|
clip,
|
||||||
options,
|
plot_style,
|
||||||
fonts,
|
options,
|
||||||
);
|
fonts,
|
||||||
|
);
|
||||||
|
}
|
||||||
last_color = None;
|
last_color = None;
|
||||||
last_lw = None;
|
last_lw = None;
|
||||||
last_dash = None;
|
last_dash = None;
|
||||||
@@ -1916,25 +1929,24 @@ fn subset_family(
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn emit_text(
|
fn emit_text(
|
||||||
ops: &mut Vec<Op>,
|
ops: &mut Vec<Op>,
|
||||||
|
fonts: &mut TextFonts,
|
||||||
wires: &[WireModel],
|
wires: &[WireModel],
|
||||||
ox: f64,
|
ox: f64,
|
||||||
oy: f64,
|
oy: f64,
|
||||||
scale: f32,
|
scale: f32,
|
||||||
plot_style: Option<&PlotStyleTable>,
|
plot_style: Option<&PlotStyleTable>,
|
||||||
options: PdfPlotOptions,
|
options: PdfPlotOptions,
|
||||||
) {
|
) -> bool {
|
||||||
use crate::scene::text::sdf_atlas;
|
use crate::scene::text::sdf_atlas;
|
||||||
|
|
||||||
if wires.iter().all(|w| w.text_verts.is_empty()) {
|
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.
|
// The atlas' baked-glyph geometry, snapshotted once per export.
|
||||||
let (table, solid_key) = {
|
let Some(snapshot) = fonts.glyph_table() else {
|
||||||
let Ok(atlas) = sdf_atlas::text_atlas().lock() else {
|
return false;
|
||||||
return;
|
|
||||||
};
|
|
||||||
(atlas.export_table(), sdf_atlas::uv_key(atlas.solid_uv()))
|
|
||||||
};
|
};
|
||||||
|
let (table, solid_key) = (&snapshot.0, snapshot.1);
|
||||||
|
|
||||||
// `Op::SetLineDashPattern` is persistent graphics state and the wire pass
|
// `Op::SetLineDashPattern` is persistent graphics state and the wire pass
|
||||||
// above only re-emits it on change, so whatever the last wire needed is
|
// above only re-emits it on change, so whatever the last wire needed is
|
||||||
@@ -1944,11 +1956,18 @@ fn emit_text(
|
|||||||
dash: LineDashPattern::default(),
|
dash: LineDashPattern::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mut embedded = true;
|
||||||
for wire in wires {
|
for wire in wires {
|
||||||
let verts = &wire.text_verts;
|
let verts = &wire.text_verts;
|
||||||
if verts.is_empty() {
|
if verts.is_empty() {
|
||||||
|
embedded = false;
|
||||||
continue;
|
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.
|
// Mirror the wire pass: indexed style color, screening, and pen width.
|
||||||
let mut ctb_color: Option<[f32; 3]> = None;
|
let mut ctb_color: Option<[f32; 3]> = None;
|
||||||
let mut lw_override: Option<f32> = None;
|
let mut lw_override: Option<f32> = None;
|
||||||
@@ -2016,10 +2035,22 @@ fn emit_text(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !ge.fill_tris.is_empty() {
|
if !ge.fill_tris.is_empty() {
|
||||||
// Filled TrueType glyph: one filled triangle per triple.
|
|
||||||
ops.push(Op::SetFillColor {
|
ops.push(Op::SetFillColor {
|
||||||
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
|
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) {
|
for tri in ge.fill_tris.chunks_exact(3) {
|
||||||
ops.push(Op::DrawPolygon {
|
ops.push(Op::DrawPolygon {
|
||||||
polygon: Polygon {
|
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, crate::scene::text::sdf_atlas::GlyphExport>,
|
||||||
|
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<GlyphTable>,
|
||||||
|
fonts: Vec<EmbeddedFont>,
|
||||||
|
/// 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<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
struct EmbeddedFont {
|
||||||
|
blob: std::sync::Arc<crate::scene::text::ttf_glyph::FontBlob>,
|
||||||
|
id: FontId,
|
||||||
|
/// Original glyph id → the character it draws (for ToUnicode).
|
||||||
|
used: std::collections::BTreeMap<u16, char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<GlyphTable> {
|
||||||
|
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<FontId> {
|
||||||
|
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<usize> {
|
||||||
|
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<u64, crate::scene::text::sdf_atlas::GlyphExport>,
|
||||||
|
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<u16> = 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<u16, u16> = 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<Vec<u8>> {
|
||||||
|
use allsorts::binary::read::ReadScope;
|
||||||
|
use allsorts::font_data::FontData;
|
||||||
|
use allsorts::subset::{subset, CmapTarget, SubsetProfile};
|
||||||
|
|
||||||
|
let font = ReadScope::new(&blob.data).read::<FontData<'_>>().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<u16, u16>,
|
||||||
|
) {
|
||||||
|
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<Op>, 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
|
/// 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("IN"), "inside run must survive the clip");
|
||||||
assert!(!stream.contains("OUT"), "outside run must be culled by 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
/// Extra spacing added after every glyph (9-unit). TTF advances already
|
||||||
/// include side bearings, so no extra tracking is added there.
|
/// include side bearings, so no extra tracking is added there.
|
||||||
pub fn letter_spacing(&self) -> f32 {
|
pub fn letter_spacing(&self) -> f32 {
|
||||||
|
|||||||
@@ -164,6 +164,12 @@ pub struct GlyphExport {
|
|||||||
pub fill_tris: Vec<[f32; 2]>,
|
pub fill_tris: Vec<[f32; 2]>,
|
||||||
/// Baked with the 1.7× bold pen; the exporter widens its own pen to match.
|
/// Baked with the 1.7× bold pen; the exporter widens its own pen to match.
|
||||||
pub bold: bool,
|
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
|
/// 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())
|
.entry(family.as_str())
|
||||||
.or_insert_with(|| Face::resolve(family));
|
.or_insert_with(|| Face::resolve(family));
|
||||||
let Some(g) = face.glyph(*ch) else { continue };
|
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(
|
out.insert(
|
||||||
uv_key(entry.uv_min),
|
uv_key(entry.uv_min),
|
||||||
GlyphExport {
|
GlyphExport {
|
||||||
@@ -328,6 +344,8 @@ impl GlyphAtlas {
|
|||||||
// share the centrelines, so carry the flag and let the exporter
|
// share the centrelines, so carry the flag and let the exporter
|
||||||
// widen its own pen by the same factor.
|
// widen its own pen by the same factor.
|
||||||
bold: *bold,
|
bold: *bold,
|
||||||
|
ch: *ch,
|
||||||
|
source,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-9
@@ -446,6 +446,122 @@ fn triangulate_contours(contours: &[Vec<[f32; 2]>]) -> Vec<[f32; 2]> {
|
|||||||
tris
|
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 ────────────────────────────────────────────────────────────────
|
// ── Shaping ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// One shaped glyph, positioned within its run. Strokes are in 9-unit space and
|
/// 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.
|
/// no stroke font provides.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
|
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))
|
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
|
/// 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.
|
/// cap-height normalisation that lines it up with the Latin stroke glyphs.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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 gid = face.glyph_index(ch)?;
|
||||||
let k = if is_full_width(ch) {
|
let k = if is_full_width(ch) {
|
||||||
em_scale(face)
|
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);
|
outline_glyph_into(data, index, face, gid, &mut fl);
|
||||||
fl.flush();
|
fl.flush();
|
||||||
let fill_tris = triangulate_contours(&fl.contours);
|
let fill_tris = triangulate_contours(&fl.contours);
|
||||||
Some(Glyph {
|
let glyph = Glyph {
|
||||||
strokes: fl.contours,
|
strokes: fl.contours,
|
||||||
advance,
|
advance,
|
||||||
fill_tris,
|
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
|
/// 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 —
|
/// 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.
|
/// so an empty outline is not an answer and the caller has to keep looking.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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};
|
use cosmic_text::{Attrs, Buffer, Metrics, Shaping};
|
||||||
let mut fs = font_system().lock().unwrap();
|
let mut fs = font_system().lock().unwrap();
|
||||||
// Default family → cosmic's own fallback search chooses a covering font.
|
// 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 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 font = fs.get_font(g.font_id, g.font_weight)?;
|
||||||
let face = ttf_parser::Face::parse(font.data(), face_index).ok()?;
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
if glyph.strokes.is_empty() && glyph.fill_tris.is_empty() {
|
if glyph.strokes.is_empty() && glyph.fill_tris.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return Some(Arc::new(glyph));
|
return Some((Arc::new(glyph), source));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
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
|
/// and covers neither Cyrillic nor Greek, however many fonts the machine has
|
||||||
/// that do.
|
/// that do.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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()
|
crate::scene::text::sysfont::families()
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|family| {
|
.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()?;
|
let face = ttf_parser::Face::parse(data, index).ok()?;
|
||||||
outline_char(data, index, &face, ch)
|
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
|
// A face that yields no outline (the system font, a space) is not a
|
||||||
// font that draws this character.
|
// font that draws this character.
|
||||||
(!glyph.strokes.is_empty() || !glyph.fill_tris.is_empty())
|
(!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