feat(text): route every text-bearing entity through the run-aware MText pipeline

Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.

Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
  `layout_mtext`; `\C7;Red`-style codes no longer render as literal
  glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
  and the LOD-aware path in [scene/tessellate.rs]) — drops the
  `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
  baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
  bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
  the value goes through the MText pipeline (SingleLine stays on the
  Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
  MText (instead of Text) whenever the value contains MText escapes;
  the LOD ladder via `tessellate_entity_dim_text` handles both kinds.

Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
  `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
  callers that draw chrome (frames, fills, baselines, greek rects)
  share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
  and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
  `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
  `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
  deleted — all callers migrated.

Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-22 05:53:01 +03:00
1 parent 5c0c144275
commit 4e5d2c8d04
8 files changed
+1027 -1070

No files matched your search

+4 -22
View File
@@ -476,28 +476,10 @@ pub fn tessellate_text_run(
out
}
/// Measure the width of a rendered text string in font glyph units × scale.
/// Returns the total advance width in world units (height-independent: divide by height for
/// a normalised ratio, or use directly when `height` is already the desired em size).
pub fn measure_text(text: &str, height: f32, width_factor: f32, font_name: &str) -> f32 {
if text.is_empty() || height <= 0.0 {
return 0.0;
}
let font = get_font(font_name);
let scale = height / 9.0;
let wf = width_factor.clamp(0.01, 100.0);
let mut cursor_x: f32 = 0.0;
for ch in text.chars() {
if ch == ' ' {
cursor_x += font.word_spacing;
} else if let Some(glyph) = font.glyph(ch) {
cursor_x += glyph.advance + font.letter_spacing;
} else {
cursor_x += 6.0 + font.letter_spacing;
}
}
cursor_x * scale * wf
}
// `measure_text` was removed alongside the legacy MText helpers in
// `text_support`; the run-aware `measure_word` / `measure_space` in
// `text_support` carry per-run state (tracking / width factor / font
// override) and are what every caller now uses.
// ── Parser ────────────────────────────────────────────────────────────────
+164 -98
View File
@@ -13,9 +13,9 @@
// Entities not handled by acad_to_truck (Viewport, Hatch, …) are tessellated
// by the legacy geometry() path so nothing regresses.
use crate::entities::multileader::{catmull_rom_pts, v_offset_for_attachment};
use crate::entities::multileader::catmull_rom_pts;
use acadrust::entities::{
Dimension, Leader, LeaderContentType, MultiLeader, MultiLeaderPathType, Text,
Dimension, Leader, LeaderContentType, MText, MultiLeader, MultiLeaderPathType, Text,
TextAttachmentPointType,
};
use acadrust::tables::DimStyle;
@@ -864,15 +864,14 @@ pub fn tessellate_dimension(
}
}
if let Some(text) = dimension_text_entity(dim, dim_txt, style, document) {
if let Some(synth_text_entity) = dimension_text_entity(dim, dim_txt, style, document) {
// Tolerance Text rendered separately so DIMTFAC scales its height
// and DIMTOLJ aligns it vertically against the primary text.
let tol_entity = dimension_tolerance_entity(dim, style, &text, dim_txt);
let tol_entity = dimension_tolerance_entity(dim, style, &synth_text_entity, dim_txt);
// Route synthesised dim text through tessellate_entity so the
// baseline/greek/full LOD ladder applies (zoom-out behaviour
// matches top-level Text). The text already has dim_scale baked
// into its height, so anno_scale stays 1.0.
let synth_text_entity = EntityType::Text(text);
// matches top-level Text / MText). The text already has dim_scale
// baked into its height, so anno_scale stays 1.0.
let text_wires = crate::scene::tessellate_entity_dim_text(
document,
selected_set,
@@ -890,8 +889,7 @@ pub fn tessellate_dimension(
wires.push(w);
}
if let Some(tol_text) = tol_entity {
let tol_entity_e = EntityType::Text(tol_text);
if let Some(tol_entity_e) = tol_entity {
let tol_wires = crate::scene::tessellate_entity_dim_text(
document,
selected_set,
@@ -1703,49 +1701,11 @@ pub fn tessellate_multileader(
})
.unwrap_or_else(|| "STANDARD".to_string());
let style = crate::entities::text_support::resolve_text_style(&style_name, document);
let font_name = style.font_name;
let font = crate::scene::cxf::get_font(&font_name);
// MultiLeader text takes its width factor / oblique from the style
// (no entity-level override exists for MText-content multileaders).
// is_backward flips the sign on width_factor; is_upside_down adds π
// to the text rotation — same convention as Text / MText.
let base_wf = style.width_factor.max(0.01);
let width_factor = if style.is_backward { -base_wf } else { base_wf };
let oblique = style.oblique_angle;
if style.is_upside_down {
rot += std::f32::consts::PI;
}
let (cos_r, sin_r) = (rot.cos(), rot.sin());
// Strip MText format codes (e.g. `{\fArial Black|b0|i0|c162|p34;...}`),
// then split on \P / \n / \N and optionally word-wrap to text_width.
let plain = crate::entities::text_support::strip_mtext_codes(&ctx.text_string);
let explicit_lines = crate::entities::text_support::split_mtext_lines(&plain);
let lines: Vec<String> = if ctx.text_width > 0.0 {
// Measurement uses positive width magnitude; the sign on
// width_factor is a render-time mirror flag.
let scale = height / 9.0 * width_factor.abs();
// ctx.text_width lives in the same WCS frame as ctx.text_height;
// it does not need a second multiplication by effective_scale.
let max_w = ctx.text_width as f32;
explicit_lines
.iter()
.flat_map(|line| {
crate::entities::text_support::word_wrap(line, max_w, scale, font)
})
.collect()
} else {
explicit_lines
};
let ls_factor = if ctx.line_spacing_factor > 0.0 {
ctx.line_spacing_factor as f32
} else {
1.0
};
let line_h = height * ls_factor * (5.0 / 3.0) * font.line_spacing;
let n_lines = lines.len().max(1) as f32;
// ml.text_alignment overrides the per-context attachment_point when
// not Left (which is the default zero). Otherwise we honour the
// context's stored attachment_point (Left/Center/Right).
@@ -1780,16 +1740,32 @@ pub fn tessellate_multileader(
}
TextAttachmentDirectionType::Horizontal => ctx.text_left_attachment,
};
let v_offset = v_offset_for_attachment(vertical_attach, n_lines, height, line_h);
let v_anchor =
crate::entities::multileader::mleader_v_anchor(vertical_attach);
// line_widths use positive magnitude for measurement; tessellate_text_ex
// gets the signed width_factor below to honour the backward flag.
let scale = height / 9.0 * width_factor.abs();
let line_widths: Vec<f32> = lines
.iter()
.map(|line| crate::entities::text_support::measure_mtext_chars(line, scale, font))
.collect();
// Shared MText pipeline — every inline format code (`\f`, `\C`/`\c`,
// `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked
// fractions, …) reaches the stroke output. Stroke origins are
// already in offset-relative space because we pass local_ins_x/y.
let layout = crate::entities::text_support::layout_mtext(
&crate::entities::text_support::MTextRenderOpts {
value: &ctx.text_string,
insertion: [local_ins_x as f64, local_ins_y as f64, z as f64],
height,
rect_w: ctx.text_width as f32,
rotation: rot,
style: &style,
attach_h_anchor: h_anchor,
v_anchor,
line_spacing_factor: ctx.line_spacing_factor as f32,
vertical_text: false,
},
);
let line_widths = &layout.line_widths;
let max_line_w = line_widths.iter().cloned().fold(0.0_f32, f32::max);
let line_h = layout.line_height;
let v_offset = layout.v_offset;
let n_lines = layout.line_count.max(1) as f32;
// Resolve text color (falls back to entity color for ByLayer / ByBlock).
let text_color = if selected {
@@ -1855,12 +1831,11 @@ pub fn tessellate_multileader(
// per row" hint that multi-line MText carries, in the text's
// own color. Empty `points` opts out of the face3d 0.45 dim so
// the fill renders at full intensity.
let mut greek_tris: Vec<[f32; 3]> = Vec::with_capacity(lines.len() * 6);
for (i, _) in lines.iter().enumerate() {
let mut greek_tris: Vec<[f32; 3]> = Vec::with_capacity(line_widths.len() * 6);
for (i, &line_w) in line_widths.iter().enumerate() {
let li = i as f32;
let line_y_bottom = -li * line_h + v_offset;
let line_y_top = line_y_bottom + height;
let line_w = line_widths[i];
if line_w <= 0.0 {
continue;
}
@@ -1895,34 +1870,20 @@ pub fn tessellate_multileader(
});
}
} else {
// Build per-line stroke points in WCS.
// Pre-tessellated by `layout_mtext`. Each TextStroke is in
// local glyph space with its world origin (already offset-
// relative because we passed local_ins_x/y) stored as f64.
let mut text_points: Vec<[f32; 3]> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let li = i as f32;
let line_y_local = -li * line_h + v_offset;
let line_w = line_widths[i];
let h_shift_local = -line_w * h_anchor;
let wcs_dx = h_shift_local * cos_r - line_y_local * sin_r;
let wcs_dy = h_shift_local * sin_r + line_y_local * cos_r;
// Origin already in offset-relative space — tessellator will rotate
// glyph offsets around it and produce points directly in render space.
let origin = [local_ins_x + wcs_dx, local_ins_y + wcs_dy];
let strokes = crate::scene::cxf::tessellate_text_ex(
origin,
height,
rot,
width_factor,
oblique,
&font_name,
line,
);
for stroke in &strokes {
for ts in &layout.strokes {
let ox = ts.origin[0] as f32;
let oy = ts.origin[1] as f32;
for stroke in &ts.strokes {
if stroke.len() < 2 {
continue;
}
text_points.push(nan);
for &[x, y] in stroke {
text_points.push([x, y, z]);
text_points.push([x + ox, y + oy, z]);
}
}
}
@@ -2805,16 +2766,68 @@ fn dimension_snap_pts(dim: &Dimension, world_offset: [f64; 3]) -> Vec<(Vec3, Sna
}
}
/// Cheap heuristic: does this string contain anything the MText parser would
/// interpret? Used by `dimension_text_entity` to pick between a synthetic
/// `Text` (plain DXF special chars only) and a synthetic `MText` (full inline
/// format-code pipeline) for the dim text override.
fn value_has_mtext_codes(s: &str) -> bool {
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' || c == '}' {
return true;
}
if c == '\\' {
if let Some(&next) = chars.peek() {
// Any backslash followed by a known MText escape letter.
if matches!(
next,
'H' | 'W'
| 'Q'
| 'T'
| 'A'
| 'C'
| 'c'
| 'f'
| 'F'
| 'p'
| 'L'
| 'l'
| 'O'
| 'o'
| 'K'
| 'k'
| 'S'
| 's'
| 'P'
| 'n'
| 'N'
| 't'
| 'U'
| 'u'
| 'M'
| 'X'
| '~'
| '{'
| '}'
) {
return true;
}
}
}
}
false
}
fn dimension_text_entity(
dim: &Dimension,
text_height: f64,
style: Option<&DimStyle>,
document: &CadDocument,
) -> Option<Text> {
) -> Option<EntityType> {
let value = dimension_text_value(dim, style)?;
// Use f64 position directly to avoid f32 round-trip precision loss at large
// coordinates (e.g. Turkish UTM ~4,000,000 m). tessellate() will apply
// world_offset when rendering this synthetic Text entity.
// world_offset when rendering this synthetic entity.
let pos_f64 = dimension_text_pos_f64(dim, style, text_height);
let base = dim.base();
@@ -2835,15 +2848,11 @@ fn dimension_text_entity(
dimension_text_natural_rotation(dim)
};
let mut text = Text::with_value(value, pos_f64)
.with_height(text_height)
.with_rotation(rotation);
// Text style resolution priority:
// 1. DIMTXSTY by handle (most reliable; survives rename)
// 2. DIMTXSTY by name
// 3. dim's own style_name (rare fallback)
text.style = style
let style_name = style
.and_then(|s| {
if !s.dimtxsty_handle.is_null() {
document
@@ -2862,6 +2871,41 @@ fn dimension_text_entity(
})
.unwrap_or_else(|| base.style_name.clone());
// Route through MText whenever the value carries inline format codes
// (`\f`, `\C`, `\H`, `\S`, brace scopes, …). Otherwise stay on the Text
// path — single-line dim text doesn't need the full MText pipeline.
if value_has_mtext_codes(&value) {
use acadrust::entities::dimension::AttachmentPointType as DA;
use acadrust::entities::AttachmentPoint as MA;
let attachment_point = match base.attachment_point {
DA::TopLeft => MA::TopLeft,
DA::TopCenter => MA::TopCenter,
DA::TopRight => MA::TopRight,
DA::MiddleLeft => MA::MiddleLeft,
DA::MiddleCenter => MA::MiddleCenter,
DA::MiddleRight => MA::MiddleRight,
DA::BottomLeft => MA::BottomLeft,
DA::BottomCenter => MA::BottomCenter,
DA::BottomRight => MA::BottomRight,
};
let mut mtext = MText::with_value(value, pos_f64);
mtext.height = text_height;
mtext.rotation = rotation;
mtext.style = style_name;
mtext.attachment_point = attachment_point;
if base.line_spacing_factor.abs() > 1e-9 {
mtext.line_spacing_factor = base.line_spacing_factor;
}
mtext.normal = base.normal;
mtext.common = base.common.clone();
return Some(EntityType::MText(mtext));
}
let mut text = Text::with_value(value, pos_f64)
.with_height(text_height)
.with_rotation(rotation);
text.style = style_name;
// Map AttachmentPointType (1..9 grid) to Text horizontal + vertical
// alignments. 1=TopLeft … 9=BottomRight (column-major).
let (ha, va) = attachment_to_text_align(base.attachment_point);
@@ -2876,7 +2920,7 @@ fn dimension_text_entity(
let _ = base.normal;
text.common = base.common.clone();
Some(text)
Some(EntityType::Text(text))
}
fn attachment_to_text_align(
@@ -3073,9 +3117,9 @@ fn alternate_units_text(measurement: f64, style: Option<&DimStyle>) -> Option<St
fn dimension_tolerance_entity(
dim: &Dimension,
style: Option<&DimStyle>,
primary: &Text,
primary: &EntityType,
primary_height: f64,
) -> Option<Text> {
) -> Option<EntityType> {
let s = style?;
let is_angular = matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_));
let tol = build_tolerance_suffix(dim.measurement(), style, is_angular)?;
@@ -3086,8 +3130,30 @@ fn dimension_tolerance_entity(
};
let tol_height = primary_height * dimtfac;
// Pull the geometry we need from the synthetic primary entity (Text or
// MText — `dimension_text_entity` routes to MText when the dim value
// carries inline format codes).
let (primary_value_len, primary_insertion, primary_rotation, primary_style, primary_common) =
match primary {
EntityType::Text(t) => (
t.value.chars().count(),
t.insertion_point,
t.rotation,
t.style.clone(),
t.common.clone(),
),
EntityType::MText(m) => (
m.value.chars().count(),
m.insertion_point,
m.rotation,
m.style.clone(),
m.common.clone(),
),
_ => return None,
};
// Approximate widths from glyph counts (~0.6 × cell size per char).
let primary_w = primary.value.chars().count() as f64 * primary_height * 0.6;
let primary_w = primary_value_len as f64 * primary_height * 0.6;
let tol_w = tol.chars().count() as f64 * tol_height * 0.6;
let gap = primary_height * 0.2;
let dx_local = primary_w * 0.5 + tol_w * 0.5 + gap;
@@ -3096,21 +3162,21 @@ fn dimension_tolerance_entity(
2 => primary_height * 0.5 - tol_height * 0.5, // top-aligned with primary top
_ => 0.0, // centred (default for ±)
};
let rot = primary.rotation;
let rot = primary_rotation;
let (sr, cr) = rot.sin_cos();
let pos = Vector3::new(
primary.insertion_point.x + dx_local * cr - dy_local * sr,
primary.insertion_point.y + dx_local * sr + dy_local * cr,
primary.insertion_point.z,
primary_insertion.x + dx_local * cr - dy_local * sr,
primary_insertion.y + dx_local * sr + dy_local * cr,
primary_insertion.z,
);
let mut t = Text::with_value(tol, pos)
.with_height(tol_height)
.with_rotation(rot);
t.style = primary.style.clone();
t.common = primary.common.clone();
t.style = primary_style;
t.common = primary_common;
t.horizontal_alignment = acadrust::entities::text::TextHorizontalAlignment::Center;
t.vertical_alignment = acadrust::entities::text::TextVerticalAlignment::Middle;
Some(t)
Some(EntityType::Text(t))
}
/// Wrap a measured value with the style's DIMPOST prefix/suffix template.