Skip to main content

rustmotion_core/engine/renderer/
text.rs

1use skia_safe::{Canvas, Font, Paint, Point, TextBlob, Typeface};
2
3use super::subpixel_font;
4
5pub fn format_counter_value(
6    value: f64,
7    decimals: u8,
8    separator: &Option<String>,
9    prefix: &Option<String>,
10    suffix: &Option<String>,
11) -> String {
12    let formatted_number = format!("{:.prec$}", value, prec = decimals as usize);
13
14    let formatted_number = if let Some(sep) = separator {
15        let parts: Vec<&str> = formatted_number.split('.').collect();
16        let integer_part = parts[0];
17
18        let (sign, digits) = if let Some(stripped) = integer_part.strip_prefix('-') {
19            ("-", stripped)
20        } else {
21            ("", integer_part)
22        };
23
24        let mut result = String::new();
25        for (i, ch) in digits.chars().rev().enumerate() {
26            if i > 0 && i % 3 == 0 {
27                result.insert(0, sep.chars().next().unwrap_or(' '));
28            }
29            result.insert(0, ch);
30        }
31
32        if !sign.is_empty() {
33            result.insert_str(0, sign);
34        }
35
36        if parts.len() > 1 {
37            result.push('.');
38            result.push_str(parts[1]);
39        }
40
41        result
42    } else {
43        formatted_number
44    };
45
46    let mut result = String::new();
47    if let Some(p) = prefix {
48        result.push_str(p);
49    }
50    result.push_str(&formatted_number);
51    if let Some(s) = suffix {
52        result.push_str(s);
53    }
54    result
55}
56
57pub fn wrap_text(text: &str, font: &Font, max_width: Option<f32>) -> Vec<String> {
58    let explicit_lines: Vec<&str> = text.split('\n').collect();
59
60    let max_w = match max_width {
61        Some(w) => w,
62        None => return explicit_lines.iter().map(|s| s.to_string()).collect(),
63    };
64
65    let mut result = Vec::new();
66    for line in explicit_lines {
67        let words: Vec<&str> = line.split_whitespace().collect();
68        if words.is_empty() {
69            result.push(String::new());
70            continue;
71        }
72
73        let mut current_line = String::new();
74        for word in words {
75            let test = if current_line.is_empty() {
76                word.to_string()
77            } else {
78                format!("{} {}", current_line, word)
79            };
80
81            let (width, _) = font.measure_str(&test, None);
82            if width > max_w && !current_line.is_empty() {
83                result.push(current_line);
84                current_line = word.to_string();
85            } else {
86                current_line = test;
87            }
88        }
89        if !current_line.is_empty() {
90            result.push(current_line);
91        }
92    }
93    result
94}
95
96pub fn make_text_blob_with_spacing(text: &str, font: &Font, spacing: f32) -> Option<TextBlob> {
97    let glyphs = font.str_to_glyphs_vec(text);
98    if glyphs.is_empty() {
99        return None;
100    }
101
102    let mut widths = vec![0.0f32; glyphs.len()];
103    font.get_widths(&glyphs, &mut widths);
104
105    let mut positions = Vec::with_capacity(glyphs.len());
106    let mut x = 0.0f32;
107    for (i, _glyph) in glyphs.iter().enumerate() {
108        positions.push(Point::new(x, 0.0));
109        x += widths[i] + spacing;
110    }
111
112    TextBlob::from_pos_text(text, &positions, font)
113}
114
115#[derive(Debug, Clone, Copy, PartialEq)]
116pub struct GlyphMetric {
117    pub x: f32,
118    pub width: f32,
119}
120
121fn glyph_metrics_single_font(text: &str, font: &Font, letter_spacing: f32) -> Vec<GlyphMetric> {
122    let glyphs = font.str_to_glyphs_vec(text);
123    if glyphs.is_empty() {
124        return Vec::new();
125    }
126    let mut widths = vec![0.0f32; glyphs.len()];
127    font.get_widths(&glyphs, &mut widths);
128    let mut out = Vec::with_capacity(glyphs.len());
129    let mut x = 0.0f32;
130    for w in widths {
131        out.push(GlyphMetric { x, width: w });
132        x += w + letter_spacing;
133    }
134    out
135}
136
137pub fn compute_glyph_metrics(
138    text: &str,
139    font: &Font,
140    emoji_font: &Option<Font>,
141    letter_spacing: f32,
142) -> Vec<GlyphMetric> {
143    if text.is_empty() {
144        return Vec::new();
145    }
146    if !needs_segmentation(text, font, emoji_font) {
147        return glyph_metrics_single_font(text, font, letter_spacing);
148    }
149
150    let runs = segment_text_runs(text, font);
151    let mut out = Vec::new();
152    let mut cursor_x = 0.0f32;
153    for run in &runs {
154        let segment = &text[run.start..run.end];
155        let mut owned = None;
156        let f = resolve_run_font(&run.kind, segment, font, emoji_font, &mut owned);
157        let metrics = glyph_metrics_single_font(segment, f, letter_spacing);
158        let run_advance: f32 = metrics.iter().map(|m| m.width + letter_spacing).sum();
159        out.extend(metrics.into_iter().map(|m| GlyphMetric {
160            x: cursor_x + m.x,
161            width: m.width,
162        }));
163        cursor_x += run_advance;
164    }
165    out
166}
167
168fn is_emoji_presentation_default(c: char) -> bool {
169    let cp = c as u32;
170    matches!(cp,
171        0x1F300..=0x1F5FF |
172        0x1F600..=0x1F64F |
173        0x1F680..=0x1F6FF |
174        0x1F900..=0x1F9FF |
175        0x1FA00..=0x1FA6F |
176        0x1FA70..=0x1FAFF |
177        0x200D |
178        0x20E3 |
179        0x1F1E0..=0x1F1FF |
180        0xE0020..=0xE007F |
181        0x1F004 | 0x1F0CF |
182        0x23E9..=0x23F3 |
183        0x23F8..=0x23FA |
184        0x2B1B..=0x2B1C |
185        0x2B50 | 0x2B55
186    )
187}
188
189fn is_text_presentation_by_default(c: char) -> bool {
190    let cp = c as u32;
191    matches!(cp,
192        0x00A9 | 0x00AE | 0x2122 |
193        0x231A..=0x231B |
194        0x2600..=0x26FF |
195        0x2702..=0x27B0 |
196        0x2934..=0x2935 |
197        0x25AA..=0x25AB |
198        0x25B6 | 0x25C0 |
199        0x25FB..=0x25FE |
200        0x2B05..=0x2B07 |
201        0x3030 | 0x303D |
202        0x3297 | 0x3299
203    )
204}
205
206const VARIATION_SELECTOR_EMOJI: char = '\u{FE0F}';
207
208fn char_wants_emoji_font(c: char, next: Option<char>) -> bool {
209    if c == VARIATION_SELECTOR_EMOJI {
210        return true;
211    }
212    if is_emoji_presentation_default(c) {
213        return true;
214    }
215    if is_text_presentation_by_default(c) {
216        return next == Some(VARIATION_SELECTOR_EMOJI);
217    }
218    false
219}
220
221enum RunKind {
222    Primary,
223    Emoji,
224    Fallback(Typeface),
225}
226
227fn same_run_kind(a: &RunKind, b: &RunKind) -> bool {
228    match (a, b) {
229        (RunKind::Primary, RunKind::Primary) => true,
230        (RunKind::Emoji, RunKind::Emoji) => true,
231        (RunKind::Fallback(ta), RunKind::Fallback(tb)) => ta.unique_id() == tb.unique_id(),
232        _ => false,
233    }
234}
235
236fn font_covers(font: &Font, text: &str) -> bool {
237    let glyphs = font.str_to_glyphs_vec(text);
238    !glyphs.is_empty() && glyphs.iter().all(|&g| g != 0)
239}
240
241fn classify_char(c: char, primary: &Font, next: Option<char>) -> RunKind {
242    if char_wants_emoji_font(c, next) {
243        return RunKind::Emoji;
244    }
245    if c.is_whitespace() || (c as u32) < 0x20 {
246        return RunKind::Primary;
247    }
248    if primary.unichar_to_glyph(c as i32) != 0 {
249        return RunKind::Primary;
250    }
251    let primary_typeface = primary.typeface();
252    let style = primary_typeface.font_style();
253    let family = primary_typeface.family_name();
254    match super::fallback_typeface_for_char(&family, style, c) {
255        Some(tf) => RunKind::Fallback(tf),
256        None => RunKind::Primary,
257    }
258}
259
260fn segment_text_runs(text: &str, primary: &Font) -> Vec<TextRun> {
261    let chars: Vec<(usize, char)> = text.char_indices().collect();
262    let mut runs = Vec::new();
263    let mut i = 0;
264    while i < chars.len() {
265        let (start_byte, c) = chars[i];
266        let next = chars.get(i + 1).map(|&(_, ch)| ch);
267        let kind = classify_char(c, primary, next);
268        let mut end_byte = start_byte + c.len_utf8();
269        i += 1;
270
271        while let Some(&(nb, nc)) = chars.get(i) {
272            let nnext = chars.get(i + 1).map(|&(_, ch)| ch);
273            let nkind = classify_char(nc, primary, nnext);
274            if !same_run_kind(&kind, &nkind) {
275                break;
276            }
277            end_byte = nb + nc.len_utf8();
278            i += 1;
279        }
280
281        runs.push(TextRun {
282            start: start_byte,
283            end: end_byte,
284            kind,
285        });
286    }
287    runs
288}
289
290struct TextRun {
291    start: usize,
292    end: usize,
293    kind: RunKind,
294}
295
296pub fn has_emoji(text: &str) -> bool {
297    let chars: Vec<char> = text.chars().collect();
298    chars
299        .iter()
300        .enumerate()
301        .any(|(i, &c)| char_wants_emoji_font(c, chars.get(i + 1).copied()))
302}
303
304fn needs_segmentation(text: &str, primary: &Font, emoji_font: &Option<Font>) -> bool {
305    if emoji_font.is_some() && has_emoji(text) {
306        return true;
307    }
308    text.chars().any(|c| {
309        !(c.is_ascii() || c.is_whitespace() || (c as u32) < 0x20)
310            && primary.unichar_to_glyph(c as i32) == 0
311    })
312}
313
314fn resolve_run_font<'a>(
315    kind: &RunKind,
316    segment: &str,
317    primary: &'a Font,
318    emoji_font: &'a Option<Font>,
319    owned: &'a mut Option<Font>,
320) -> &'a Font {
321    match kind {
322        RunKind::Primary => primary,
323        RunKind::Emoji => match emoji_font {
324            Some(ef) if font_covers(ef, segment.trim_end_matches(VARIATION_SELECTOR_EMOJI)) => ef,
325            _ => primary,
326        },
327        RunKind::Fallback(tf) => {
328            *owned = Some(subpixel_font(tf.clone(), primary.size()));
329            owned.as_ref().unwrap()
330        }
331    }
332}
333
334pub fn draw_text_with_fallback(
335    canvas: &Canvas,
336    text: &str,
337    font: &Font,
338    emoji_font: &Option<Font>,
339    letter_spacing: f32,
340    x: f32,
341    y: f32,
342    paint: &Paint,
343) {
344    if !needs_segmentation(text, font, emoji_font) {
345        if letter_spacing.abs() > 0.01 {
346            if let Some(blob) = make_text_blob_with_spacing(text, font, letter_spacing) {
347                canvas.draw_text_blob(&blob, (x, y), paint);
348            }
349        } else if let Some(blob) = TextBlob::new(text, font) {
350            canvas.draw_text_blob(&blob, (x, y), paint);
351        }
352        return;
353    }
354
355    let runs = segment_text_runs(text, font);
356    let mut cursor_x = x;
357
358    for run in &runs {
359        let segment = &text[run.start..run.end];
360        let mut owned = None;
361        let f = resolve_run_font(&run.kind, segment, font, emoji_font, &mut owned);
362
363        if letter_spacing.abs() > 0.01 {
364            if let Some(blob) = make_text_blob_with_spacing(segment, f, letter_spacing) {
365                canvas.draw_text_blob(&blob, (cursor_x, y), paint);
366            }
367        } else if let Some(blob) = TextBlob::new(segment, f) {
368            canvas.draw_text_blob(&blob, (cursor_x, y), paint);
369        }
370
371        let (w, _) = f.measure_str(segment, None);
372        let extra = if letter_spacing.abs() > 0.01 {
373            letter_spacing * (segment.chars().count() as f32 - 1.0).max(0.0)
374        } else {
375            0.0
376        };
377        cursor_x += w + extra;
378    }
379}
380
381pub fn measure_text_with_fallback(
382    text: &str,
383    font: &Font,
384    emoji_font: &Option<Font>,
385    letter_spacing: f32,
386) -> f32 {
387    if !needs_segmentation(text, font, emoji_font) {
388        let (w, _) = font.measure_str(text, None);
389        let extra = if letter_spacing.abs() > 0.01 {
390            letter_spacing * (text.chars().count() as f32 - 1.0).max(0.0)
391        } else {
392            0.0
393        };
394        return w + extra;
395    }
396
397    let runs = segment_text_runs(text, font);
398    let mut total_w = 0.0f32;
399
400    for run in &runs {
401        let segment = &text[run.start..run.end];
402        let mut owned = None;
403        let f = resolve_run_font(&run.kind, segment, font, emoji_font, &mut owned);
404        let (w, _) = f.measure_str(segment, None);
405        let extra = if letter_spacing.abs() > 0.01 {
406            letter_spacing * (segment.chars().count() as f32 - 1.0).max(0.0)
407        } else {
408            0.0
409        };
410        total_w += w + extra;
411    }
412    total_w
413}
414
415pub fn wrap_text_with_tracking(
416    text: &str,
417    font: &Font,
418    emoji_font: &Option<Font>,
419    max_width: Option<f32>,
420    letter_spacing: f32,
421) -> Vec<String> {
422    let explicit_lines: Vec<&str> = text.split('\n').collect();
423
424    let max_w = match max_width {
425        Some(w) => w,
426        None => return explicit_lines.iter().map(|s| s.to_string()).collect(),
427    };
428
429    let mut result = Vec::new();
430    for line in explicit_lines {
431        let words: Vec<&str> = line.split_whitespace().collect();
432        if words.is_empty() {
433            result.push(String::new());
434            continue;
435        }
436
437        let mut current_line = String::new();
438        for word in words {
439            let test = if current_line.is_empty() {
440                word.to_string()
441            } else {
442                format!("{} {}", current_line, word)
443            };
444
445            let width = measure_text_with_fallback(&test, font, emoji_font, letter_spacing);
446            if width > max_w && !current_line.is_empty() {
447                result.push(current_line);
448                current_line = word.to_string();
449            } else {
450                current_line = test;
451            }
452        }
453        if !current_line.is_empty() {
454            result.push(current_line);
455        }
456    }
457    result
458}
459
460pub fn wrap_text_with_fallback(
461    text: &str,
462    font: &Font,
463    emoji_font: &Option<Font>,
464    max_width: Option<f32>,
465) -> Vec<String> {
466    wrap_text_with_tracking(text, font, emoji_font, max_width, 0.0)
467}
468
469#[cfg(test)]
470mod tracking_tests {
471    use super::super::typeface_with_fallback;
472    use super::*;
473    use skia_safe::{surfaces, AlphaType, Color, ColorType, FontStyle as SkFontStyle, ImageInfo};
474
475    fn bold_font(size: f32) -> Font {
476        let typeface = typeface_with_fallback("Helvetica", SkFontStyle::bold())
477            .expect("host must have a fallback typeface");
478        subpixel_font(typeface, size)
479    }
480
481    #[test]
482    fn negative_tracking_gains_an_unneeded_break_in_old_wrap_but_not_new() {
483        let text = "THAT MOVES";
484        for font_size in [240.0f32, 290.0, 300.0] {
485            let font = bold_font(font_size);
486            let letter_spacing = -9.0f32;
487
488            let real_width = measure_text_with_fallback(text, &font, &None, letter_spacing);
489            let zero_width = measure_text_with_fallback(text, &font, &None, 0.0);
490            assert!(
491                zero_width > real_width,
492                "negative tracking must make the real width narrower than the \
493                 zero-tracking estimate at {font_size}px (real={real_width}, zero={zero_width})"
494            );
495
496            let max_w = (real_width + zero_width) / 2.0;
497
498            let old_lines = wrap_text_with_fallback(text, &font, &None, Some(max_w));
499            let new_lines =
500                wrap_text_with_tracking(text, &font, &None, Some(max_w), letter_spacing);
501
502            assert_eq!(
503                old_lines.len(),
504                2,
505                "old (zero-tracking) wrap should wrongly split at {font_size}px, got {old_lines:?}"
506            );
507            assert_eq!(
508                new_lines.len(),
509                1,
510                "new (tracking-aware) wrap should correctly keep one line at {font_size}px, \
511                 matching what {max_w}px >= real width {real_width}px allows, got {new_lines:?}"
512            );
513        }
514    }
515
516    #[test]
517    fn tracking_aware_wrap_agrees_across_width_samples_old_wrap_does_not() {
518        let text = "THAT MOVES";
519        let font = bold_font(290.0);
520        let letter_spacing = -9.0f32;
521        let real_width = measure_text_with_fallback(text, &font, &None, letter_spacing);
522        let zero_width = measure_text_with_fallback(text, &font, &None, 0.0);
523
524        let sample_a = zero_width + 24.0;
525        let sample_b = real_width + 1.0;
526        assert!(
527            sample_b < zero_width,
528            "test setup: sample_b must be inside the bug window"
529        );
530
531        let old_a = wrap_text_with_fallback(text, &font, &None, Some(sample_a)).len();
532        let old_b = wrap_text_with_fallback(text, &font, &None, Some(sample_b)).len();
533        let new_a =
534            wrap_text_with_tracking(text, &font, &None, Some(sample_a), letter_spacing).len();
535        let new_b =
536            wrap_text_with_tracking(text, &font, &None, Some(sample_b), letter_spacing).len();
537
538        assert_ne!(
539            old_a, old_b,
540            "reproduction: old wrap must disagree across the two width samples \
541             (sample_a={sample_a}, sample_b={sample_b})"
542        );
543        assert_eq!(
544            new_a, new_b,
545            "fix: new wrap must agree across the two width samples \
546             (sample_a={sample_a}, sample_b={sample_b})"
547        );
548        assert_eq!(
549            new_a, 1,
550            "both samples satisfy the real tracked width, so 1 line is correct"
551        );
552    }
553
554    #[test]
555    fn wrap_text_with_fallback_is_unchanged_zero_tracking_behaviour() {
556        let text = "THAT MOVES";
557        let font = bold_font(290.0);
558        let max_w = 700.0;
559        assert_eq!(
560            wrap_text_with_fallback(text, &font, &None, Some(max_w)),
561            wrap_text_with_tracking(text, &font, &None, Some(max_w), 0.0)
562        );
563    }
564
565    fn render_and_measure_ink_centre_x(
566        lines: &[String],
567        font: &Font,
568        letter_spacing: f32,
569        box_left: f32,
570        box_width: f32,
571        line_height: f32,
572        top_y: f32,
573        w: u32,
574        h: u32,
575    ) -> Option<f32> {
576        let mut surface = surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
577        let canvas = surface.canvas();
578        canvas.clear(Color::BLACK);
579        let mut paint = Paint::default();
580        paint.set_color(Color::WHITE);
581        paint.set_anti_alias(true);
582
583        let (_, metrics) = font.metrics();
584        let ascent = -metrics.ascent;
585
586        for (i, line) in lines.iter().enumerate() {
587            if line.is_empty() {
588                continue;
589            }
590            let advance = measure_text_with_fallback(line, font, &None, letter_spacing);
591            let x = box_left + (box_width - advance) / 2.0;
592            let y = top_y + i as f32 * line_height + ascent;
593            draw_text_with_fallback(canvas, line, font, &None, letter_spacing, x, y, &paint);
594        }
595
596        let info = ImageInfo::new(
597            (w as i32, h as i32),
598            ColorType::RGBA8888,
599            AlphaType::Unpremul,
600            None,
601        );
602        let mut buf = vec![0u8; (w * h * 4) as usize];
603        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
604
605        let mut min_x: Option<u32> = None;
606        let mut max_x: Option<u32> = None;
607        for y in 0..h {
608            for x in 0..w {
609                let idx = ((y * w + x) * 4) as usize;
610                if buf[idx] > 40 {
611                    min_x = Some(min_x.map_or(x, |m| m.min(x)));
612                    max_x = Some(max_x.map_or(x, |m| m.max(x)));
613                }
614            }
615        }
616        match (min_x, max_x) {
617            (Some(a), Some(b)) => Some((a as f32 + b as f32) / 2.0),
618            _ => None,
619        }
620    }
621
622    #[test]
623    fn sweep_that_moves_fixed_primitive_stays_centred() {
624        const W: u32 = 1920;
625        const H: u32 = 1080;
626        const FRAME_CENTRE: f32 = 960.0;
627        let text = "THAT MOVES";
628
629        let box_max_width = 1400.0f32;
630
631        let mut results = Vec::new();
632        for &(font_size, letter_spacing) in &[
633            (240.0f32, 0.0f32),
634            (240.0, -9.0),
635            (290.0, -9.0),
636            (300.0, -9.0),
637        ] {
638            let font = bold_font(font_size);
639            let line_height = font_size * 0.85;
640
641            let lines =
642                wrap_text_with_tracking(text, &font, &None, Some(box_max_width), letter_spacing);
643            let box_width = lines
644                .iter()
645                .map(|l| measure_text_with_fallback(l, &font, &None, letter_spacing))
646                .fold(0.0f32, f32::max);
647            let box_left = FRAME_CENTRE - box_width / 2.0;
648            let top_y = (H as f32 - lines.len() as f32 * line_height) / 2.0;
649
650            let ink_centre = render_and_measure_ink_centre_x(
651                &lines,
652                &font,
653                letter_spacing,
654                box_left,
655                box_width,
656                line_height,
657                top_y,
658                W,
659                H,
660            )
661            .unwrap_or_else(|| panic!("expected ink at {font_size}px/{letter_spacing}"));
662
663            results.push((font_size, letter_spacing, lines.len(), ink_centre));
664
665            assert!(
666                (ink_centre - FRAME_CENTRE).abs() < 15.0,
667                "fixed pipeline ink centre {ink_centre:.1} should be within 15px of \
668                 {FRAME_CENTRE} at {font_size}px/{letter_spacing} tracking (lines={})",
669                lines.len()
670            );
671        }
672
673        eprintln!("sweep_that_moves_fixed_primitive_stays_centred (after fix):");
674        for (font_size, letter_spacing, line_count, ink_centre) in &results {
675            eprintln!(
676                "  {font_size}px / {letter_spacing} tracking -> lines={line_count}, ink_centre={ink_centre:.1}"
677            );
678        }
679    }
680}
681
682#[cfg(test)]
683mod emoji_presentation_tests {
684    use super::super::{emoji_typeface, typeface_with_fallback};
685    use super::*;
686
687    #[test]
688    fn text_presentation_default_symbols_are_not_emoji_without_vs16() {
689        assert!(
690            !char_wants_emoji_font('\u{2713}', None),
691            "✓ bare must be text"
692        );
693        assert!(
694            !char_wants_emoji_font('\u{2714}', None),
695            "✔ bare must be text"
696        );
697        assert!(
698            !char_wants_emoji_font('\u{00A9}', None),
699            "© bare must be text"
700        );
701        assert!(
702            !char_wants_emoji_font('\u{00AE}', None),
703            "® bare must be text"
704        );
705        assert!(
706            !char_wants_emoji_font('\u{2122}', None),
707            "™ bare must be text"
708        );
709    }
710
711    #[test]
712    fn text_presentation_default_symbols_opt_into_emoji_with_vs16() {
713        assert!(char_wants_emoji_font('\u{2713}', Some('\u{FE0F}')));
714        assert!(char_wants_emoji_font('\u{00A9}', Some('\u{FE0F}')));
715    }
716
717    #[test]
718    fn genuine_pictographs_are_always_emoji_regardless_of_vs16() {
719        assert!(
720            char_wants_emoji_font('\u{1F600}', None),
721            "😀 must stay emoji"
722        );
723        assert!(char_wants_emoji_font('\u{1F600}', Some('\u{FE0F}')));
724    }
725
726    #[test]
727    fn variation_selector_16_itself_is_always_emoji() {
728        assert!(char_wants_emoji_font('\u{FE0F}', None));
729    }
730
731    #[test]
732    fn variation_selector_15_does_not_force_emoji() {
733        assert!(!char_wants_emoji_font('\u{2713}', Some('\u{FE0E}')));
734    }
735
736    #[test]
737    fn has_emoji_reflects_the_narrowed_classification() {
738        assert!(!has_emoji("2713:\u{2713} copyright:\u{00A9}"));
739        assert!(has_emoji("checked \u{2713}\u{FE0F}"));
740        assert!(has_emoji("grinning \u{1F600}"));
741    }
742
743    fn helvetica_font(size: f32) -> Font {
744        let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal())
745            .expect("host must have a fallback typeface");
746        subpixel_font(typeface, size)
747    }
748
749    fn render_and_sample(text: &str, size: f32) -> (usize, f64, f64, f64) {
750        use skia_safe::{surfaces, AlphaType, Color, ColorType, ImageInfo};
751        const W: i32 = 200;
752        const H: i32 = 200;
753        let font = helvetica_font(size);
754        let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, size));
755        let mut surface = surfaces::raster_n32_premul((W, H)).unwrap();
756        let canvas = surface.canvas();
757        canvas.clear(Color::BLACK);
758        let mut paint = Paint::default();
759        paint.set_color(Color::WHITE);
760        paint.set_anti_alias(true);
761        let (_, metrics) = font.metrics();
762        let ascent = -metrics.ascent;
763        draw_text_with_fallback(
764            canvas,
765            text,
766            &font,
767            &emoji_font,
768            0.0,
769            10.0,
770            ascent + 10.0,
771            &paint,
772        );
773
774        let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None);
775        let mut buf = vec![0u8; (W * H * 4) as usize];
776        surface.read_pixels(&info, &mut buf, (W * 4) as usize, (0, 0));
777
778        let (mut n, mut sr, mut sg, mut sb) = (0usize, 0f64, 0f64, 0f64);
779        for px in buf.as_chunks::<4>().0.iter() {
780            if px[3] > 40 {
781                n += 1;
782                sr += px[0] as f64;
783                sg += px[1] as f64;
784                sb += px[2] as f64;
785            }
786        }
787        if n == 0 {
788            (0, 0.0, 0.0, 0.0)
789        } else {
790            (n, sr / n as f64, sg / n as f64, sb / n as f64)
791        }
792    }
793
794    #[test]
795    fn bare_check_mark_paints_requested_white_not_tofu_or_color_bitmap() {
796        let font = helvetica_font(64.0);
797        if !font_covers(&font, "\u{2713}") {
798            eprintln!("skip: primary font doesn't cover U+2713 on this host");
799            return;
800        }
801        let (ink, r, g, b) = render_and_sample("\u{2713}", 64.0);
802        assert!(ink > 20, "expected visible ink for ✓, got {ink} pixels");
803        assert!(
804            r > 150.0 && g > 150.0 && b > 150.0,
805            "✓ should paint near-white, got mean rgb=({r:.0},{g:.0},{b:.0})"
806        );
807    }
808}
809
810#[cfg(test)]
811mod glyph_fallback_tests {
812    use super::super::{fallback_typeface_for_char, typeface_with_fallback};
813    use super::*;
814
815    fn helvetica_font(size: f32) -> Font {
816        let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal())
817            .expect("host must have a fallback typeface");
818        subpixel_font(typeface, size)
819    }
820
821    #[test]
822    fn font_covers_detects_missing_glyphs_deterministically() {
823        let font = helvetica_font(32.0);
824        assert!(font_covers(&font, "ABC"), "Helvetica must cover ASCII");
825        assert!(
826            !font_covers(&font, "\u{4F60}\u{597D}"),
827            "Helvetica must not cover CJK"
828        );
829    }
830
831    #[test]
832    fn classify_char_stays_primary_for_a_codepoint_no_font_covers() {
833        let font = helvetica_font(32.0);
834        let pua = '\u{E000}';
835        assert!(
836            !font_covers(&font, &pua.to_string()),
837            "test setup: PUA code point must be uncovered by the primary font"
838        );
839        let style = font.typeface().font_style();
840        let family = font.typeface().family_name();
841        if fallback_typeface_for_char(&family, style, pua).is_some() {
842            eprintln!(
843                "skip: host has some font claiming to cover U+E000 (PUA) — can't exercise the \
844                 'nothing covers it anywhere' branch on this host"
845            );
846            return;
847        }
848        let kind = classify_char(pua, &font, None);
849        assert!(
850            matches!(kind, RunKind::Primary),
851            "an uncoverable-anywhere code point must resolve to Primary, not panic"
852        );
853    }
854
855    #[test]
856    fn classify_char_resolves_a_fallback_when_the_host_has_one() {
857        let font = helvetica_font(32.0);
858        let style = font.typeface().font_style();
859        let family = font.typeface().family_name();
860        if fallback_typeface_for_char(&family, style, '\u{4F60}').is_none() {
861            eprintln!("skip: no CJK-capable font installed on this host");
862            return;
863        }
864        let kind = classify_char('\u{4F60}', &font, None);
865        let RunKind::Fallback(fallback) = kind else {
866            panic!(
867                "classify_char must resolve a Fallback run for an uncovered CJK code point when \
868                 the host has a capable font"
869            );
870        };
871        let fallback_font = subpixel_font(fallback, 32.0);
872        assert!(
873            font_covers(&fallback_font, "\u{4F60}"),
874            "resolved fallback typeface must actually cover the code point that triggered it"
875        );
876    }
877
878    #[test]
879    fn measure_and_paint_agree_on_cjk_width_when_fallback_is_available() {
880        let font = helvetica_font(48.0);
881        let text = "\u{4F60}\u{597D}";
882        if !text.chars().any(|c| {
883            fallback_typeface_for_char("Helvetica", font.typeface().font_style(), c).is_some()
884        }) {
885            eprintln!("skip: no CJK-capable font installed on this host");
886            return;
887        }
888
889        let runs = segment_text_runs(text, &font);
890        assert!(
891            runs.iter().any(|r| matches!(r.kind, RunKind::Fallback(_))),
892            "expected at least one Fallback run when segmenting CJK text with a fallback font \
893             available, got kinds: {:?}",
894            runs.iter()
895                .map(|r| match &r.kind {
896                    RunKind::Primary => "Primary",
897                    RunKind::Emoji => "Emoji",
898                    RunKind::Fallback(_) => "Fallback",
899                })
900                .collect::<Vec<_>>()
901        );
902
903        let measured_w = measure_text_with_fallback(text, &font, &None, 0.0);
904        assert!(
905            measured_w > 30.0,
906            "expected a real CJK measurement, got suspiciously narrow {measured_w}"
907        );
908
909        use skia_safe::{surfaces, AlphaType, Color, ColorType, ImageInfo};
910        const W: i32 = 400;
911        const H: i32 = 200;
912        let mut surface = surfaces::raster_n32_premul((W, H)).unwrap();
913        let canvas = surface.canvas();
914        canvas.clear(Color::BLACK);
915        let mut paint = Paint::default();
916        paint.set_color(Color::WHITE);
917        paint.set_anti_alias(true);
918        let (_, metrics) = font.metrics();
919        let ascent = -metrics.ascent;
920        draw_text_with_fallback(canvas, text, &font, &None, 0.0, 10.0, ascent + 10.0, &paint);
921
922        let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None);
923        let mut buf = vec![0u8; (W * H * 4) as usize];
924        surface.read_pixels(&info, &mut buf, (W * 4) as usize, (0, 0));
925        let mut min_x: Option<i32> = None;
926        let mut max_x: Option<i32> = None;
927        for y in 0..H {
928            for x in 0..W {
929                let idx = ((y * W + x) * 4) as usize;
930                if buf[idx] > 40 {
931                    min_x = Some(min_x.map_or(x, |m| m.min(x)));
932                    max_x = Some(max_x.map_or(x, |m| m.max(x)));
933                }
934            }
935        }
936        let (min_x, max_x) = (
937            min_x.expect("must paint something"),
938            max_x.expect("must paint something"),
939        );
940        let painted_width = (max_x - min_x) as f32;
941
942        assert!(
943            (painted_width - measured_w).abs() < measured_w * 0.5 + 20.0,
944            "measured width {measured_w} should roughly match the painted ink width \
945             {painted_width} (min_x={min_x}, max_x={max_x})"
946        );
947    }
948}
949
950#[cfg(test)]
951mod glyph_metrics_tests {
952    use super::super::typeface_with_fallback;
953    use super::*;
954    use skia_safe::FontStyle as SkFontStyle;
955
956    fn test_font(size: f32) -> Font {
957        let typeface = typeface_with_fallback("Helvetica", SkFontStyle::default())
958            .expect("host must have a fallback typeface");
959        subpixel_font(typeface, size)
960    }
961
962    #[test]
963    fn empty_text_has_no_glyphs() {
964        let font = test_font(32.0);
965        assert!(compute_glyph_metrics("", &font, &None, 0.0).is_empty());
966    }
967
968    #[test]
969    fn glyph_count_matches_char_count_for_plain_ascii() {
970        let font = test_font(32.0);
971        let metrics = compute_glyph_metrics("Hello", &font, &None, 0.0);
972        assert_eq!(metrics.len(), 5);
973    }
974
975    #[test]
976    fn first_glyph_starts_at_line_origin() {
977        let font = test_font(32.0);
978        let metrics = compute_glyph_metrics("Hello", &font, &None, 0.0);
979        assert_eq!(metrics[0].x, 0.0);
980    }
981
982    #[test]
983    fn glyphs_are_monotonically_increasing_and_sum_to_the_advance_width() {
984        let font = test_font(48.0);
985        let text = "Sentence?";
986        let metrics = compute_glyph_metrics(text, &font, &None, 0.0);
987        assert_eq!(metrics.len(), text.chars().count());
988        for pair in metrics.windows(2) {
989            assert!(
990                pair[1].x >= pair[0].x,
991                "glyph x must be non-decreasing: {:?}",
992                metrics
993            );
994        }
995        let last = metrics.last().unwrap();
996        let total_advance = last.x + last.width;
997        let measured = measure_text_with_fallback(text, &font, &None, 0.0);
998        assert!(
999            (total_advance - measured).abs() < 0.5,
1000            "last glyph's right edge ({total_advance}) should match the \
1001             measured advance width ({measured})"
1002        );
1003    }
1004
1005    #[test]
1006    fn last_glyph_is_the_detachable_question_mark() {
1007        let font = test_font(32.0);
1008        let metrics = compute_glyph_metrics("Sentence", &font, &None, 0.0);
1009        let e_glyph = *metrics.last().unwrap();
1010        let question_x = e_glyph.x + e_glyph.width;
1011        assert!(question_x > e_glyph.x);
1012    }
1013
1014    #[test]
1015    fn letter_spacing_widens_the_gap_between_glyphs() {
1016        let font = test_font(32.0);
1017        let tight = compute_glyph_metrics("AB", &font, &None, 0.0);
1018        let wide = compute_glyph_metrics("AB", &font, &None, 10.0);
1019        assert_eq!(tight.len(), 2);
1020        assert_eq!(wide.len(), 2);
1021        assert!(wide[1].x > tight[1].x + 9.0);
1022    }
1023
1024    #[test]
1025    fn single_font_path_matches_make_text_blob_with_spacing_positions() {
1026        let font = test_font(40.0);
1027        let text = "Hello";
1028        let spacing = 2.0;
1029        let metrics = compute_glyph_metrics(text, &font, &None, spacing);
1030
1031        let glyphs = font.str_to_glyphs_vec(text);
1032        let mut widths = vec![0.0f32; glyphs.len()];
1033        font.get_widths(&glyphs, &mut widths);
1034        let mut x = 0.0f32;
1035        for (i, w) in widths.iter().enumerate() {
1036            assert_eq!(metrics[i].x, x);
1037            assert_eq!(metrics[i].width, *w);
1038            x += w + spacing;
1039        }
1040    }
1041}