Skip to main content

rustmotion_core/engine/renderer/
text.rs

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