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}