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

Generated
+1
View File
@@ -6,6 +6,7 @@ version = 4
name = "OpenCADStudio"
version = "2026.39.0"
dependencies = [
"allsorts",
"ashpd",
"base64",
"bincode",
+5
View File
@@ -103,6 +103,11 @@ i18n-embed = { version = "0.16", features = ["desktop-requester"] }
rayon = "1"
open = "5"
printpdf = { version = "0.9.1", default-features = false, features = ["images"] }
# TrueType subsetting for searchable PDF text (same version printpdf itself
# uses, so no new binary enters the lockfile). Same feature set as printpdf's
# own allsorts use (pure-Rust flate backend) — the defaults would drag in the
# libz-sys C dependency. Native-only like printpdf.
allsorts = { version = "0.16", default-features = false, features = ["flate2_rust"] }
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
+3 -1
View File
@@ -2865,7 +2865,9 @@ impl OpenCADStudio {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
};
..Default::default()
};
previews.push(guide);
}
}
+30 -7
View File
@@ -4051,7 +4051,9 @@ fn tessellate_dimension_inner(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
if !ext2.is_empty() {
wires.push(WireModel {
@@ -4088,7 +4090,9 @@ fn tessellate_dimension_inner(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
} else {
wires.push(WireModel {
@@ -4125,7 +4129,9 @@ fn tessellate_dimension_inner(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
}
@@ -4168,7 +4174,9 @@ fn tessellate_dimension_inner(
// — not a crash. Follow-up: double-single-split via points_to_ds to
// match emit_wire's paired fill path.
fill_tris_low: Vec::new(),
});
..Default::default()
});
// Arrowheads and ticks: the dimension line's colour and weight, never
// its dashes.
if !geom.arrow_lines.is_empty() {
@@ -4224,7 +4232,9 @@ fn tessellate_dimension_inner(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
// DIMTFILL: 0=none, 1=drawing background (mask), 2=DIMTFILLCLR.
@@ -4279,7 +4289,9 @@ fn tessellate_dimension_inner(
// (sub-metre error at UTM scale) — not a crash. Follow-up:
// double-single-split via points_to_ds to match emit_wire.
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
}
}
@@ -4326,7 +4338,9 @@ fn tessellate_dimension_inner(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
}
@@ -5855,6 +5869,15 @@ fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) {
&wire.text_verts,
map,
);
// OCS→WCS is rigid: unit height scale, no in-plane rotation change.
wire.map_searchable_runs(
&|p| {
let mapped = map(p[0], p[1], p[2]);
[mapped.0, mapped.1, mapped.2]
},
1.0,
0.0,
);
for (point, _) in &mut wire.snap_pts {
let mapped = map(point.x, point.y, point.z);
*point = DVec3::new(mapped.0, mapped.1, mapped.2);
+2
View File
@@ -705,6 +705,7 @@ impl LeaderTess for Leader {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
..Default::default()
};
}
@@ -887,6 +888,7 @@ impl LeaderTess for Leader {
plinegen: true,
fill_tris,
fill_tris_low,
..Default::default()
}
}
}
+45 -15
View File
@@ -2106,7 +2106,9 @@ impl MultiLeaderTess for MultiLeader {
// error at UTM scale) — not a crash. Follow-up: double-single-split
// via points_to_ds to match emit_wire's paired fill path.
fill_tris_low: Vec::new(),
});
..Default::default()
});
let host = codec::EntityType::MultiLeader(Box::new(ml.clone()));
for block_use in crate::scene::render_graph::entity_render_block_uses(document, &host, 1.0)
@@ -2299,6 +2301,8 @@ impl MultiLeaderTess for MultiLeader {
// cluster-position data.
let mut deco_pts: Vec<[f32; 3]> = Vec::new();
let mut deco_fill: Vec<[f32; 3]> = Vec::new();
let mut searchable: Vec<crate::scene::model::wire_model::SearchableTextRun> =
Vec::new();
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
for ts in &layout.strokes {
let is_shaped = ts
@@ -2331,17 +2335,18 @@ impl MultiLeaderTess for MultiLeader {
continue;
}
let run = ts.run.as_ref().unwrap();
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
&mut atlas,
run.height,
run.rotation,
run.width_factor,
run.oblique,
run.tracking,
&run.font,
run.bold,
&run.text,
);
let (quads, pen_adv) =
crate::scene::text::glyph_quads::layout_glyph_quads(
&mut atlas,
run.height,
run.rotation,
run.width_factor,
run.oblique,
run.tracking,
&run.font,
run.bold,
&run.text,
);
// Inline `\C` / `\c` colour wins over the entity text
// colour, matching the top-level Text arm.
let gcolor = ts
@@ -2356,6 +2361,24 @@ impl MultiLeaderTess for MultiLeader {
gcolor,
0.0,
);
{
use crate::scene::model::wire_model::{
clean_searchable_text, SearchableTextRun,
};
let visible = clean_searchable_text(&run.text);
if !visible.is_empty() {
searchable.push(SearchableTextRun {
text: visible,
origin: [ts.origin[0], ts.origin[1], z as f64],
height: run.height,
rotation: run.rotation,
color: gcolor,
bold: run.bold,
font: run.font.clone(),
adv_width: pen_adv,
});
}
}
}
}
if !sdf_verts.is_empty() || !deco_pts.is_empty() || !deco_fill.is_empty() {
@@ -2400,6 +2423,7 @@ impl MultiLeaderTess for MultiLeader {
dash_from_start: false,
dash_align_end: None,
text_verts: sdf_verts,
searchable_text: searchable,
name: name.clone(),
points: deco_pts,
points_low: Vec::new(),
@@ -2492,7 +2516,9 @@ impl MultiLeaderTess for MultiLeader {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
}
@@ -2583,7 +2609,9 @@ impl MultiLeaderTess for MultiLeader {
plinegen: true,
fill_tris,
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
// Text frame — closed rectangle, matches text color.
@@ -2629,7 +2657,9 @@ impl MultiLeaderTess for MultiLeader {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
}
}
+35 -12
View File
@@ -1488,6 +1488,7 @@ pub fn tessellate_table(
// SDF cell text: glyph quads (per-vertex coloured) collected across all
// cells; emitted as one text-carrying wire 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 borders: HashMap<([u8; 4], u32), ([f32; 4], f32, Vec<[f32; 3]>)> = HashMap::default();
let mut emitted: rustc_hash::FxHashSet<(i32, i32, i32, i32, i32, i32)> =
rustc_hash::FxHashSet::default();
@@ -2016,17 +2017,18 @@ pub fn tessellate_table(
}
continue;
}
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
&mut atlas,
run.height,
run.rotation,
run.width_factor,
run.oblique,
run.tracking,
&run.font,
run.bold,
&run.text,
);
let (quads, pen_adv) =
crate::scene::text::glyph_quads::layout_glyph_quads(
&mut atlas,
run.height,
run.rotation,
run.width_factor,
run.oblique,
run.tracking,
&run.font,
run.bold,
&run.text,
);
crate::scene::pipeline::text_gpu::push_glyph_vertices(
&mut text_verts,
&quads,
@@ -2035,6 +2037,24 @@ pub fn tessellate_table(
tcol,
0.0,
);
{
use crate::scene::model::wire_model::{
clean_searchable_text, SearchableTextRun,
};
let visible = clean_searchable_text(&run.text);
if !visible.is_empty() {
searchable.push(SearchableTextRun {
text: visible,
origin: [stroke.origin[0], stroke.origin[1], to.z as f64],
height: run.height,
rotation: run.rotation,
color: tcol,
bold: run.bold,
font: run.font.clone(),
adv_width: pen_adv,
});
}
}
}
}
}
@@ -2084,7 +2104,9 @@ pub fn tessellate_table(
// (sub-metre error at UTM scale) — not a crash. Follow-up:
// double-single-split via points_to_ds to match emit_wire.
fill_tris_low: Vec::new(),
}
..Default::default()
}
};
let mut out: Vec<WireModel> = Vec::new();
@@ -2115,6 +2137,7 @@ pub fn tessellate_table(
let mut w = mk(entity_color, vec![], vec![], line_weight_px);
w.aabb = [nx as f32, ny as f32, xx as f32, xy as f32];
w.text_verts = text_verts;
w.searchable_text = searchable;
out.push(w);
}
out
+635 -4
View File
@@ -15,11 +15,13 @@ use crate::scene::model::hatch_model::HatchPattern;
use crate::scene::WireModel;
use crate::scene::model::image_model::ImageModel;
#[cfg(not(target_arch = "wasm32"))]
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,
WindingOrder,
TextRenderingMode, WindingOrder,
};
use std::path::Path;
@@ -425,11 +427,14 @@ pub async fn pick_pdf_path_async(stem: String) -> Option<std::path::PathBuf> {
#[cfg(not(target_arch = "wasm32"))]
fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result<Vec<u8>, String> {
let mut doc = PdfDocument::new("Open CAD Studio Export");
// Subset-embed one TrueType font per used system family before any page
// references them (fonts live on the document resource dictionary).
let fonts = SearchableFontSet::build(&mut doc, pages);
// 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();
for (index, page) in pages.iter().enumerate() {
append_pdf_page(&mut doc, &mut image_resources, page, plot_style)
append_pdf_page(&mut doc, &mut image_resources, page, plot_style, &fonts)
.map_err(|error| format!("Page {}: {error}", index + 1))?;
}
let mut warnings = Vec::new();
@@ -451,6 +456,7 @@ fn append_pdf_page(
image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
page: &PdfPageInput,
fallback_plot_style: Option<&PlotStyleTable>,
fonts: &SearchableFontSet,
) -> Result<(), String> {
let PlotContent { wires, hatches, wipeouts, images, group_splits } = &page.content;
let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32);
@@ -590,7 +596,7 @@ fn append_pdf_page(
}
draw_items.push((wire.draw_depth, 2u8, sequence, DrawItem::Wire(wire)));
sequence += 1;
if !wire.text_verts.is_empty() {
if !wire.text_verts.is_empty() || !wire.searchable_text.is_empty() {
draw_items.push((wire.draw_depth, 3u8, sequence, DrawItem::Text(wire)));
sequence += 1;
}
@@ -653,6 +659,13 @@ 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(
&mut ops,
std::slice::from_ref(&wire.wire),
@@ -662,6 +675,16 @@ 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,
);
last_color = None;
last_lw = None;
last_dash = None;
@@ -1506,6 +1529,378 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] {
}
}
/// Invisible searchable text layer for laid-out runs (`Tr 3`).
///
/// Each `SearchableTextRun` carries the visible string plus its final
/// world-space baseline origin, cap height, advance width and rotation — all
/// baked by the layout stage (MTEXT `\P` newlines arrive as separate per-line
/// runs, `attachment_point`/width wrapping and block placement already
/// applied), so the exporter only places a `BT … Tj ET` with an
/// arbitrary-rotation text matrix (`Tm`), honouring the MTEXT traps by
/// construction.
///
/// Design (review V2): the vector outlines are **always** drawn — the plot
/// looks pixel-identical with or without this layer — and the runs add an
/// invisible (`Tr 3`) text layer for searching, copying and screen readers.
/// A gap here can only affect extractability, never the rendered sheet.
/// Decorations (underline/overline/strike), stroke fonts, shaped and mixed
/// scripts keep working because their outlines are untouched.
///
/// Font choice per run (review V3 — "why Helvetica, not the DWG font?"):
/// - A run whose style resolves to an installed TrueType family is set in a
/// **subset CIDFontType2 of that exact family** (+ `/ToUnicode`), cut down
/// by `allsorts` to the glyphs the plot uses. Bold runs resolve the bold
/// face via fontdb weight matching.
/// - SHX/LFF stroke fonts have no embeddable font program (there is no TrueType
/// behind `txt`/`romans` — only pen strokes), unresolvable names and CFF/
/// collection edge cases fall back to Base-14 Helvetica/Helvetica-Bold.
/// A Type3 font synthesized from the SDF atlas would cover those exactly and
/// is the natural follow-up; it needs no new layout data.
/// - `Tf` takes an *em* size but `run.height` is a *cap* height: the em size
/// is `height / cap_ratio` (per-font OS/2 sCapHeight, 0.72 for Helvetica) so
/// the invisible run aligns with the drawn cap height.
/// - Width-matching (`Tz`, baked into the `Tm` x-basis — equivalent, and the
/// only horizontal scaling printpdf exposes): `k = laid_advance /
/// natural_advance` from the run's stored pen advance and the font's hmtx
/// advances, so centered/right-aligned runs highlight where they draw.
/// - Scaling: the font size is converted world-mm → points via `MM_TO_PT`;
/// the page CTM (plot scale/rotation/clip) applies on top inside the same
/// graphics state as the wire geometry, so text and outlines scale together.
/// - Runs fully outside the export-window clip are skipped (rect overlap on
/// the run rect — same space as the clip polygon).
///
/// Returns the number of runs emitted.
#[cfg(not(target_arch = "wasm32"))]
fn emit_searchable_text(
ops: &mut Vec<Op>,
wires: &[WireModel],
ox: f64,
oy: f64,
clip: Option<(f32, f32, f32, f32)>,
plot_style: Option<&PlotStyleTable>,
options: PdfPlotOptions,
fonts: &SearchableFontSet,
) -> usize {
use crate::scene::model::wire_model::run_rect_overlap;
/// Helvetica cap height ≈ 0.72 em.
const HELVETICA_CAP_RATIO: f32 = 0.72;
let mut emitted = 0;
for wire in wires {
if wire.searchable_text.is_empty() {
continue;
}
let mut ctb_color: Option<[f32; 3]> = None;
let mut screening = 1.0;
if let Some(ctb) = plot_style {
if wire.aci > 0 {
ctb_color = ctb.resolve_color(wire.aci);
screening = ctb.resolve_screening(wire.aci);
}
}
for run in &wire.searchable_text {
if run.text.is_empty() || run.height <= 0.0 || !run.height.is_finite() {
continue;
}
// Export-window clip culls by run rect (origin + advance × cap
// height box); the survivors inherit the clip path anyway.
if let Some((cx, cy, cw, ch)) = clip {
let px = run.origin[0] + ox;
let py = run.origin[1] + oy;
if !run_rect_overlap(
[px, py],
run.rotation,
run.adv_width as f64,
run.height as f64,
[cx as f64, cy as f64, (cx + cw) as f64, (cy + ch) as f64],
) {
continue;
}
}
// Per-run font choice: the DWG style's own family when a subset
// is embedded, else the WinAnsi-gated Helvetica fallback.
enum RunFont<'a> {
Subset(&'a EmbeddedSubset),
Helvetica,
}
let choice = match crate::scene::text::font_face::Face::resolve(&run.font) {
crate::scene::text::font_face::Face::Ttf { family, .. } => fonts
.find(&family, run.bold)
.map(RunFont::Subset)
.unwrap_or(RunFont::Helvetica),
_ => RunFont::Helvetica,
};
if matches!(choice, RunFont::Helvetica) && !is_winansi_encodable(&run.text) {
continue;
}
let rgb = ctb_color.unwrap_or_else(|| adapt_text_color([run.color[0], run.color[1], run.color[2]]));
let [r, g, b] = plotted_color(rgb, run.color[3], screening, options);
// World mm → page points; the page CTM (plot scale/rotation/clip)
// applies on top, exactly as for the wire geometry.
let x_pt = ((run.origin[0] + ox) * MM_TO_PT as f64) as f32;
let y_pt = ((run.origin[1] + oy) * MM_TO_PT as f64) as f32;
if !x_pt.is_finite() || !y_pt.is_finite() {
continue;
}
// Em size from cap height, and width-match factor baked into the
// Tm x-basis (= Tz, the only horizontal scaling printpdf exposes).
let (font_handle, size_pt, width_k) = match choice {
RunFont::Subset(sub) => {
let size = (run.height * MM_TO_PT / sub.cap_ratio).max(0.1);
(PdfFontHandle::External(sub.font_id.clone()), size, sub.width_factor(run))
}
RunFont::Helvetica => {
let size = (run.height * MM_TO_PT / HELVETICA_CAP_RATIO).max(0.1);
let handle = PdfFontHandle::Builtin(if run.bold {
BuiltinFont::HelveticaBold
} else {
BuiltinFont::Helvetica
});
(handle, size, 1.0)
}
};
if !size_pt.is_finite() {
continue;
}
let (sin_r, cos_r) = run.rotation.sin_cos();
ops.push(Op::StartTextSection);
ops.push(Op::SetFont { font: font_handle, size: Pt(size_pt) });
ops.push(Op::SetTextRenderingMode {
mode: TextRenderingMode::Invisible,
});
ops.push(Op::SetTextMatrix {
matrix: printpdf::TextMatrix::Raw([
cos_r * width_k,
sin_r * width_k,
-sin_r,
cos_r,
x_pt,
y_pt,
]),
});
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
});
ops.push(Op::ShowText {
items: vec![TextItem::Text(run.text.clone())],
});
ops.push(Op::EndTextSection);
emitted += 1;
}
}
emitted
}
/// Whether every char in `text` survives Base-14 WinAnsi encoding.
///
/// ASCII + Latin-1 incl. French accents and the `°±Ø` DXF specials — but NOT
/// the C1 control range U+0080–U+009F (unassigned in WinAnsi; emitting them
/// would mojibake). Anything else is rejected per-run so the exporter never
/// emits a wrong `Tj` — those runs keep their outlines and stay unsearchable.
/// (Runs set in an embedded subset bypass this gate: Identity-H covers any
/// glyph the subset carries.)
#[cfg(not(target_arch = "wasm32"))]
fn is_winansi_encodable(text: &str) -> bool {
text.chars().all(|c| {
let cp = c as u32;
cp <= 0xFF && !(0x80..=0x9F).contains(&cp)
})
}
// ── Embedded subset fonts (one system family × weight, cut to used glyphs) ──
/// One embedded subset TrueType font for the search layer: a single system
/// family at one weight, cut by `allsorts` to the glyphs the plot uses.
#[cfg(not(target_arch = "wasm32"))]
struct EmbeddedSubset {
family: String,
bold: bool,
font_id: printpdf::FontId,
/// Subset-space GID per char, re-parsed from the subset bytes (never
/// assumed from allsorts' assignment order).
gid_of: std::collections::BTreeMap<char, u16>,
/// Raw advance per subset GID, in font units (hmtx, for Tz).
width_of: std::collections::BTreeMap<u16, u16>,
upem: u16,
/// Cap-height fraction of em (OS/2 sCapHeight, else 0.7).
cap_ratio: f32,
}
#[cfg(not(target_arch = "wasm32"))]
impl EmbeddedSubset {
/// Width-match factor for a run: laid advance ÷ natural advance at the
/// run's cap height. 1.0 when the widths are unknown (never explodes a
/// selection highlight: clamped to a sane band).
fn width_factor(&self, run: &SearchableTextRun) -> f32 {
let natural: u32 = run
.text
.chars()
.filter_map(|c| self.gid_of.get(&c))
.filter_map(|g| self.width_of.get(g))
.map(|w| *w as u32)
.sum();
if natural == 0 || run.adv_width <= 0.0 || run.height <= 0.0 {
return 1.0;
}
// laid (drawing mm) vs natural at cap height, both in em fractions:
// k = adv / (height * Σw / (upem * cap)).
let k = run.adv_width * self.upem as f32 * self.cap_ratio
/ (run.height * natural as f32);
if !k.is_finite() {
1.0
} else {
k.clamp(0.25, 4.0)
}
}
}
/// Embedded subsets for one export (Helvetica fallback needs no entry).
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
struct SearchableFontSet {
subsets: Vec<EmbeddedSubset>,
}
#[cfg(not(target_arch = "wasm32"))]
impl SearchableFontSet {
/// Collect every TTF-resolvable run's chars per (family, bold), subset
/// each bucket with `allsorts`, and register the subsets on the document.
/// Anything unresolvable (SHX/LFF names, big-font pairs, CFF outlines,
/// missing files) simply gets no entry — those runs take Helvetica.
fn build(doc: &mut PdfDocument, pages: &[PdfPageInput]) -> Self {
use std::collections::{BTreeMap, BTreeSet};
let mut buckets: BTreeMap<(String, bool), BTreeSet<char>> = BTreeMap::new();
for page in pages {
for wire in page.content.wires.iter() {
for run in &wire.searchable_text {
if run.text.is_empty() {
continue;
}
if let crate::scene::text::font_face::Face::Ttf { family, .. } =
crate::scene::text::font_face::Face::resolve(&run.font)
{
buckets
.entry((family, run.bold))
.or_default()
.extend(run.text.chars());
}
}
}
}
let mut out = Self::default();
for ((family, bold), chars) in &buckets {
if let Some(subset) = subset_family(doc, family, *bold, chars) {
out.subsets.push(subset);
}
}
out
}
fn find(&self, family: &str, bold: bool) -> Option<&EmbeddedSubset> {
self.subsets
.iter()
.find(|s| s.family == family && s.bold == bold)
}
}
/// Subset one system family to `chars`, embed the subset, and register it.
/// Returns `None` whenever embedding is impossible or pointless (no file, CFF
/// outlines the stub can't describe, subset failure, nothing mappable) — the
/// caller falls back to Helvetica, never to an invalid font dict.
#[cfg(not(target_arch = "wasm32"))]
fn subset_family(
doc: &mut PdfDocument,
family: &str,
bold: bool,
chars: &std::collections::BTreeSet<char>,
) -> Option<EmbeddedSubset> {
use allsorts::{binary::read::ReadScope, font_data::FontData, tables::FontTableProvider};
let weight = if bold {
fontdb::Weight::BOLD
} else {
fontdb::Weight::NORMAL
};
let (bytes, index) = crate::scene::text::sysfont::with_face_data_weighted(
family,
weight,
|data, index| (data.to_vec(), index),
)?;
// Original GIDs for the used chars (+ .notdef, mandatory for subsetting).
let orig = ttf_parser::Face::parse(&bytes, index).ok()?;
let mut ids: Vec<u16> = vec![0];
for c in chars {
if let Some(gid) = orig.glyph_index(*c) {
if !ids.contains(&gid.0) {
ids.push(gid.0);
}
}
}
if ids.len() <= 1 {
return None;
}
let scope = ReadScope::new(&bytes);
let font_file = scope.read::<FontData<'_>>().ok()?;
let provider = font_file.table_provider(index as usize).ok()?;
// The no-text_layout ParsedFont stub can only describe TrueType
// (CIDFontType2 + FontFile2) — never mislabel CFF outlines as TrueType.
if provider.has_table(allsorts::tag::CFF) || provider.has_table(allsorts::tag::CFF2) {
return None;
}
let subset_bytes = allsorts::subset::subset(
&provider,
&ids,
&allsorts::subset::SubsetProfile::Pdf,
allsorts::subset::CmapTarget::Unicode,
)
.ok()?;
// Re-parse the SUBSET: GID assignment is allsorts' business, and the maps
// below must be in subset space. Anything unmappable stays out.
let sub = ttf_parser::Face::parse(&subset_bytes, 0).ok()?;
let upem = sub.units_per_em().max(1);
let cap_ratio = sub
.capital_height()
.filter(|c| *c > 0)
.map(|c| c as f32 / upem as f32)
.filter(|r| (0.2..=1.0).contains(r))
.unwrap_or(0.7);
let mut gid_chars: std::collections::BTreeMap<char, u16> = std::collections::BTreeMap::new();
let mut width_of: std::collections::BTreeMap<u16, u16> = std::collections::BTreeMap::new();
for c in chars {
if let Some(gid) = sub.glyph_index(*c) {
gid_chars.insert(*c, gid.0);
width_of.insert(gid.0, sub.glyph_hor_advance(gid).unwrap_or(0));
}
}
if gid_chars.is_empty() {
return None;
}
// printpdf (no text_layout) embeds `original_bytes` verbatim as the
// CIDFontType2 stream and derives ToUnicode/widths from these maps — so
// handing it the SUBSET bytes + subset-space maps yields a true subset
// embed with no lopdf surgery. (Metrics out before the bytes move.)
let (ascent, descent) = (sub.ascender(), sub.descender());
let cp_map: std::collections::BTreeMap<u32, u16> =
gid_chars.iter().map(|(c, g)| (*c as u32, *g)).collect();
let parsed = printpdf::ParsedFont::with_glyph_data(
subset_bytes,
0,
Some(family.to_string()),
cp_map,
width_of.clone(),
upem,
printpdf::FontMetrics { ascent, descent },
);
let font_id = doc.add_font(&parsed);
Some(EmbeddedSubset {
family: family.to_string(),
bold,
font_id,
gid_of: gid_chars,
width_of,
upem,
cap_ratio,
})
}
/// Re-emit every wire's SDF text as vector geometry.
///
/// Each visible glyph rides on `wire.text_verts` as one 6-vertex quad (two
@@ -1514,6 +1909,10 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] {
/// quad's `uv_min` and map it into that quad by affine interpolation of the
/// plane rect — so a stroke (LFF) font emits polylines and a filled TrueType
/// glyph emits filled triangles, exactly where the SDF quad sits.
///
/// Always drawn: the visible glyph rendering (strokes for LFF/SHX pen fonts,
/// fills for TrueType outlines, solid bars for decorations). The invisible
/// searchable layer above adds extractability; it never replaces this.
#[cfg(not(target_arch = "wasm32"))]
fn emit_text(
ops: &mut Vec<Op>,
@@ -1905,7 +2304,7 @@ mod tests {
fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire {
use crate::scene::pipeline::text_gpu::push_glyph_vertices;
use crate::scene::text::{glyph_quads::layout_glyph_quads, sdf_atlas};
let quads = {
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, "txt", false, text)
};
@@ -1939,4 +2338,236 @@ mod tests {
no_text.len()
);
}
fn searchable_wire(text: &str, origin: [f64; 3]) -> PlotWire {
use crate::scene::model::wire_model::SearchableTextRun;
PlotWire {
wire: WireModel {
searchable_text: vec![SearchableTextRun {
text: text.to_string(),
origin,
height: 10.0,
rotation: 0.0,
color: [0.0, 0.0, 0.0, 1.0],
bold: false,
font: "txt".into(),
adv_width: 10.0 * text.chars().count().max(1) as f32,
}],
..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false)
},
draw_depth: 0.0,
}
}
// Outline-only export exposes zero BT operators and no extractable text;
// a preserved run must emit a real invisible (`Tr 3`) `BT … Tj ET` with
// the string, on top of the unchanged outlines.
#[test]
fn searchable_text_emits_bt_tj_with_string() {
let wire = searchable_wire("CMa-03", [20.0, 30.0, 0.0]);
let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
assert!(bytes.starts_with(b"%PDF"), "not a PDF");
let stream = pdf_stream_text(&bytes);
assert!(stream.contains("BT"), "no text object (BT) in searchable export");
assert!(
stream.contains("CMa-03"),
"searchable string missing from content stream"
);
assert!(
stream.contains("/Helvetica"),
"searchable export should reference the text font resource"
);
assert!(
stream.contains("Tj") || stream.contains("TJ"),
"no text-showing operator (Tj/TJ)"
);
assert!(
stream.contains("Tr"),
"searchable layer must be invisible (Tr rendering mode)"
);
}
// Invisible layer coexists with outlines: the searchable file carries the
// string while the outlines-only file does not, and the outlines are
// identical in both (zero visual change — the layer only adds bytes).
#[test]
fn searchable_layer_coexists_with_outlines() {
let mut outline_only = text_wire("HELLO", [20.0, 20.0, 0.0]);
outline_only.wire.searchable_text.clear();
let mut searchable = text_wire("HELLO", [20.0, 20.0, 0.0]);
searchable.wire.searchable_text =
searchable_wire("HELLO", [20.0, 20.0, 0.0]).wire.searchable_text;
// Outlines are never suppressed: both wires keep their glyph quads.
assert!(
!searchable.wire.text_verts.is_empty(),
"searchable wire must keep its outline quads"
);
let outline_bytes = build_pdf_pages(&[test_page(vec![outline_only])], None).unwrap();
let searchable_bytes = build_pdf_pages(&[test_page(vec![searchable])], None).unwrap();
let outline_stream = pdf_stream_text(&outline_bytes);
let searchable_stream = pdf_stream_text(&searchable_bytes);
assert!(
!outline_stream.contains("HELLO"),
"outline path must not leak extractable text"
);
assert!(
searchable_stream.contains("HELLO") && searchable_stream.contains("BT"),
"searchable path must carry extractable BT/Tj text"
);
assert!(
searchable_stream.contains("Tr"),
"searchable text must render invisibly (Tr)"
);
assert!(
searchable_bytes.len() > outline_bytes.len(),
"invisible layer adds bytes ({} > {}); visuals come from the kept outlines",
searchable_bytes.len(),
outline_bytes.len()
);
}
// Per-run fallback: a non-WinAnsi run (Greek/Cyrillic/CJK/symbols) emits
// no `Tj` — its outlines still draw, so nothing renders as missing glyphs.
#[test]
fn searchable_text_skips_non_winansi_runs() {
let wire = searchable_wire("ΩΩ", [20.0, 20.0, 0.0]);
let stream = pdf_stream_text(&build_pdf_pages(&[test_page(vec![wire])], None).unwrap());
assert!(
!stream.contains("BT"),
"non-encodable run must not emit a text object"
);
}
// Rotation must ride a text matrix (Tm), not just an angle — a 90° run
// keeps its string and emits a rotated Tm.
#[test]
fn searchable_text_rotation_uses_text_matrix() {
let mut wire = searchable_wire("AB", [20.0, 20.0, 0.0]);
wire.wire.searchable_text[0].rotation = std::f32::consts::FRAC_PI_2;
let stream = pdf_stream_text(&build_pdf_pages(&[test_page(vec![wire])], None).unwrap());
assert!(stream.contains("AB"), "rotated run lost its string");
assert!(stream.contains("Tm"), "rotated run needs an explicit text matrix (Tm)");
}
#[test]
fn winansi_filter_covers_accents_and_rejects_cjk() {
assert!(is_winansi_encodable("CMa-03"));
assert!(is_winansi_encodable("crème °±Ø"));
assert!(!is_winansi_encodable("Ω"));
assert!(!is_winansi_encodable("中"));
// C1 controls are unassigned in WinAnsi: emitting them would mojibake
// (explicit \u escapes — a literal control char would not survive
// editors, and NBSP U+00A0 next door *is* encodable).
assert!(is_winansi_encodable("a\u{a0}b")); // NBSP is WinAnsi 0xA0
assert!(!is_winansi_encodable("a\u{80}b"));
assert!(!is_winansi_encodable("a\u{9f}b"));
}
#[test]
fn width_factor_matches_laid_to_natural_advance() {
use std::collections::BTreeMap;
let sub = EmbeddedSubset {
family: "Test".into(),
bold: false,
font_id: printpdf::FontId::new(),
gid_of: [('A', 1), ('B', 2)].into_iter().collect::<BTreeMap<_, _>>(),
width_of: [(1, 600), (2, 620)].into_iter().collect::<BTreeMap<_, _>>(),
upem: 1000,
cap_ratio: 0.7,
};
let run = SearchableTextRun {
text: "AB".into(),
origin: [0.0, 0.0, 0.0],
height: 10.0,
rotation: 0.0,
color: [0.0, 0.0, 0.0, 1.0],
bold: false,
font: "Test".into(),
// Laid advance exactly equals natural at cap height, so k = 1:
// adv = height * Σw / (upem * cap) = 10*1220/(1000*0.7).
adv_width: 10.0 * 1220.0 / (1000.0 * 0.7),
};
assert!((sub.width_factor(&run) - 1.0).abs() < 1e-4);
let mut wide = run.clone();
wide.adv_width *= 2.0;
assert!((sub.width_factor(&wide) - 2.0).abs() < 1e-4);
let mut empty = run;
empty.adv_width = 0.0;
assert_eq!(sub.width_factor(&empty), 1.0);
}
// A TTF-backed run embeds a subset of its own family with ToUnicode —
// the DWG font, not Helvetica. Skips where no test font is installed.
#[test]
fn subset_embeds_the_runs_own_family_with_tounicode() {
let family = ["DejaVu Sans", "Liberation Sans", "Arial"]
.into_iter()
.find(|f| {
crate::scene::text::sysfont::with_face_data_weighted(
f,
fontdb::Weight::NORMAL,
|_, _| true,
)
.unwrap_or(false)
});
let Some(family) = family else {
eprintln!("no test TTF installed; skipping subset test");
return;
};
let full_len =
crate::scene::text::sysfont::with_face_data_weighted(family, fontdb::Weight::NORMAL, |data, _| {
data.len()
})
.unwrap();
let mut wire = searchable_wire("CMa-03 pincé", [20.0, 30.0, 0.0]);
wire.wire.searchable_text[0].font = family.to_string();
// Unit level: the subset is built for the run's family and maps the
// used chars (extractability source of truth — the content stream
// carries hex GIDs, resolved via ToUnicode, not literal text).
let mut probe = printpdf::PdfDocument::new("subset-probe");
let fonts =
SearchableFontSet::build(&mut probe, &[test_page(vec![wire.clone()])]);
let sub = fonts
.find(family, false)
.expect("subset embedded for the run's own family");
assert!(sub.gid_of.contains_key(&'C'), "used chars must be mapped");
assert!(sub.gid_of.contains_key(&'é'), "accents must survive subsetting");
// PDF level: ToUnicode + embedded bytes, and no Helvetica fallback.
let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
let stream = pdf_stream_text(&bytes);
assert!(
stream.contains("ToUnicode"),
"subset font must carry a ToUnicode CMap"
);
assert!(
stream.contains("FontFile2"),
"subset font must embed its (TrueType) bytes"
);
assert!(
!stream.contains("/Helvetica"),
"own-family run must not fall back to Helvetica"
);
assert!(
bytes.len() < full_len,
"subset PDF ({} bytes) must be lighter than the full font ({} bytes)",
bytes.len(),
full_len
);
}
// Runs fully outside the export window never reach the content stream.
#[test]
fn clip_culls_runs_outside_the_export_window() {
let inside = searchable_wire("IN", [20.0, 20.0, 0.0]);
let outside = searchable_wire("OUT", [500.0, 500.0, 0.0]);
// Same page, windowed clip around the origin run only.
let mut page = test_page(vec![inside, outside]);
page.clip = Some((0.0, 0.0, 100.0, 100.0));
let stream = pdf_stream_text(&build_pdf_pages(&[page], None).unwrap());
assert!(stream.contains("IN"), "inside run must survive the clip");
assert!(!stream.contains("OUT"), "outside run must be culled by the clip");
}
}
+6 -2
View File
@@ -366,7 +366,9 @@ impl CadCommand for AlignedDimensionCommand {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
..Default::default()
})
}
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
@@ -482,7 +484,9 @@ fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
+3 -1
View File
@@ -1275,7 +1275,9 @@ fn preview_wire(points: Vec<DVec3>) -> WireModel {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR"] });
+3 -1
View File
@@ -134,7 +134,9 @@ impl DataLinkPlaceCommand {
plinegen: true,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
}
+3 -1
View File
@@ -364,7 +364,9 @@ fn preview_line(far_chord: Vec3, chord: Vec3, text: Vec3) -> WireModel {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
inventory::submit!(crate::command::CommandRegistration { names: &["DIMDIAMETER"] });
+3 -1
View File
@@ -755,7 +755,9 @@ fn preview_for_dimension(dimension: &Dimension) -> WireModel {
plinegen: true,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
fn linear_preview(
+3 -1
View File
@@ -506,7 +506,9 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
plinegen: true,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
inventory::submit!(crate::command::CommandRegistration {
+3 -1
View File
@@ -470,7 +470,9 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
pub fn arrowhead_wings(tip: Vec3, next: Vec3, size: f32) -> [Vec3; 2] {
+3 -1
View File
@@ -590,7 +590,9 @@ fn preview_wire(points: Vec<DVec3>) -> WireModel {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
/// Project the two extension origins onto the dimension line, which passes
+3 -1
View File
@@ -799,7 +799,9 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
fn arrowhead_wings(tip: Vec3, next: Vec3, size: f32) -> [Vec3; 2] {
+3 -1
View File
@@ -523,7 +523,9 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
fn point_segment_distance_xy(point: Vector3, start: Vector3, end: Vector3) -> f64 {
+3 -1
View File
@@ -315,7 +315,9 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
+3 -1
View File
@@ -376,7 +376,9 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS"] });
+3 -1
View File
@@ -341,7 +341,9 @@ impl TableCommand {
plinegen: true,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
}
+3 -1
View File
@@ -114,7 +114,9 @@ impl CadCommand for ToleranceCommand {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
..Default::default()
})
}
}
+3 -1
View File
@@ -235,7 +235,9 @@ impl CadCommand for AttdefCommand {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
..Default::default()
})
}
}
+3 -1
View File
@@ -213,7 +213,9 @@ impl MlineCommand {
plinegen: true,
fill_tris,
fill_tris_low,
})
..Default::default()
})
}
}
+3 -1
View File
@@ -202,7 +202,9 @@ impl CadCommand for ImageCommand {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
..Default::default()
})
}
}
+6 -2
View File
@@ -119,7 +119,9 @@ impl CadCommand for RayCommand {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
..Default::default()
})
}
}
@@ -408,7 +410,9 @@ impl CadCommand for XLineCommand {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
..Default::default()
})
}
}
+3 -1
View File
@@ -401,7 +401,9 @@ impl CadCommand for AreaCommand {
plinegen: true,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
})
..Default::default()
})
}
fn area_preview_regions(&self) -> Option<Vec<AreaPreviewRegion>> {
+3 -1
View File
@@ -124,7 +124,9 @@ impl CadCommand for DistCommand {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
..Default::default()
})
}
}
+3 -1
View File
@@ -103,7 +103,9 @@ impl MeasureGeomCommand {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
/// Extract (radius) from a Circle or Arc; `None` for anything else.
+3 -1
View File
@@ -4870,7 +4870,9 @@ fn preview_wire(points: Vec<[f32; 3]>, color: [f32; 4], name: &str) -> WireModel
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
/// Insert the exact boundary crossings between consecutive samples so a
+3 -1
View File
@@ -3710,5 +3710,7 @@ fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
+3 -1
View File
@@ -96,7 +96,9 @@ impl CadCommand for PlotWindowCommand {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
..Default::default()
})
}
}
+3 -1
View File
@@ -175,6 +175,8 @@ impl CadCommand for ZoomWindowCommand {
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
..Default::default()
})
}
}
+47 -7
View File
@@ -54,6 +54,7 @@ pub struct LocalWire {
/// expand-time transform (`emit_wire`) maps each vertex to world exactly
/// like `points`, so block-instance text lands at the right place/scale.
pub text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
pub searchable_text: Vec<crate::scene::model::wire_model::SearchableTextRun>,
pub key_vertices: Vec<[f64; 3]>,
pub snap_pts: Vec<(glam::DVec3, SnapHint)>,
pub tangent_geoms: Vec<TangentGeom>,
@@ -881,6 +882,7 @@ fn tessellate_sub_local(
is_point: matches!(sub, EntityType::Point(_)),
point_marker: wire.point_marker,
text_verts: wire.text_verts,
searchable_text: wire.searchable_text,
key_vertices: wire.key_vertices,
snap_pts: wire.snap_pts,
tangent_geoms: wire.tangent_geoms,
@@ -1346,12 +1348,11 @@ fn translated_prototype_wire(
}
}
}
if !wire.text_verts.is_empty() {
wire.text_verts =
crate::scene::model::wire_model::map_text_verts(&wire.text_verts, |x, y, z| {
(x + delta[0], y + delta[1], z + delta[2])
});
}
wire.map_text_layout(
&|p| [p[0] + delta[0], p[1] + delta[1], p[2] + delta[2]],
1.0,
0.0,
);
if wire.aabb != WireModel::UNBOUNDED_AABB {
wire.aabb[0] += delta_f32[0];
wire.aabb[1] += delta_f32[1];
@@ -1583,6 +1584,7 @@ struct BatchEntry {
pick_tris_low: Vec<[f32; 3]>,
/// Accumulated SDF glyph quads (world space) for block-instance text.
text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
searchable_text: Vec<crate::scene::model::wire_model::SearchableTextRun>,
min_x: f32,
min_y: f32,
max_x: f32,
@@ -1767,6 +1769,7 @@ impl Batches {
dash_from_start: false,
dash_align_end: None,
text_verts: b.text_verts,
searchable_text: b.searchable_text,
name: name.to_string(),
points: b.points,
points_low: b.points_low,
@@ -2550,7 +2553,9 @@ fn emit_wire(
plinegen: lw.plinegen,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
};
..Default::default()
};
out.extra_wires.push(wire);
return;
}
@@ -2906,6 +2911,41 @@ fn emit_wire(
draw_depth: tv.draw_depth,
});
}
// Searchable runs follow the same insert transform: origin via the full
// matrix, cap height by the mean XY scale, rotation by the transformed
// X-axis angle — so block-instance text plots searchable at its placed
// size and angle, not its block-local one.
//
// Notes (review V2): `sx`/`sy` are axis *lengths* (always ≥ 0), so a
// mirrored insert (`sx = -1`) yields length 1, not a zero mean — there is
// no 0.1 pt collapse. Mirroring folds into `rotation += π` (readable
// invisible text) rather than a reflected `Tm`: the visible outlines
// carry the true mirror. Non-uniform scale, width factor, oblique and
// tracking are approximated by the mean scale here; exact reproduction
// needs `Tz`/`Tc`/skewed `Tm` (follow-up — selection-only mismatch on the
// invisible layer, never a visual defect).
if !lw.searchable_text.is_empty() {
let x_axis = accum_xform.apply_rotation(Vector3::new(1.0, 0.0, 0.0));
let y_axis = accum_xform.apply_rotation(Vector3::new(0.0, 1.0, 0.0));
let sx = (x_axis.x * x_axis.x + x_axis.y * x_axis.y + x_axis.z * x_axis.z).sqrt();
let sy = (y_axis.x * y_axis.x + y_axis.y * y_axis.y + y_axis.z * y_axis.z).sqrt();
let mean_scale = ((sx + sy) * 0.5).max(1e-6);
let rot_delta = (x_axis.y).atan2(x_axis.x);
let base_len = entry.searchable_text.len();
entry.searchable_text.extend(lw.searchable_text.iter().cloned());
crate::scene::model::wire_model::map_searchable_runs(
&mut entry.searchable_text[base_len..],
&|p| {
let v = accum_xform.apply(Vector3::new(p[0], p[1], p[2]));
[v.x, v.y, v.z]
},
mean_scale,
rot_delta as f32,
);
for run in &mut entry.searchable_text[base_len..] {
run.color = final_color;
}
}
}
fn transform_tangent(
+12 -4
View File
@@ -961,7 +961,9 @@ fn tessellate_entity_inner(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}];
..Default::default()
}];
}
// Render non-annotative dimensions from their stored picture block.
@@ -1207,7 +1209,9 @@ fn tessellate_entity_inner(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
};
..Default::default()
};
let expanded = expand_block_object(
document,
@@ -1592,7 +1596,9 @@ fn lod_stub_wire(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
/// Sub-pixel LOD stub for 3D entities. Emits the entity's 3D AABB as a
@@ -1687,7 +1693,9 @@ fn lod_stub_wire_3d(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
/// Tessellate each visible AttributeEntity attached to an Insert and append
+115 -30
View File
@@ -399,7 +399,9 @@ fn split_mixed_polyline(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
}
@@ -535,7 +537,9 @@ fn split_mixed_polyline(
plinegen,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
} else if let Some(first_arc) = out.first_mut() {
first_arc.snap_pts = snap_pts;
first_arc.key_vertices = key_vertices.to_vec();
@@ -614,7 +618,9 @@ fn point_cloud_wires(
plinegen: true,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
}];
..Default::default()
}];
let mode = crate::scene::frame::mode(
document,
crate::scene::frame::FrameKind::PointCloudClip,
@@ -870,7 +876,9 @@ pub fn tessellate(
plinegen: true,
fill_tris,
fill_tris_low,
});
..Default::default()
});
}
}
let mut snap_attached = false;
@@ -1039,7 +1047,9 @@ pub fn tessellate(
plinegen: false,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
// Snap / key vertices ride the first emitted wire only (they describe
@@ -1254,8 +1264,13 @@ pub fn tessellate(
// materialisation and (for block content) the block-expand
// transform — no separate document-wide collector.
let mut sdf_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
let mut searchable: Vec<crate::scene::model::wire_model::SearchableTextRun> =
Vec::new();
{
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
use crate::scene::model::wire_model::{
clean_searchable_text, SearchableTextRun,
};
// Selection tints the whole run; otherwise inline `\C`
// colours (bin key) win, falling back to entity colour.
for group in &stroke_groups {
@@ -1263,6 +1278,11 @@ pub fn tessellate(
if crate::scene::text::web_font::requires_shaping(&run.text) {
continue;
}
// Searchable counterpart, cleaned once here and
// pushed after layout below (one run per laid-out
// group, so MTEXT wrap/attachment and rotation are
// already baked into the origin — no re-layout).
let visible = clean_searchable_text(&run.text);
let slx_v = (group.origin[0] - ref_lx_v) * anno + ref_lx_v;
let sly_v = (group.origin[1] - ref_ly_v) * anno + ref_ly_v;
// Base colour only (inline `\C` wins). Selection /
@@ -1279,17 +1299,18 @@ pub fn tessellate(
gcolor,
text_contrast_background(entity, bg_color),
);
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
&mut atlas,
run.height,
run.rotation,
run.width_factor,
run.oblique,
run.tracking,
&run.font,
run.bold,
&run.text,
);
let (quads, pen_adv) =
crate::scene::text::glyph_quads::layout_glyph_quads(
&mut atlas,
run.height,
run.rotation,
run.width_factor,
run.oblique,
run.tracking,
&run.font,
run.bold,
&run.text,
);
if let Some(plane) = group.plane {
let scaled_origin = [
plane.scale_origin[0]
@@ -1319,6 +1340,40 @@ pub fn tessellate(
0.0,
);
}
// Searchable run: same adapted colour as the quads,
// exact pen advance in world units (layout's final
// pen position × annotation factor, like the quads).
if !visible.is_empty() {
let adv_world = (pen_adv as f64 * anno) as f32;
let (origin, rotation) = if let Some(plane) = group.plane {
(
[
plane.scale_origin[0]
+ (plane.origin[0] - plane.scale_origin[0])
* anno,
plane.scale_origin[1]
+ (plane.origin[1] - plane.scale_origin[1])
* anno,
plane.scale_origin[2]
+ (plane.origin[2] - plane.scale_origin[2])
* anno,
],
(plane.x_axis[1]).atan2(plane.x_axis[0]) as f32,
)
} else {
([slx_v, sly_v, elev_v], run.rotation)
};
searchable.push(SearchableTextRun {
text: visible,
origin,
height: (run.height as f64 * anno) as f32,
rotation,
color: gcolor,
bold: run.bold,
font: run.font.clone(),
adv_width: adv_world,
});
}
}
}
}
@@ -1428,7 +1483,9 @@ pub fn tessellate(
plinegen: true,
fill_tris: ft,
fill_tris_low: ftl,
});
..Default::default()
});
}
// Text frame — a closed rectangle in the text
// colour around the same box.
@@ -1486,7 +1543,9 @@ pub fn tessellate(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
}
}
@@ -1541,7 +1600,9 @@ pub fn tessellate(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
wires.push(WireModel {
bg_adapt: None,
@@ -1561,6 +1622,7 @@ pub fn tessellate(
dash_from_start: false,
dash_align_end: None,
text_verts: sdf_verts,
searchable_text: searchable,
name,
points: Vec::new(),
points_low: Vec::new(),
@@ -1643,7 +1705,9 @@ pub fn tessellate(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
if !bin.fill_tris.is_empty() {
@@ -1691,7 +1755,9 @@ pub fn tessellate(
plinegen: true,
fill_tris: bin.fill_tris,
fill_tris_low: bin.fill_tris_low,
});
..Default::default()
});
}
}
@@ -1720,6 +1786,7 @@ pub fn tessellate(
dash_from_start: false,
dash_align_end: None,
text_verts: sdf_verts,
searchable_text: searchable,
name: name.clone(),
points: Vec::new(),
points_low: Vec::new(),
@@ -1774,7 +1841,9 @@ pub fn tessellate(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
return out;
}
@@ -1855,7 +1924,9 @@ pub fn tessellate(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}];
..Default::default()
}];
}
}
}
@@ -2006,7 +2077,9 @@ pub fn tessellate(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
}
@@ -2054,7 +2127,9 @@ pub fn tessellate(
display_visible: true,
snap_only: false,
plot_visible: true,
});
..Default::default()
});
}
if out.is_empty() {
@@ -2092,7 +2167,9 @@ pub fn tessellate(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
..Default::default()
});
}
return out;
@@ -2152,7 +2229,9 @@ pub fn tessellate(
aabb: WireModel::UNBOUNDED_AABB,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
}];
..Default::default()
}];
}
RenderObject::SegmentedLines(points) => {
@@ -2242,7 +2321,9 @@ pub fn tessellate(
aabb: WireModel::UNBOUNDED_AABB,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}];
..Default::default()
}];
}
RenderObject::TaperedLines(points, widths) => {
@@ -2321,7 +2402,9 @@ pub fn tessellate(
aabb: WireModel::UNBOUNDED_AABB,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}];
..Default::default()
}];
}
}
@@ -2431,7 +2514,9 @@ pub fn tessellate(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}]
..Default::default()
}]
}
+389 -3
View File
@@ -364,6 +364,202 @@ impl PointMarker {
/// resolution pass must leave it alone. Only `Batches::finalize` fills it in.
pub type BgAdapt = Option<Box<BgAdaptInputs>>;
/// One laid-out text run preserved for searchable PDF export.
///
/// `origin` is the final world-space baseline start (drawing units, f64 —
/// annotation scale and block placement already applied), `height` is the
/// world cap height (run.height × anno), `rotation` the run angle in radians.
/// `text` holds the visible characters only (DXF controls stripped, `%%`
/// specials decoded, `\P` → space — multi-line MTEXT arrives as one run per
/// visual line from the layout stage, so no re-wrapping is needed).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SearchableTextRun {
pub text: String,
pub origin: [f64; 3],
pub height: f32,
pub rotation: f32,
pub color: [f32; 4],
pub bold: bool,
/// Style font name as laid out (`GlyphRun.font`: TTF family, LFF/SHX
/// name, …). Lets the PDF exporter resolve the run's *actual* font and
/// embed a subset instead of always falling back to Helvetica.
pub font: String,
/// Laid-out advance width in drawing units (pen advance incl. spacing,
/// annotation scale applied). Drives `Tz` width-matching and run-level
/// culling at export; scaled alongside `height` by every transform.
pub adv_width: f32,
}
impl SearchableTextRun {
/// World-space origin translated by `delta` (preview drags, block moves).
pub fn translated(&self, delta: [f64; 3]) -> Self {
let mut out = self.clone();
out.origin[0] += delta[0];
out.origin[1] += delta[1];
out.origin[2] += delta[2];
out
}
}
/// Clean a laid-out run's raw string into visible searchable characters.
///
/// The input is the exact string passed to `layout_glyph_quads` for the run,
/// so the extracted text cannot diverge from the laid-out glyph sequence —
/// cleaning only strips controls. Traps paid for at the format level:
/// - `\P` is a real newline → space here (runs are already per-line).
/// - Decoration toggles (`\L…\l`, `\O…\o`, `\K…\k`) and other backslash
/// controls are stripped, not searched.
/// - `\Snum^den;`/stacking → `num/den`; `\U+XXXX` → the char; `{…}` grouping
/// braces are dropped; `\M+…` paragraph controls are stripped.
/// - DXF `%%d`/`%%p`/`%%c` decode to `°`/`±`/`Ø`; `%%` + 3 digits → the char.
/// - Literal `\\` → `\`, `\~` → non-breaking space.
pub fn clean_searchable_text(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let bytes = raw.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && i + 1 < bytes.len() {
let n = bytes[i + 1];
match n {
b'P' | b'p' => {
out.push(' ');
i += 2;
continue;
}
b'\\' => {
out.push('\\');
i += 2;
continue;
}
b'~' => {
out.push('\u{a0}');
i += 2;
continue;
}
// Stacking: `\Snum^den;` → `num/den` (searchable fraction).
b'S' | b's' => {
if let Some(semi) = raw[i + 2..].find(';') {
let body = &raw[i + 2..i + 2 + semi];
if let Some(caret) = body.find('^') {
out.push_str(&body[..caret]);
out.push('/');
out.push_str(&body[caret + 1..]);
} else {
out.push_str(body);
}
i += 2 + semi + 1;
} else {
i += 2;
}
continue;
}
// Unicode escape `\U+XXXX` (4–6 hex digits) → the char.
// Anything else (bad length, surrogate, > U+10FFFF) leaves
// the digits to be copied literally below — visible
// garbage-in beats silently dropped text.
b'U' | b'u' => {
let rest = &raw[i + 2..];
let after_plus = rest.strip_prefix('+').unwrap_or(rest);
let digits: String = after_plus
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.collect();
let mut pushed = false;
if (4..=6).contains(&digits.len()) {
if let Ok(cp) = u32::from_str_radix(&digits, 16) {
if let Some(ch) = char::from_u32(cp) {
out.push(ch);
pushed = true;
}
}
}
i += 2 + (rest.len() - after_plus.len())
+ if pushed { digits.len() } else { 0 };
continue;
}
// Decoration / format toggles: drop both the opener and closer.
b'L' | b'l' | b'O' | b'o' | b'K' | b'k' | b'C' | b'c' | b'F'
| b'f' | b'H' | b'h' | b'Q' | b'q' | b'T'
| b't' | b'W' | b'w' | b'A' | b'a' | b'M' | b'm' => {
// `\C1;`, `\Farial;` etc. run to the next `;` — skip it.
if let Some(semi) = raw[i + 2..].find(';') {
i += 2 + semi + 1;
} else {
i += 2;
}
continue;
}
_ => {
i += 1;
continue;
}
}
} else if b == b'%' && i + 2 < bytes.len() && bytes[i + 1] == b'%' {
let c = bytes[i + 2];
match c {
b'd' | b'D' => out.push('°'),
b'p' | b'P' => out.push('±'),
b'c' | b'C' => out.push('Ø'),
b'%' => out.push('%'),
_ if c.is_ascii_digit()
&& i + 4 < bytes.len()
&& bytes[i + 3].is_ascii_digit()
&& bytes[i + 4].is_ascii_digit() =>
{
let digits =
std::str::from_utf8(&bytes[i + 2..i + 5]).unwrap_or("");
if let Ok(code) = digits.parse::<u32>() {
out.push(char::from_u32(code).unwrap_or('?'));
} else {
out.push_str("%%");
out.push_str(digits);
}
i += 5;
continue;
}
_ => {
out.push('%');
out.push('%');
out.push(c as char);
}
}
i += 3;
continue;
} else if b == b'{' || b == b'}' {
// MTEXT grouping braces: structure only, not content.
i += 1;
continue;
} else {
// Copy one UTF-8 char.
if let Some(ch) = raw[i..].chars().next() {
out.push(ch);
i += ch.len_utf8();
} else {
break;
}
}
}
// Collapse whitespace (MTEXT padding) but keep single spaces.
let mut collapsed = String::with_capacity(out.len());
let mut prev_space = true; // trim leading
for ch in out.chars() {
if ch.is_whitespace() {
if !prev_space {
collapsed.push(' ');
prev_space = true;
}
} else {
collapsed.push(ch);
prev_space = false;
}
}
if collapsed.ends_with(' ') {
collapsed.pop();
}
collapsed
}
/// The inputs `Batches::finalize` consumes when it resolves a colour.
///
/// The flags are independent — a wire can paint the background itself while
@@ -496,6 +692,17 @@ pub struct WireModel {
/// exactly like `points` — no separate collector pass. The renderer
/// gathers these across all wires into the text vertex buffer.
pub text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
/// Searchable text runs for PDF export (real text, not outlines).
///
/// The SDF `text_verts` above carry only tessellated quads — the source
/// string is lost by the time the PDF exporter runs, so searchable text
/// was impossible without re-layout. Each entry preserves one laid-out
/// run (already positioned: MTEXT attachment/width/wrap, rotation and
/// block placement are baked into `origin`/`rotation`/`height`), letting
/// `io::pdf_export` emit `BT … Tj ET` with an embedded font instead of
/// vector outlines. Empty on non-text wires; transformed alongside
/// `text_verts` by the block-expand loop and preview clones.
pub searchable_text: Vec<SearchableTextRun>,
/// Block-local composed draw-order offset in (-1, 1) for a wire that must
/// order against its *siblings inside the block* — currently wide-polyline
/// bands, whose solid area would otherwise cover later-drawn siblings.
@@ -597,6 +804,7 @@ impl WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
text_verts: Vec::new(),
searchable_text: Vec::new(),
name,
points,
points_low: Vec::new(),
@@ -630,10 +838,9 @@ impl WireModel {
if let Some(marker) = &mut out.point_marker {
marker.origin += delta.as_dvec3();
}
if !out.text_verts.is_empty() {
{
let (dx, dy, dz) = (delta.x as f64, delta.y as f64, delta.z as f64);
out.text_verts =
map_text_verts(&self.text_verts, |x, y, z| (x + dx, y + dy, z + dz));
out.map_text_layout(&|p| [p[0] + dx, p[1] + dy, p[2] + dz], 1.0, 0.0);
}
out.tangent_geoms = self
.tangent_geoms
@@ -661,6 +868,9 @@ impl WireModel {
(p.x, p.y, p.z)
});
}
// Preview-only clone: searchable export runs are rebuilt from source
// geometry, never carried on a rubber-band preview.
out.searchable_text.clear();
out.tangent_geoms.clear();
out
}
@@ -719,6 +929,7 @@ impl WireModel {
.iter()
.map(|tg| tg.rotated(center.as_dvec3(), axis.as_dvec3(), angle_rad as f64))
.collect();
out.searchable_text.clear();
out
}
@@ -758,6 +969,7 @@ impl WireModel {
.iter()
.map(|tg| tg.scaled(center.as_dvec3(), factor as f64))
.collect();
out.searchable_text.clear();
out
}
@@ -834,6 +1046,7 @@ pub fn stretched_windows(
}
});
}
out.searchable_text.clear();
out
}
@@ -903,6 +1116,7 @@ pub fn stretched_windows(
.iter()
.map(|tg| tg.mirrored(p1.as_dvec3(), plane_normal.as_dvec3()))
.collect();
out.searchable_text.clear();
out
}
@@ -959,6 +1173,117 @@ fn map_points(
}
}
impl WireModel {
/// Remap every text channel geometrically in ONE call.
///
/// The SDF quads and the searchable runs describe the same placed text;
/// transforming one without the other strands invisible text away from its
/// outlines. Every geometric remap of text (block expand, OCS→WCS, paper
/// projection, preview drags) MUST go through here so the rule — origins
/// via `map`, cap height and advance scaled by `height_scale`, rotation
/// shifted by `rot_delta` — lives in exactly one place instead of being
/// re-implemented (and drifted) per call site.
pub fn map_text_layout(
&mut self,
map: &dyn Fn([f64; 3]) -> [f64; 3],
height_scale: f64,
rot_delta: f32,
) {
if !self.text_verts.is_empty() {
self.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let p = map([x, y, z]);
(p[0], p[1], p[2])
});
}
self.map_searchable_runs(map, height_scale, rot_delta);
}
/// Remap only the searchable runs (origin, cap height, advance, rotation).
/// Used where the quads need custom handling (per-vertex colour, per-quad
/// culling) but the runs must follow the same geometry — same rule as
/// [`Self::map_text_layout`], runs half.
pub fn map_searchable_runs(
&mut self,
map: &dyn Fn([f64; 3]) -> [f64; 3],
height_scale: f64,
rot_delta: f32,
) {
map_searchable_runs(&mut self.searchable_text, map, height_scale, rot_delta);
}
}
/// Remap a run slice geometrically: origins via `map`, cap height and advance
/// scaled by `height_scale`, rotation shifted by `rot_delta`. The single rule
/// behind [`WireModel::map_text_layout`] and [`WireModel::map_searchable_runs`];
/// batch code (block expand) maps runs it appends to shared entries through
/// this so the geometry can never drift from the method pair.
pub fn map_searchable_runs(
runs: &mut [SearchableTextRun],
map: &dyn Fn([f64; 3]) -> [f64; 3],
height_scale: f64,
rot_delta: f32,
) {
for run in runs {
run.origin = map(run.origin);
run.height = (run.height as f64 * height_scale) as f32;
run.adv_width = (run.adv_width as f64 * height_scale) as f32;
run.rotation += rot_delta;
}
}
/// Overlap of a text run's rect with an axis-aligned rect (SAT on both axes).
///
/// The run occupies `adv_width` along its baseline direction and roughly
/// `height` above (cap) plus a descender allowance below: corners span
/// `[-0.3h, +1.0h]` across the baseline. `rect` is `[x0, y0, x1, y1]`.
/// Conservative by design (it only gates searchability, never drawing).
pub fn run_rect_overlap(
origin: [f64; 2],
rotation: f32,
adv_width: f64,
height: f64,
rect: [f64; 4],
) -> bool {
if !adv_width.is_finite() || !height.is_finite() || adv_width <= 0.0 || height <= 0.0 {
return true;
}
let (s, c) = rotation.sin_cos();
let (dx, dy) = (c as f64, s as f64);
let (nx, ny) = (-dy, dx);
let corners = [
[origin[0] + nx * -0.3 * height, origin[1] + ny * -0.3 * height],
[
origin[0] + dx * adv_width + nx * -0.3 * height,
origin[1] + dy * adv_width + ny * -0.3 * height,
],
[
origin[0] + dx * adv_width + nx * height,
origin[1] + dy * adv_width + ny * height,
],
[origin[0] + nx * height, origin[1] + ny * height],
];
// Separating-axis test on the OBB axes plus world x/y.
for (ax, ay) in [(dx, dy), (nx, ny), (1.0, 0.0), (0.0, 1.0)] {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for p in &corners {
let t = p[0] * ax + p[1] * ay;
lo = lo.min(t);
hi = hi.max(t);
}
let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
for p in [[rect[0], rect[1]], [rect[2], rect[1]], [rect[2], rect[3]], [rect[0], rect[3]]] {
let t = p[0] * ax + p[1] * ay;
rlo = rlo.min(t);
rhi = rhi.max(t);
}
if hi < rlo || rhi < lo {
return false;
}
}
true
}
/// Map every glyph vertex's double-single world position through `f`, re-
/// splitting the result. The preview transforms above move `points`, but SDF
/// glyph quads live in `text_verts` (absolute-world double-single) — so a text
@@ -995,6 +1320,7 @@ impl Default for WireModel {
Self {
point_marker: None,
text_verts: Vec::new(),
searchable_text: Vec::new(),
name: String::new(),
points: Vec::new(),
points_low: Vec::new(),
@@ -1129,4 +1455,64 @@ mod tests {
panic!("Expected PlanarCircle");
}
}
#[test]
fn map_text_layout_moves_quads_and_runs_together() {
let mut wire = WireModel::default();
wire.searchable_text.push(SearchableTextRun {
text: "AB".into(),
origin: [10.0, 20.0, 0.0],
height: 5.0,
rotation: 0.0,
color: WireModel::WHITE,
bold: false,
font: "txt".into(),
adv_width: 8.0,
});
// Uniform scale ×2 about the origin plus translation: quads and runs
// must agree afterwards (the lockstep this helper exists to enforce).
wire.map_text_layout(&|p| [p[0] * 2.0 + 1.0, p[1] * 2.0, p[2]], 2.0, 0.5);
let run = &wire.searchable_text[0];
assert_eq!(run.origin, [21.0, 40.0, 0.0]);
assert!((run.height - 10.0).abs() < 1e-4);
assert!((run.adv_width - 16.0).abs() < 1e-4);
assert!((run.rotation - 0.5).abs() < 1e-6);
}
#[test]
fn run_rect_overlap_keeps_straddlers_drops_outsiders() {
assert!(run_rect_overlap([10.0, 20.0], 0.0, 8.0, 5.0, [0.0, 0.0, 30.0, 30.0]));
assert!(!run_rect_overlap([10.0, 20.0], 0.0, 8.0, 5.0, [100.0, 100.0, 130.0, 130.0]));
// A 90° run crossing the rect edge still counts (origin-only culling
// would have dropped it).
assert!(run_rect_overlap(
[28.0, 20.0],
std::f32::consts::FRAC_PI_2,
8.0,
5.0,
[0.0, 0.0, 30.0, 30.0]
));
// Degenerate runs never cull (conservative: searchability only).
assert!(run_rect_overlap([500.0, 500.0], 0.0, 0.0, 5.0, [0.0, 0.0, 30.0, 30.0]));
}
#[test]
fn searchable_text_cleaning_handles_mtext_traps() {
// `\P` is a real newline → space (runs arrive per-line).
assert_eq!(clean_searchable_text("A\\PB"), "A B");
// Decoration toggles are stripped, not searched.
assert_eq!(clean_searchable_text("\\LAB\\l"), "AB");
// DXF specials decode (incl. %%nnn codes).
assert_eq!(clean_searchable_text("%%d"), "°");
assert_eq!(clean_searchable_text("%%p"), "±");
assert_eq!(clean_searchable_text("%%c"), "Ø");
assert_eq!(clean_searchable_text("%%065"), "A");
// Stacking, unicode escapes and grouping braces.
assert_eq!(clean_searchable_text("\\S1^2;"), "1/2");
assert_eq!(clean_searchable_text("\\U+00E9"), "é");
assert_eq!(clean_searchable_text("{AB}"), "AB");
// Plain text survives verbatim (drawing codes stay searchable).
assert_eq!(clean_searchable_text("CMa-03"), "CMa-03");
assert_eq!(clean_searchable_text(""), "");
}
}
+4 -2
View File
@@ -639,7 +639,9 @@ pub fn frame_wire(
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
..Default::default()
}
}
/// Clip a hatch fill boundary to `poly`.
@@ -1116,7 +1118,7 @@ mod tests {
text_verts: Vec::new(),
points: vec![[5.0, 5.0, 0.0], [15.0, 5.0, 0.0]],
..Default::default()
}];
}];
clip_wires(&mut wires, &poly);
assert_eq!(wires.len(), 1);
+23
View File
@@ -347,6 +347,29 @@ impl Scene {
out.pattern_stations.clear();
}
out.text_verts = projected_text;
// Searchable runs ride the same projection through the shared
// helper (origin map + height/advance scale), then cull by
// run-rect overlap — a run straddling the viewport edge is
// kept, where origin-only culling would drop visible text.
out.searchable_text = wire.searchable_text.clone();
out.map_searchable_runs(
&|p| {
let q = proj_abs(p[0], p[1], p[2]);
[q[0] as f64, q[1] as f64, q[2] as f64]
},
scale as f64,
0.0,
);
out.searchable_text.retain(|run| {
run.origin.iter().all(|v| v.is_finite())
&& crate::scene::model::wire_model::run_rect_overlap(
[run.origin[0], run.origin[1]],
run.rotation,
run.adv_width as f64,
run.height as f64,
[vp_x0 as f64, vp_y0 as f64, vp_x1 as f64, vp_y1 as f64],
)
});
// Paper coordinates are small sheet units — no relative-to-eye
// residual is needed, and keeping the model wire's points_low
// here would add a model-scale offset to the paper points.
+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> {
+1 -1
View File
@@ -3801,7 +3801,7 @@ mod ext_tests {
text_verts: Vec::new(),
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
..Default::default()
};
};
let wires = [wire];
// Extension-driven acquisition (endpoints_only): a midpoint — like an
+1
View File
@@ -88,6 +88,7 @@ fn text_and_dim_reach_pdf_export() {
.cloned()
.map(|mut w| {
w.wire.text_verts.clear();
w.wire.searchable_text.clear();
w
})
.collect();