merman_render/text/
deterministic.rs1use super::{TextMeasurer, TextMetrics, TextStyle, WrapMode, estimate_line_width_px};
4
5#[derive(Debug, Clone, Default)]
6pub struct DeterministicTextMeasurer {
7 pub char_width_factor: f64,
8 pub line_height_factor: f64,
9}
10
11impl DeterministicTextMeasurer {
12 fn replace_br_variants(text: &str) -> String {
13 let mut out = String::with_capacity(text.len());
14 let mut i = 0usize;
15 while i < text.len() {
16 let Some(rest) = text.get(i..) else {
17 break;
18 };
19
20 if rest.starts_with('<') {
24 let bytes = text.as_bytes();
25 if i + 3 < bytes.len()
26 && matches!(bytes[i + 1], b'b' | b'B')
27 && matches!(bytes[i + 2], b'r' | b'R')
28 {
29 let mut j = i + 3;
30 while j < bytes.len() && matches!(bytes[j], b' ' | b'\t' | b'\r' | b'\n') {
31 j += 1;
32 }
33 if j < bytes.len() && bytes[j] == b'/' {
34 j += 1;
35 }
36 if j < bytes.len() && bytes[j] == b'>' {
37 out.push('\n');
38 i = j + 1;
39 continue;
40 }
41 }
42 }
43
44 let Some(ch) = rest.chars().next() else {
45 break;
46 };
47 out.push(ch);
48 i += ch.len_utf8();
49 }
50 out
51 }
52
53 pub fn normalized_text_lines(text: &str) -> Vec<String> {
54 let t = Self::replace_br_variants(text);
55 let mut out = t.split('\n').map(|s| s.to_string()).collect::<Vec<_>>();
56
57 while out.len() > 1 && out.last().is_some_and(|s| s.trim().is_empty()) {
61 out.pop();
62 }
63
64 if out.is_empty() {
65 vec!["".to_string()]
66 } else {
67 out
68 }
69 }
70
71 pub(crate) fn split_line_to_words(text: &str) -> Vec<String> {
72 let parts = text.split(' ').collect::<Vec<_>>();
75 let mut out: Vec<String> = Vec::new();
76 for part in parts {
77 if !part.is_empty() {
78 out.push(part.to_string());
79 }
80 out.push(" ".to_string());
81 }
82 while out.last().is_some_and(|s| s == " ") {
83 out.pop();
84 }
85 out
86 }
87
88 fn wrap_line(line: &str, max_chars: usize, break_long_words: bool) -> Vec<String> {
89 if max_chars == 0 {
90 return vec![line.to_string()];
91 }
92
93 let mut tokens = std::collections::VecDeque::from(Self::split_line_to_words(line));
94 let mut out: Vec<String> = Vec::new();
95 let mut cur = String::new();
96
97 while let Some(tok) = tokens.pop_front() {
98 if cur.is_empty() && tok == " " {
99 continue;
100 }
101
102 let candidate = format!("{cur}{tok}");
103 if candidate.chars().count() <= max_chars {
104 cur = candidate;
105 continue;
106 }
107
108 if !cur.trim().is_empty() {
109 out.push(cur.trim_end().to_string());
110 cur.clear();
111 tokens.push_front(tok);
112 continue;
113 }
114
115 if tok == " " {
117 continue;
118 }
119 if !break_long_words {
120 out.push(tok);
121 } else {
122 let tok_chars = tok.chars().collect::<Vec<_>>();
124 let head: String = tok_chars.iter().take(max_chars.max(1)).collect();
125 let tail: String = tok_chars.iter().skip(max_chars.max(1)).collect();
126 out.push(head);
127 if !tail.is_empty() {
128 tokens.push_front(tail);
129 }
130 }
131 }
132
133 if !cur.trim().is_empty() {
134 out.push(cur.trim_end().to_string());
135 }
136
137 if out.is_empty() {
138 vec!["".to_string()]
139 } else {
140 out
141 }
142 }
143}
144
145impl TextMeasurer for DeterministicTextMeasurer {
146 fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics {
147 self.measure_wrapped(text, style, None, WrapMode::SvgLike)
148 }
149
150 fn measure_wrapped(
151 &self,
152 text: &str,
153 style: &TextStyle,
154 max_width: Option<f64>,
155 wrap_mode: WrapMode,
156 ) -> TextMetrics {
157 self.measure_wrapped_impl(text, style, max_width, wrap_mode, true)
158 .0
159 }
160
161 fn measure_wrapped_with_raw_width(
162 &self,
163 text: &str,
164 style: &TextStyle,
165 max_width: Option<f64>,
166 wrap_mode: WrapMode,
167 ) -> (TextMetrics, Option<f64>) {
168 self.measure_wrapped_impl(text, style, max_width, wrap_mode, true)
169 }
170
171 fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
172 let t = text.trim_end();
173 if t.is_empty() {
174 return 0.0;
175 }
176 (style.font_size.max(1.0) * 1.1).max(0.0)
177 }
178}
179
180impl DeterministicTextMeasurer {
181 fn measure_wrapped_impl(
182 &self,
183 text: &str,
184 style: &TextStyle,
185 max_width: Option<f64>,
186 wrap_mode: WrapMode,
187 clamp_html_width: bool,
188 ) -> (TextMetrics, Option<f64>) {
189 let uses_heuristic_widths = self.char_width_factor == 0.0;
190 let char_width_factor = if uses_heuristic_widths {
191 match wrap_mode {
192 WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => 0.6,
193 WrapMode::HtmlLike => 0.5,
194 }
195 } else {
196 self.char_width_factor
197 };
198 let default_line_height_factor = match wrap_mode {
199 WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => 1.1,
200 WrapMode::HtmlLike => 1.5,
201 };
202 let line_height_factor = if self.line_height_factor == 0.0 {
203 default_line_height_factor
204 } else {
205 self.line_height_factor
206 };
207
208 let font_size = style.font_size.max(1.0);
209 let max_width = max_width.filter(|w| w.is_finite() && *w > 0.0);
210 let break_long_words = matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun);
211
212 let raw_lines = Self::normalized_text_lines(text);
213 let mut raw_width: f64 = 0.0;
214 for line in &raw_lines {
215 let w = if uses_heuristic_widths {
216 estimate_line_width_px(line, font_size)
217 } else {
218 line.chars().count() as f64 * font_size * char_width_factor
219 };
220 raw_width = raw_width.max(w);
221 }
222 let needs_wrap =
223 wrap_mode == WrapMode::HtmlLike && max_width.is_some_and(|w| raw_width > w);
224
225 let mut lines = Vec::new();
226 for line in raw_lines {
227 if let Some(w) = max_width {
228 let char_px = font_size * char_width_factor;
229 let max_chars = ((w / char_px).floor() as isize).max(1) as usize;
230 lines.extend(Self::wrap_line(&line, max_chars, break_long_words));
231 } else {
232 lines.push(line);
233 }
234 }
235
236 let mut width: f64 = 0.0;
237 for line in &lines {
238 let w = if uses_heuristic_widths {
239 estimate_line_width_px(line, font_size)
240 } else {
241 line.chars().count() as f64 * font_size * char_width_factor
242 };
243 width = width.max(w);
244 }
245 if clamp_html_width && wrap_mode == WrapMode::HtmlLike {
249 if let Some(w) = max_width {
250 if needs_wrap {
251 width = w;
252 } else {
253 width = width.min(w);
254 }
255 }
256 }
257 let height = lines.len() as f64 * font_size * line_height_factor;
258 let metrics = TextMetrics {
259 width,
260 height,
261 line_count: lines.len(),
262 };
263 let raw_width_px = if wrap_mode == WrapMode::HtmlLike {
264 Some(raw_width)
265 } else {
266 None
267 };
268 (metrics, raw_width_px)
269 }
270}