1use crate::TextStyle;
2
3#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct TextSize {
6 pub width: f64,
7 pub height: f64,
8}
9
10pub trait TextMeasure {
13 fn measure(&self, text: &str, style: &TextStyle) -> TextSize;
14}
15
16const MONOSPACE_CHAR_WIDTH_14PX: f64 = 8.6;
20const MONOSPACE_LINE_HEIGHT_14PX: f64 = 16.8;
21
22pub struct SimpleTextMeasure {
25 pub avg_char_width: f64,
26 pub line_height: f64,
27}
28
29impl Default for SimpleTextMeasure {
30 fn default() -> Self {
31 Self {
32 avg_char_width: MONOSPACE_CHAR_WIDTH_14PX,
33 line_height: MONOSPACE_LINE_HEIGHT_14PX,
34 }
35 }
36}
37
38impl SimpleTextMeasure {
39 pub fn new(avg_char_width: f64, line_height: f64) -> Self {
40 debug_assert!(avg_char_width > 0.0, "char width must be positive");
41 debug_assert!(line_height > 0.0, "line height must be positive");
42 Self {
43 avg_char_width,
44 line_height,
45 }
46 }
47}
48
49use crate::font_fallback::{FontSlot, font_for_char};
50
51pub const fn char_width_ratio(ch: char) -> f64 {
54 match font_for_char(ch) {
55 FontSlot::Primary => 1.0, FontSlot::ExtendedText => 0.85, FontSlot::Monospace => 1.0, FontSlot::Dingbats => 1.4, FontSlot::Arabic => 0.8, FontSlot::Cjk => 1.8, FontSlot::Emoji => 2.0, }
63}
64
65impl SimpleTextMeasure {
66 pub fn measure_raw(text: &str, style: &TextStyle) -> TextSize {
69 let defaults = Self::default();
70 let scale = style.font_size / crate::constants::REFERENCE_FONT_SIZE;
71 let mut max_width: f64 = 0.0;
72 let mut line_count: usize = 0;
73 for line in text.split('\n') {
74 line_count += 1;
75 let w: f64 = line.chars().map(char_width_ratio).sum();
76 max_width = max_width.max(w);
77 }
78 TextSize {
79 width: max_width * defaults.avg_char_width * scale,
80 height: line_count as f64 * defaults.line_height * scale,
81 }
82 }
83}
84
85impl TextMeasure for SimpleTextMeasure {
86 fn measure(&self, text: &str, style: &TextStyle) -> TextSize {
87 let scale = style.font_size / crate::constants::REFERENCE_FONT_SIZE;
88 let stripped = strip_markup(text);
89 let mut max_width: f64 = 0.0;
90 let mut line_count: usize = 0;
91 for line in stripped.split('\n') {
92 line_count += 1;
93 let w: f64 = line.chars().map(char_width_ratio).sum();
94 max_width = max_width.max(w);
95 }
96
97 TextSize {
98 width: max_width * self.avg_char_width * scale,
99 height: line_count as f64 * self.line_height * scale,
100 }
101 }
102}
103
104pub fn text_baseline_y_offset(font_size: f64, line_count: usize) -> f64 {
117 let baseline_from_center = font_size * crate::constants::BASELINE_ASCENT_RATIO;
120 let line_height = font_size * crate::constants::LINE_HEIGHT_MULTIPLIER;
122 let block_offset = (line_count as f64 - 1.0) * line_height / 2.0;
123 baseline_from_center - block_offset
124}
125
126#[derive(Debug, Clone, PartialEq)]
128pub struct MdSpan {
129 pub text: String,
130 pub bold: bool,
131 pub italic: bool,
132}
133
134pub fn parse_inline_markdown(text: &str) -> Option<Vec<MdSpan>> {
137 if !text.contains('*') {
138 return None;
139 }
140
141 let mut spans = Vec::new();
142 let mut bold = false;
143 let mut italic = false;
144 let mut buf = String::new();
145 let mut chars = text.chars().peekable();
146
147 while let Some(c) = chars.next() {
148 if c == '*' && chars.peek() == Some(&'*') {
149 chars.next();
150 if !buf.is_empty() {
151 spans.push(MdSpan {
152 text: std::mem::take(&mut buf),
153 bold,
154 italic,
155 });
156 }
157 bold = !bold;
158 } else if c == '*' {
159 if !buf.is_empty() {
160 spans.push(MdSpan {
161 text: std::mem::take(&mut buf),
162 bold,
163 italic,
164 });
165 }
166 italic = !italic;
167 } else {
168 buf.push(c);
169 }
170 }
171 if !buf.is_empty() {
172 spans.push(MdSpan {
173 text: buf,
174 bold,
175 italic,
176 });
177 }
178
179 if spans.iter().any(|s| s.bold || s.italic) {
180 Some(spans)
181 } else {
182 None
183 }
184}
185
186fn strip_tags(text: &str, include_markdown: bool) -> String {
190 let mut result = String::with_capacity(text.len());
191 let mut in_tag = false;
192 let mut tag_buf = String::with_capacity(8);
193 let mut chars = text.chars().peekable();
194
195 while let Some(ch) = chars.next() {
196 if in_tag {
197 if ch == '>' {
198 in_tag = false;
199 if tag_buf.eq_ignore_ascii_case("br")
200 || tag_buf.eq_ignore_ascii_case("br/")
201 || tag_buf.eq_ignore_ascii_case("br /")
202 {
203 result.push('\n');
204 }
205 } else {
206 tag_buf.push(ch);
207 }
208 } else if ch == '<' {
209 in_tag = true;
210 tag_buf.clear();
211 } else if include_markdown && ch == '*' {
212 if chars.peek() == Some(&'*') {
213 chars.next();
214 }
215 } else {
216 result.push(ch);
217 }
218 }
219 result
220}
221
222fn strip_markup(text: &str) -> String {
224 strip_tags(text, true)
225}
226
227#[cfg(test)]
229fn strip_html_tags(text: &str) -> String {
230 strip_tags(text, false)
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 const W: f64 = MONOSPACE_CHAR_WIDTH_14PX;
237 const LH: f64 = MONOSPACE_LINE_HEIGHT_14PX;
238
239 fn default_style() -> TextStyle {
240 TextStyle::default()
241 }
242
243 #[test]
244 fn simple_measure_basic() {
245 let m = SimpleTextMeasure::default();
246 let s = m.measure("hello", &default_style());
247 assert!((s.width - 5.0 * W).abs() < f64::EPSILON);
248 assert!((s.height - LH).abs() < f64::EPSILON);
249 }
250
251 #[test]
252 fn simple_measure_empty() {
253 let m = SimpleTextMeasure::default();
254 let s = m.measure("", &default_style());
255 assert!((s.width - 0.0).abs() < f64::EPSILON);
256 assert!((s.height - LH).abs() < f64::EPSILON);
257 }
258
259 #[test]
260 fn simple_measure_strips_html() {
261 let m = SimpleTextMeasure::default();
262 let s = m.measure("<b>bold</b>", &default_style());
263 assert!((s.width - 4.0 * W).abs() < f64::EPSILON);
264 }
265
266 #[test]
267 fn simple_measure_br_adds_lines() {
268 let m = SimpleTextMeasure::default();
269 let s = m.measure("line1<br/>line2", &default_style());
270 assert!((s.height - 2.0 * LH).abs() < f64::EPSILON);
271 }
272
273 #[test]
274 fn simple_measure_font_size_scales() {
275 let m = SimpleTextMeasure::default();
276 let mut style = default_style();
277 style.font_size = 28.0; let s = m.measure("ab", &style);
279 assert!((s.width - 2.0 * W * 2.0).abs() < f64::EPSILON);
280 assert!((s.height - LH * 2.0).abs() < f64::EPSILON);
281 }
282
283 #[test]
284 fn simple_measure_custom_char_width() {
285 let m = SimpleTextMeasure::new(10.0, 20.0);
286 let s = m.measure("abc", &default_style());
287 assert!((s.width - 30.0).abs() < f64::EPSILON);
288 assert!((s.height - 20.0).abs() < f64::EPSILON);
289 }
290
291 #[test]
292 fn measure_strips_markdown() {
293 let m = SimpleTextMeasure::default();
294 let w_plain = m.measure("bold", &default_style()).width;
295 let w_md = m.measure("**bold**", &default_style()).width;
296 assert!(
297 (w_plain - w_md).abs() < f64::EPSILON,
298 "markdown markers should be stripped: plain={w_plain} md={w_md}"
299 );
300 }
301
302 #[test]
303 fn strip_html_basic() {
304 assert_eq!(strip_html_tags("<b>bold</b>"), "bold");
305 assert_eq!(strip_html_tags("<i>italic</i>"), "italic");
306 assert_eq!(strip_html_tags("no tags"), "no tags");
307 }
308
309 #[test]
310 fn strip_html_br_to_newline() {
311 assert_eq!(strip_html_tags("a<br/>b"), "a\nb");
312 assert_eq!(strip_html_tags("a<br>b"), "a\nb");
313 assert_eq!(strip_html_tags("a<br />b"), "a\nb");
314 }
315
316 #[test]
317 fn strip_html_nested() {
318 assert_eq!(strip_html_tags("<b><i>text</i></b>"), "text");
319 }
320
321 #[test]
322 fn default_trait() {
323 let m = SimpleTextMeasure::default();
324 assert!((m.avg_char_width - W).abs() < f64::EPSILON);
325 assert!((m.line_height - LH).abs() < f64::EPSILON);
326 }
327
328 #[test]
329 fn cjk_chars_wider_than_latin() {
330 let m = SimpleTextMeasure::default();
331 let w = m.measure("你好世界", &default_style()).width;
332 assert!((w - 4.0 * 1.8 * W).abs() < 1e-10);
333 }
334
335 #[test]
336 fn japanese_kana_wider_than_latin() {
337 let m = SimpleTextMeasure::default();
338 let w = m.measure("こんにちは世界", &default_style()).width;
339 assert!((w - 7.0 * 1.8 * W).abs() < 1e-10);
340 }
341
342 #[test]
343 fn mixed_latin_cjk() {
344 let m = SimpleTextMeasure::default();
345 let w = m.measure("Hi你好", &default_style()).width;
346 assert!((w - (2.0 + 2.0 * 1.8) * W).abs() < 1e-10);
347 }
348
349 #[test]
350 fn latin_and_cyrillic_widths() {
351 let m = SimpleTextMeasure::default();
352 let w_latin = m.measure("hello", &default_style()).width;
353 let w_cyrillic = m.measure("приве", &default_style()).width;
354 assert!((w_latin - 5.0 * W).abs() < 1e-10);
355 assert!((w_cyrillic - 5.0 * 0.85 * W).abs() < 1e-10);
356 }
357
358 #[test]
359 fn char_width_ratios() {
360 assert!((char_width_ratio('A') - 1.0).abs() < f64::EPSILON); assert!((char_width_ratio('你') - 1.8).abs() < f64::EPSILON); assert!((char_width_ratio('α') - 0.85).abs() < f64::EPSILON); assert!((char_width_ratio('★') - 1.4).abs() < f64::EPSILON); assert!((char_width_ratio('→') - 1.0).abs() < f64::EPSILON); assert!((char_width_ratio('م') - 0.8).abs() < f64::EPSILON); }
367
368 use proptest::prelude::*;
371
372 proptest! {
373 #[test]
374 fn char_width_ratio_always_positive(c in proptest::char::any()) {
375 let r = char_width_ratio(c);
376 prop_assert!(r > 0.0, "char_width_ratio({c:?}) = {r}, must be > 0");
377 }
378
379 #[test]
380 fn measure_width_positive_for_nonempty(
381 text in "[a-zA-Z0-9]{1,20}",
382 ) {
383 let m = SimpleTextMeasure::default();
384 let s = m.measure(&text, &default_style());
385 prop_assert!(s.width > 0.0, "width must be > 0 for non-empty text, got {}", s.width);
386 prop_assert!(s.height > 0.0, "height must be > 0, got {}", s.height);
387 }
388
389 #[test]
390 fn measure_scales_linearly_with_font_size(
391 text in "[a-z]{1,10}",
392 scale in 0.5..4.0f64,
393 ) {
394 let m = SimpleTextMeasure::default();
395 let base_style = default_style();
396 let mut scaled_style = base_style.clone();
397 scaled_style.font_size = base_style.font_size * scale;
398
399 let s1 = m.measure(&text, &base_style);
400 let s2 = m.measure(&text, &scaled_style);
401
402 prop_assert!((s2.width / s1.width - scale).abs() < 1e-10,
403 "width should scale by {scale}: w1={}, w2={}", s1.width, s2.width);
404 prop_assert!((s2.height / s1.height - scale).abs() < 1e-10,
405 "height should scale by {scale}: h1={}, h2={}", s1.height, s2.height);
406 }
407 }
408}