forked from 1b-agent/opencadstudio-upstream
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:
1 parent
443b935a3f
commit
02c470aac7
44 files changed
+1534
-127
No files matched your search
Vendored
+47
-7
@@ -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(
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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(""), "");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user