feat(pdf): searchable text layer with per-style subset font embedding

Real BT/Tj text objects ride alongside the vector outlines (Tr 3,
zero visual change): runs preserved at layout time, TTF styles embedded
as allsorts subsets with ToUnicode, width-matched Tm, clip-culled.
WireModel literals moved to ..Default::default() tails.
This commit is contained in:
Karim Jerbi committed 2026-10-01 23:30:35 +01:00
1 parent 443b935a3f
commit 02c470aac7
44 files changed
+1534 -127

No files matched your search

+28 -2
View File
@@ -147,6 +147,7 @@ pub fn apply_along(
// Embedded linetype text (LtSeg::Text) renders as SDF glyph quads collected
// here; emitted as one extra text-carrying WireModel at the end.
let mut text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
let mut searchable: Vec<crate::scene::model::wire_model::SearchableTextRun> = Vec::new();
let mut elem_idx: usize = 0;
let mut elem_consumed: f32 = 0.0;
@@ -269,7 +270,7 @@ pub fn apply_along(
// SDF: glyph quads at the insert point (rotation baked in by
// layout_glyph_quads), collected for the text wire.
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
let (quads, pen_adv) = crate::scene::text::glyph_quads::layout_glyph_quads(
&mut atlas,
*tx_scale,
fwd_angle,
@@ -291,6 +292,28 @@ pub fn apply_along(
color,
0.0,
);
{
use crate::scene::model::wire_model::{
clean_searchable_text, SearchableTextRun,
};
let visible = clean_searchable_text(&resolved);
if !visible.is_empty() {
searchable.push(SearchableTextRun {
text: visible,
origin: [
insert[0] as f64,
insert[1] as f64,
insert[2] as f64,
],
height: *tx_scale,
rotation: fwd_angle,
color,
bold: false,
font: style.to_string(),
adv_width: pen_adv,
});
}
}
}
elem_idx += 1;
@@ -342,7 +365,9 @@ pub fn apply_along(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
..Default::default()
})
.collect();
// Embedded linetype text (SDF): one extra wire carrying the glyph quads,
@@ -376,6 +401,7 @@ pub fn apply_along(
dash_from_start: false,
dash_align_end: None,
text_verts,
searchable_text: searchable,
name: name.to_string(),
points: Vec::new(),
points_low: Vec::new(),
+57 -12
View File
@@ -34,6 +34,13 @@ pub struct GlyphQuad {
/// Lay `text` out as per-glyph quads. Mirrors `tessellate_text_run`'s transform
/// and per-character advance so the quads land exactly where the strokes would.
/// Whitespace and glyphs with no ink advance the pen but emit no quad.
///
/// Returns the quads plus the final pen advance in run-local units
/// (`cursor × scale × |width_factor|`, always ≥ 0): the exact laid-out width
/// including spacing/tracking, so exporters can width-match (`Tz`) and cull
/// runs without re-deriving advances from a second layout pass. Mirrored
/// (negative width factor) runs collapse to a positive extent — the search
/// layer reads LTR while the outlines keep the true mirror.
#[allow(clippy::too_many_arguments)]
pub fn layout_glyph_quads(
atlas: &mut GlyphAtlas,
@@ -45,9 +52,9 @@ pub fn layout_glyph_quads(
font_name: &str,
bold: bool,
text: &str,
) -> Vec<GlyphQuad> {
) -> (Vec<GlyphQuad>, f32) {
if text.is_empty() || height <= 0.0 {
return vec![];
return (vec![], 0.0);
}
let scale = height / 9.0;
@@ -155,6 +162,7 @@ pub fn layout_glyph_quads(
}
}
let pen_advance = (cursor_x * scale * wf).abs();
// Any decoration still open at the end runs to the final pen position.
for (slot, y) in [(under, UNDER_Y), (over, OVER_Y), (strike, STRIKE_Y)] {
if let Some(s) = slot {
@@ -176,7 +184,7 @@ pub fn layout_glyph_quads(
});
}
quads
(quads, pen_advance)
}
// ── Tests ─────────────────────────────────────────────────────────────────
@@ -198,7 +206,8 @@ mod tests {
#[test]
fn glyphs_advance_rightward() {
let mut atlas = GlyphAtlas::new(512, 512);
let quads = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
let (quads, _) =
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
assert_eq!(quads.len(), 2, "two inked glyphs -> two quads");
assert!(
center(&quads[1])[0] > center(&quads[0])[0],
@@ -216,8 +225,10 @@ mod tests {
#[test]
fn space_emits_no_quad_but_advances() {
let mut atlas = GlyphAtlas::new(512, 512);
let ab = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
let a_sp_a = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A A");
let (ab, _) =
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
let (a_sp_a, _) =
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A A");
assert_eq!(a_sp_a.len(), 2, "space produces no quad");
assert!(
center(&a_sp_a[1])[0] > center(&ab[1])[0],
@@ -231,8 +242,9 @@ mod tests {
// Use the baseline advance direction (glyph0 -> glyph1), which is +x when
// flat and swings to +y after a 90° turn — independent of where a single
// glyph's box centre sits within the cap height.
let flat = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
let turned = layout_glyph_quads(
let (flat, _) =
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
let (turned, _) = layout_glyph_quads(
&mut atlas,
10.0,
std::f32::consts::FRAC_PI_2,
@@ -256,8 +268,10 @@ mod tests {
#[test]
fn taller_text_makes_bigger_quads() {
let mut atlas = GlyphAtlas::new(512, 512);
let small = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A");
let big = layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A");
let (small, _) =
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A");
let (big, _) =
layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A");
let extent = |q: &GlyphQuad| {
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
for p in &q.corners {
@@ -278,13 +292,44 @@ mod tests {
#[test]
fn underline_emits_an_extra_solid_quad() {
let mut atlas = GlyphAtlas::new(512, 512);
let plain = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB");
let (plain, plain_adv) =
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB");
// `\L…\l` underlines the run — same glyph quads plus one decoration quad.
let under = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "\\LAB\\l");
let (under, under_adv) =
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "\\LAB\\l");
assert_eq!(under.len(), plain.len() + 1, "underline adds one quad");
// The decoration quad samples the atlas's solid texel (degenerate UV).
let deco = under.last().unwrap();
assert_eq!(deco.uv_min, deco.uv_max, "decoration quad uses the solid UV");
assert_eq!(deco.uv_min, atlas.solid_uv());
// Decorations don't move the pen: same advance either way, and the
// advance covers both glyphs (positive, wider than one glyph).
assert!(
(under_adv - plain_adv).abs() < 1e-6,
"decoration must not move the pen: {under_adv} vs {plain_adv}"
);
assert!(plain_adv > 0.0, "two glyphs advance the pen");
}
#[test]
fn pen_advance_scales_with_height_and_width_factor() {
let mut atlas = GlyphAtlas::new(512, 512);
let (_, base) =
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB");
let (_, tall) =
layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB");
assert!(
(tall / base - 2.0).abs() < 0.05,
"double height ~ double advance: {tall} vs {base}"
);
let (_, wide) =
layout_glyph_quads(&mut atlas, 10.0, 0.0, 2.0, 0.0, 1.0, "txt", false, "AB");
assert!(
(wide / base - 2.0).abs() < 0.05,
"double width factor ~ double advance: {wide} vs {base}"
);
let (_, empty) =
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "");
assert_eq!(empty, 0.0, "empty text advances nothing");
}
}
+20
View File
@@ -139,10 +139,18 @@ fn canonical_family_name_uncached(family: &str) -> Option<String> {
/// Resolve a family name to a concrete face id (regular weight/style).
fn face_id(family: &str) -> Option<fontdb::ID> {
face_id_weighted(family, fontdb::Weight::NORMAL)
}
/// Resolve a family name to a concrete face id at the requested weight
/// (e.g. bold runs). Falls back through fontdb's fuzzy matching, so a
/// family without a bold face still resolves — to its regular face.
fn face_id_weighted(family: &str, weight: fontdb::Weight) -> Option<fontdb::ID> {
let db = &fonts().db;
let canonical = canonical_family_name(family)?;
let query = fontdb::Query {
families: &[fontdb::Family::Name(&canonical)],
weight,
..Default::default()
};
db.query(&query)
@@ -157,6 +165,18 @@ pub fn with_face_data<T>(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Optio
fonts().db.with_face_data(id, f)
}
/// Borrow the raw face bytes for `family` at `weight` (see
/// [`face_id_weighted`]). The PDF exporter uses this to embed the bold face
/// for bold runs instead of faux-stroking the regular one.
pub fn with_face_data_weighted<T>(
family: &str,
weight: fontdb::Weight,
f: impl FnOnce(&[u8], u32) -> T,
) -> Option<T> {
let id = face_id_weighted(family, weight)?;
fonts().db.with_face_data(id, f)
}
/// The family of the installed face whose file is named `name` — a bare file
/// name or any path to one, matched case-insensitively.
fn family_for_file(name: &str) -> Option<String> {