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}