From 02c470aac7af1f912349ee7fd5ad612863e8b7da Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Thu, 1 Oct 2026 23:30:17 +0100 Subject: [PATCH] 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. --- Cargo.lock | 1 + Cargo.toml | 5 + src/app/update/viewport.rs | 4 +- src/entities/dimension.rs | 37 +- src/entities/leader.rs | 2 + src/entities/multileader.rs | 60 +- src/entities/table.rs | 47 +- src/io/pdf_export.rs | 639 +++++++++++++++++++++- src/modules/annotate/aligned_dim.rs | 8 +- src/modules/annotate/angular_dim.rs | 4 +- src/modules/annotate/data_link.rs | 4 +- src/modules/annotate/diameter_dim.rs | 4 +- src/modules/annotate/dim_baseline.rs | 4 +- src/modules/annotate/jogged_radius_dim.rs | 4 +- src/modules/annotate/leader_cmd.rs | 4 +- src/modules/annotate/linear_dim.rs | 4 +- src/modules/annotate/mleader_cmd.rs | 4 +- src/modules/annotate/mleader_edit.rs | 4 +- src/modules/annotate/ordinate_dim.rs | 4 +- src/modules/annotate/radius_dim.rs | 4 +- src/modules/annotate/table_cmd.rs | 4 +- src/modules/annotate/tolerance_cmd.rs | 4 +- src/modules/draw/draw/attdef.rs | 4 +- src/modules/draw/draw/mline.rs | 4 +- src/modules/draw/draw/raster_image.rs | 4 +- src/modules/draw/draw/ray.rs | 8 +- src/modules/draw/inquiry/area.rs | 4 +- src/modules/draw/inquiry/dist.rs | 4 +- src/modules/draw/inquiry/measuregeom.rs | 4 +- src/modules/draw/modify/trim.rs | 4 +- src/modules/model/primitive_cmd.rs | 4 +- src/modules/view/plot_window.rs | 4 +- src/modules/view/zoom_window.rs | 4 +- src/scene/cache/block_cache.rs | 54 +- src/scene/convert/tess.rs | 16 +- src/scene/convert/tessellate.rs | 145 ++++- src/scene/model/wire_model.rs | 392 ++++++++++++- src/scene/pick/xclip.rs | 6 +- src/scene/project.rs | 23 + src/scene/text/complex_lt.rs | 30 +- src/scene/text/glyph_quads.rs | 69 ++- src/scene/text/sysfont.rs | 20 + src/snap.rs | 2 +- tests/pdf_export_text_check.rs | 1 + 44 files changed, 1534 insertions(+), 127 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6074183f5..a2ce9f354 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "OpenCADStudio" version = "2026.39.0" dependencies = [ + "allsorts", "ashpd", "base64", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 56e6a7dac..8ebca382d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c12f3646d..7dabd8108 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2865,7 +2865,9 @@ impl OpenCADStudio { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }; + + ..Default::default() +}; previews.push(guide); } } diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index edb51cd44..28f1428d8 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -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); diff --git a/src/entities/leader.rs b/src/entities/leader.rs index 717ee110f..83b0b615e 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -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() } } } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index bf27cff68..b6992403f 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -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 = + 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() +}); } } } diff --git a/src/entities/table.rs b/src/entities/table.rs index 26c212d29..0130996b9 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -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 = Vec::new(); + let mut searchable: Vec = 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 = 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 diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index f75a1a6ed..90d0f901b 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -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 { #[cfg(not(target_arch = "wasm32"))] fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result, 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, + 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, + /// Raw advance per subset GID, in font units (hmtx, for Tz). + width_of: std::collections::BTreeMap, + 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, +} + +#[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> = 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, +) -> Option { + 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 = 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::>().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 = std::collections::BTreeMap::new(); + let mut width_of: std::collections::BTreeMap = 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 = + 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, @@ -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::>(), + width_of: [(1, 600), (2, 620)].into_iter().collect::>(), + 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"); + } } diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 313f16e92..0b5c9abcb 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -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 { @@ -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() +} } diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index d26a84dec..dfc62bbdc 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -1275,7 +1275,9 @@ fn preview_wire(points: Vec) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR"] }); diff --git a/src/modules/annotate/data_link.rs b/src/modules/annotate/data_link.rs index cbc7baf6f..7dc07e044 100644 --- a/src/modules/annotate/data_link.rs +++ b/src/modules/annotate/data_link.rs @@ -134,7 +134,9 @@ impl DataLinkPlaceCommand { plinegen: true, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - } + + ..Default::default() +} } } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index c9155b8c1..f6bc3b091 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -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"] }); diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 06ed14422..ffddc10f9 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -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( diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index f022f2657..dfe0d9893 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -506,7 +506,9 @@ fn preview_wire(points: Vec) -> WireModel { plinegen: true, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - } + + ..Default::default() +} } inventory::submit!(crate::command::CommandRegistration { diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index f7894581c..14ce88c3e 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -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] { diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 31a3df457..06009f0e9 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -590,7 +590,9 @@ fn preview_wire(points: Vec) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } /// Project the two extension origins onto the dimension line, which passes diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index fbb9c9add..20d2b98a8 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -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] { diff --git a/src/modules/annotate/mleader_edit.rs b/src/modules/annotate/mleader_edit.rs index ac78d9926..4ed0706c2 100644 --- a/src/modules/annotate/mleader_edit.rs +++ b/src/modules/annotate/mleader_edit.rs @@ -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 { diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 0f5fba809..a064627ab 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -315,7 +315,9 @@ fn preview_wire(points: Vec) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 737ae0bd3..63fde8349 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -376,7 +376,9 @@ fn preview_wire(points: Vec) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS"] }); diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index 2d5bfacf4..a8648297a 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -341,7 +341,9 @@ impl TableCommand { plinegen: true, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - } + + ..Default::default() +} } } diff --git a/src/modules/annotate/tolerance_cmd.rs b/src/modules/annotate/tolerance_cmd.rs index d884253b4..504bbbc36 100644 --- a/src/modules/annotate/tolerance_cmd.rs +++ b/src/modules/annotate/tolerance_cmd.rs @@ -114,7 +114,9 @@ impl CadCommand for ToleranceCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index 053ffe8af..2044efaad 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -235,7 +235,9 @@ impl CadCommand for AttdefCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/modules/draw/draw/mline.rs b/src/modules/draw/draw/mline.rs index 51306e6d9..ccab5edbc 100644 --- a/src/modules/draw/draw/mline.rs +++ b/src/modules/draw/draw/mline.rs @@ -213,7 +213,9 @@ impl MlineCommand { plinegen: true, fill_tris, fill_tris_low, - }) + + ..Default::default() +}) } } diff --git a/src/modules/draw/draw/raster_image.rs b/src/modules/draw/draw/raster_image.rs index 63a69e202..2df8b62fd 100644 --- a/src/modules/draw/draw/raster_image.rs +++ b/src/modules/draw/draw/raster_image.rs @@ -202,7 +202,9 @@ impl CadCommand for ImageCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index bc2bf4eb7..b0490ccc7 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -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() +}) } } diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 6a33bc06a..de198a04f 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -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> { diff --git a/src/modules/draw/inquiry/dist.rs b/src/modules/draw/inquiry/dist.rs index 8592655b6..f73b14745 100644 --- a/src/modules/draw/inquiry/dist.rs +++ b/src/modules/draw/inquiry/dist.rs @@ -124,7 +124,9 @@ impl CadCommand for DistCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs index 11502eccc..416fb6172 100644 --- a/src/modules/draw/inquiry/measuregeom.rs +++ b/src/modules/draw/inquiry/measuregeom.rs @@ -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. diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index e48082364..37db81855 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -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 diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 1234c014f..016640de0 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -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() +} } diff --git a/src/modules/view/plot_window.rs b/src/modules/view/plot_window.rs index cca291c52..7aaf6a593 100644 --- a/src/modules/view/plot_window.rs +++ b/src/modules/view/plot_window.rs @@ -96,7 +96,9 @@ impl CadCommand for PlotWindowCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/modules/view/zoom_window.rs b/src/modules/view/zoom_window.rs index 2ac31bd98..a7f6be8c9 100644 --- a/src/modules/view/zoom_window.rs +++ b/src/modules/view/zoom_window.rs @@ -175,6 +175,8 @@ impl CadCommand for ZoomWindowCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index ddddc40fd..198b97de2 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -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, + pub searchable_text: Vec, pub key_vertices: Vec<[f64; 3]>, pub snap_pts: Vec<(glam::DVec3, SnapHint)>, pub tangent_geoms: Vec, @@ -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, + searchable_text: Vec, 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( diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 7bed040dc..f07a9c024 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -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 diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 321f39d38..433932665 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -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 = Vec::new(); + let mut searchable: Vec = + 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() +}] } diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index c872803a6..4358d1e5c 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -364,6 +364,202 @@ impl PointMarker { /// resolution pass must leave it alone. Only `Batches::finalize` fills it in. pub type BgAdapt = Option>; +/// 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::() { + 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, + /// 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, /// 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(""), ""); + } } diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 4d463004c..907f6aafd 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -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); diff --git a/src/scene/project.rs b/src/scene/project.rs index 15b937265..fec1f4684 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -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. diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index 302f8a2ba..2b8d081fe 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -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 = Vec::new(); + let mut searchable: Vec = 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(), diff --git a/src/scene/text/glyph_quads.rs b/src/scene/text/glyph_quads.rs index ffe4d5109..8a1199d48 100644 --- a/src/scene/text/glyph_quads.rs +++ b/src/scene/text/glyph_quads.rs @@ -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 { +) -> (Vec, 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"); } } diff --git a/src/scene/text/sysfont.rs b/src/scene/text/sysfont.rs index a5d8458d2..138b2b40f 100644 --- a/src/scene/text/sysfont.rs +++ b/src/scene/text/sysfont.rs @@ -139,10 +139,18 @@ fn canonical_family_name_uncached(family: &str) -> Option { /// Resolve a family name to a concrete face id (regular weight/style). fn face_id(family: &str) -> Option { + 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 { 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(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( + family: &str, + weight: fontdb::Weight, + f: impl FnOnce(&[u8], u32) -> T, +) -> Option { + 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 { diff --git a/src/snap.rs b/src/snap.rs index 68b94be50..53bc6a779 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -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 diff --git a/tests/pdf_export_text_check.rs b/tests/pdf_export_text_check.rs index fef4702be..8ad8037b3 100644 --- a/tests/pdf_export_text_check.rs +++ b/tests/pdf_export_text_check.rs @@ -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();