fix: resolve DXF %%x codes and render MText decoration strokes

Text/MText values now correctly handle DXF special-character sequences:
- %%d → °, %%p → ±, %%c → ⌀, %%nnn → Unicode scalar (resolved in tessellation)
- %%u / %%o toggle underline and overline strokes for TEXT entities

MText decoration codes (\L/\l, \O/\o, \K/\k) were previously consumed
as argument-bearing codes, silently eating the decorated text. Fixed
strip_mtext_codes to keep them as zero-width markers; tessellate_text_ex
now emits actual line strokes (underline, overline, strikethrough).
measure_mtext_chars and word_wrap skip markers so line widths are correct.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Sonnet 4.6 committed 2026-04-26 21:57:51 +03:00
1 parent 089612584d
commit 4504916f97
4 files changed
+222 -37

No files matched your search

+2 -11
View File
@@ -4,7 +4,7 @@ use glam::Vec3;
use crate::command::EntityTransform;
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip};
use crate::entities::text_support::{
resolve_text_style, split_mtext_lines, strip_mtext_codes, word_wrap,
measure_mtext_chars, resolve_text_style, split_mtext_lines, strip_mtext_codes, word_wrap,
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
@@ -145,16 +145,7 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
};
let line_w = if h_anchor > 0.0 {
let scale = t.height as f32 / 9.0 * style_width_factor;
line.chars()
.map(|c| {
if c == ' ' {
return font.word_spacing * scale;
}
font.glyph(c)
.map(|g| (g.advance + font.letter_spacing) * scale)
.unwrap_or(t.height as f32 * 0.6)
})
.sum()
measure_mtext_chars(line, scale, font)
} else {
0.0
};
+5 -2
View File
@@ -3,7 +3,7 @@ use glam::Vec3;
use crate::command::EntityTransform;
use crate::entities::common::{edit_prop as edit, parse_f64, square_grip};
use crate::entities::text_support::{resolve_text_style, text_local_bounds};
use crate::entities::text_support::{resolve_dxf_special_chars, resolve_text_style, text_local_bounds};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::cxf;
@@ -71,9 +71,11 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
(_, VA::Bottom | VA::Middle | VA::Top, Some(a)) => [a.x as f32, a.y as f32],
_ => [t.insertion_point.x as f32, t.insertion_point.y as f32],
};
// Strip %%u/%%o for bounds (they add no width); resolve %%d/%%c/%%p for correct advance.
let value_for_bounds = resolve_dxf_special_chars(&t.value);
let bounds = text_local_bounds(
&font_name,
&t.value,
&value_for_bounds,
t.height as f32,
width_factor,
oblique_angle,
@@ -99,6 +101,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
anchor[0] - (anchor_local_x * cos_r - anchor_local_y * sin_r),
anchor[1] - (anchor_local_x * sin_r + anchor_local_y * cos_r),
];
// Pass raw value — tessellate_text_ex resolves %%x codes and emits decoration strokes.
let strokes_2d = cxf::tessellate_text_ex(
origin,
t.height as f32,
+87 -18
View File
@@ -101,6 +101,58 @@ pub fn text_local_bounds(
}
}
/// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values:
/// - `%%d` / `%%D` → `°`
/// - `%%p` / `%%P` → `±`
/// - `%%c` / `%%C` → `⌀`
/// - `%%u` / `%%U` → underline toggle (stripped — not renderable with stroke fonts)
/// - `%%o` / `%%O` → overline toggle (stripped)
/// - `%%%%` → `%`
/// - `%%nnn` (3 decimal digits) → Unicode scalar `nnn`
/// Any unrecognised `%%x` is passed through unchanged.
pub fn resolve_dxf_special_chars(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' || chars.peek() != Some(&'%') {
out.push(c);
continue;
}
chars.next(); // consume second '%'
match chars.peek().map(|c| c.to_ascii_lowercase()) {
Some('d') => { chars.next(); out.push('°'); }
Some('p') => { chars.next(); out.push('±'); }
Some('c') => { chars.next(); out.push('⌀'); }
Some('u') | Some('o') => { chars.next(); } // toggle codes — strip silently
Some('%') => { chars.next(); out.push('%'); }
Some(d) if d.is_ascii_digit() => {
let mut digits = String::with_capacity(3);
for _ in 0..3 {
match chars.peek() {
Some(&ch) if ch.is_ascii_digit() => { digits.push(chars.next().unwrap()); }
_ => break,
}
}
if digits.len() == 3 {
if let Ok(n) = digits.parse::<u32>() {
if let Some(ch) = char::from_u32(n) {
out.push(ch);
continue;
}
}
}
out.push('%');
out.push('%');
out.push_str(&digits);
}
_ => { out.push('%'); out.push('%'); }
}
}
out
}
pub fn strip_mtext_codes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
@@ -116,7 +168,14 @@ pub fn strip_mtext_codes(s: &str) -> String {
chars.next();
out.push(' ');
}
Some(c) if "pHWQTACcLOKlfFUu".contains(c) => {
// Decoration toggles — standalone (no args, no ';'). Keep as \X markers
// so tessellate_text_ex can emit underline/overline/strikethrough strokes.
Some('L') | Some('l') | Some('O') | Some('o') | Some('K') | Some('k') => {
out.push('\\');
out.push(chars.next().unwrap());
}
// Codes with semicolon-terminated arguments — strip entirely.
Some(c) if "pHWQTACcfFUu".contains(c) => {
chars.next();
for c in chars.by_ref() {
if c == ';' {
@@ -139,7 +198,7 @@ pub fn strip_mtext_codes(s: &str) -> String {
}
}
out
resolve_dxf_special_chars(&out)
}
pub fn split_mtext_lines(s: &str) -> Vec<String> {
@@ -149,32 +208,42 @@ pub fn split_mtext_lines(s: &str) -> Vec<String> {
.collect()
}
/// Measure the advance width of an MText line (after strip_mtext_codes), correctly
/// skipping decoration toggle markers (`\L`, `\l`, `\O`, `\o`, `\K`, `\k`).
pub fn measure_mtext_chars(text: &str, scale: f32, font: &cxf::CxfFile) -> f32 {
let mut width = 0.0_f32;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' && matches!(chars.peek(), Some('L' | 'l' | 'O' | 'o' | 'K' | 'k')) {
chars.next();
continue;
}
if c == ' ' {
width += font.word_spacing * scale;
} else {
width += font
.glyph(c)
.map(|g| (g.advance + font.letter_spacing) * scale)
.unwrap_or(scale * 6.0);
}
}
width
}
pub fn word_wrap(text: &str, max_w: f32, scale: f32, font: &'static cxf::CxfFile) -> Vec<String> {
if max_w <= 0.0 || text.is_empty() {
return vec![text.to_string()];
}
let glyph_w = |c: char| -> f32 {
if c == ' ' {
return font.word_spacing * scale;
}
font.glyph(c)
.map(|g| (g.advance + font.letter_spacing) * scale)
.unwrap_or(scale * 6.0)
};
let space_w = font.word_spacing * scale;
let mut lines = Vec::new();
let mut current = String::new();
let mut current_w = 0.0_f32;
for word in text.split(' ') {
let word_w: f32 = word.chars().map(glyph_w).sum();
let space_w = if current.is_empty() {
0.0
} else {
glyph_w(' ')
};
if !current.is_empty() && current_w + space_w + word_w > max_w {
let word_w = measure_mtext_chars(word, scale, font);
let gap = if current.is_empty() { 0.0 } else { space_w };
if !current.is_empty() && current_w + gap + word_w > max_w {
lines.push(std::mem::take(&mut current));
current_w = 0.0;
}