fix: decode all MTEXT escape codes correctly

Previously \\U+XXXX was in the "strip until ;" list, silently dropping
all Unicode character references (°, ±, ⌀, Ø, €, …).

Changes:
- \\U+XXXX / \\u+XXXX  decode to the actual Unicode character (was stripped)
- \\S upper/lower;     stacked fractions/tolerances render as "upper/lower"
                       or "upper^lower" instead of leaking as literal text
- \\t                  tab → four spaces (was leaking as literal 't')
- Unknown escapes      silently consume the code char (was leaking it)
- \\M+...;             DBCS multibyte — stripped (added to semicolon-arg list)
- \\X                  paragraph-align end marker — stripped cleanly

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Sonnet 4.6 committed 2026-05-01 11:51:45 +03:00
1 parent c5c5ba1b60
commit 3009639741
1 file changed
+86 -12
+86 -12
View File
@@ -164,38 +164,112 @@ pub fn strip_mtext_codes(s: &str) -> String {
while let Some(ch) = chars.next() {
match ch {
'\\' => match chars.peek().copied() {
// Paragraph / line breaks
Some('P') | Some('n') | Some('N') => {
chars.next();
out.push('\n');
}
// Non-breaking space → regular space (preserves spacing, simpler for word-wrap)
Some('~') => {
chars.next();
out.push(' ');
}
// Decoration toggles — standalone (no args, no ';'). Keep as \X markers
// so tessellate_text_ex can emit underline/overline/strikethrough strokes.
// Tab
Some('t') => {
chars.next();
out.push_str(" ");
}
// \U+XXXX or \u+XXXX — Unicode character by hex code point.
// Up to 6 hex digits; optional trailing semicolon.
// NOTE: 'U'/'u' were previously in the "strip until ;" list — that was wrong.
Some('U') | Some('u') => {
chars.next();
if chars.peek() == Some(&'+') {
chars.next(); // consume '+'
let mut hex = String::with_capacity(6);
for _ in 0..6 {
match chars.peek() {
Some(&c) if c.is_ascii_hexdigit() => {
hex.push(chars.next().unwrap());
}
_ => break,
}
}
if chars.peek() == Some(&';') {
chars.next(); // consume optional trailing semicolon
}
if let Ok(n) = u32::from_str_radix(&hex, 16) {
if let Some(decoded) = char::from_u32(n) {
out.push(decoded);
continue;
}
}
// Undecodable — silently drop
} else {
// \U or \u without '+' — strip until semicolon
for c in chars.by_ref() {
if c == ';' { break; }
}
}
}
// \S<upper><sep><lower>; — stacked text (fraction / tolerance).
// sep is '/' (diagonal fraction), '^' (stacked with bar), '#' (horizontal bar).
// Render as "upper/lower" or "upper^lower" since stroke fonts can't stack.
Some('S') | Some('s') => {
chars.next();
let mut upper = String::new();
let mut lower = String::new();
let mut sep = '/';
let mut in_lower = false;
for c in chars.by_ref() {
if c == ';' { break; }
if !in_lower && (c == '/' || c == '^' || c == '#') {
sep = c;
in_lower = true;
} else if in_lower {
lower.push(c);
} else {
upper.push(c);
}
}
out.push_str(&upper);
if !lower.is_empty() {
out.push(if sep == '#' { '/' } else { sep });
out.push_str(&lower);
}
}
// Decoration toggles — keep as \X markers so the tessellator 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 == ';' {
break;
}
}
}
// Literal backslash
Some('\\') => {
chars.next();
out.push('\\');
}
// Literal braces
Some('{') | Some('}') => {
out.push(chars.next().unwrap());
}
_ => {}
// In-line codes with semicolon-terminated arguments — strip entirely.
// p = paragraph format H = height W = width factor
// Q = oblique angle T = tracking A = alignment
// C = ACI color c = true color f/F = font change
// M = DBCS multibyte X = paragraph-align end (arg-less but safe to strip)
Some(c) if "pHWQTACcfFMX".contains(c) => {
chars.next();
for c in chars.by_ref() {
if c == ';' { break; }
}
}
// Unknown escape — consume and silently discard the code character so
// it does not appear as a literal in the output.
Some(_) => { chars.next(); }
None => {}
},
// Strip brace grouping markers (scope delimiters for in-line formatting)
'{' | '}' => {}
'\r' => {}
other => out.push(other),