From 1d29b626e66cc1a06999ba36dfb40e441929b3f6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 23:49:44 +0200 Subject: [PATCH] fix(fonts): MTEXT \W...x multiplies the width factor; document the atom cap (#1272) Co-Authored-By: Claude Sonnet 5.5 --- crates/fonts/src/mtext.rs | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index 1d53dc915..8ccc21989 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -173,7 +173,9 @@ enum Atom { Para(usize), } -/// Upper bound on parsed atoms (hostile contents). +/// Upper bound on parsed atoms (words, spaces and paragraph breaks) per MTEXT. Hostile contents +/// are cut here: once more than this many atoms are parsed, `parse` stops reading and the rest +/// of the contents is silently dropped from the layout. There is no warning to the caller. const MAX_ATOMS: usize = 200_000; /// Scale of stacked text relative to the surrounding text. const STACK_SCALE: f64 = 0.7; @@ -334,8 +336,14 @@ fn parse(contents: &str, base: Fmt) -> (Vec, Vec) { 'W' => { let (a, n) = arg(&chars, i); i = n; - if let Some(v) = num(a.trim_end_matches(['x', 'X'])).filter(|v| *v > 0.01 && *v < 100.0) { - derive(&mut fmts, &mut cur, &|f| f.wf = v); + // `\W2x;` multiplies the current factor (like `\H2x;`); `\W2;` sets it. + let (rel, v) = match a.trim().strip_suffix(['x', 'X']) { + Some(r) => (true, num(r)), + None => (false, num(&a)), + }; + if let Some(v) = v.filter(|v| *v > 0.01 && *v < 100.0) { + // Relative factors compound; keep the result within the absolute range. + derive(&mut fmts, &mut cur, &|f| f.wf = if rel { (f.wf * v).clamp(0.01, 100.0) } else { v }); } } 'Q' => { @@ -688,6 +696,29 @@ mod tests { assert_eq!(plain_mtext("unterminated \\H2"), "unterminated "); } + /// The width factor of the last format `parse` derived. + fn last_wf(contents: &str, base_wf: f64) -> f64 { + let base = Fmt { font: TextFont::Stroke, height: 1.0, color: None, under: false, over: false, wf: base_wf, oblique: 0.0, valign: 1 }; + parse(contents, base).1.last().map_or(f64::NAN, |f| f.wf) + } + + #[test] + fn width_factor_x_is_relative() { + assert_eq!(last_wf("\\W2;a", 1.5), 2.0, "absolute sets the factor"); + assert_eq!(last_wf("\\W2x;a", 1.5), 3.0, "x multiplies the current factor"); + assert_eq!(last_wf("\\W2;\\W2x;a", 1.0), 4.0, "relative builds on the last absolute"); + assert_eq!(last_wf("\\W0.5X;a", 1.0), 0.5, "capital X too"); + // Hostile: a long relative chain stays inside the absolute range. + let chain = "\\W99x;".repeat(50); + assert_eq!(last_wf(&chain, 1.0), 100.0); + let down = "\\W0.02x;".repeat(50); + assert_eq!(last_wf(&down, 1.0), 0.01); + // Out of range or not a number: ignored. + assert_eq!(last_wf("\\W1e999x;a", 1.5), 1.5); + assert_eq!(last_wf("\\WNaNx;a", 1.5), 1.5); + assert_eq!(last_wf("\\Wx;a", 1.5), 1.5); + } + #[test] fn wraps_to_width() { let l = layout_mtext("the quick brown fox jumps over the lazy dog", Vec2::ZERO, 1.0, 8.0, 1, 0.0, 1.0);