rustmotion_core/engine/renderer/text.rs
1use skia_safe::{Canvas, Font, Paint, Point, TextBlob, Typeface};
2
3// ─── Counter formatting ─────────────────────────────────────────────────────
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 // Format with decimals
13 let formatted_number = format!("{:.prec$}", value, prec = decimals as usize);
14
15 // Apply thousands separator if specified
16 let formatted_number = if let Some(sep) = separator {
17 let parts: Vec<&str> = formatted_number.split('.').collect();
18 let integer_part = parts[0];
19
20 // Handle negative sign
21 let (sign, digits) = if let Some(stripped) = integer_part.strip_prefix('-') {
22 ("-", stripped)
23 } else {
24 ("", integer_part)
25 };
26
27 let mut result = String::new();
28 for (i, ch) in digits.chars().rev().enumerate() {
29 if i > 0 && i % 3 == 0 {
30 result.insert(0, sep.chars().next().unwrap_or(' '));
31 }
32 result.insert(0, ch);
33 }
34
35 if !sign.is_empty() {
36 result.insert_str(0, sign);
37 }
38
39 if parts.len() > 1 {
40 result.push('.');
41 result.push_str(parts[1]);
42 }
43
44 result
45 } else {
46 formatted_number
47 };
48
49 // Build final string with prefix/suffix
50 let mut result = String::new();
51 if let Some(p) = prefix {
52 result.push_str(p);
53 }
54 result.push_str(&formatted_number);
55 if let Some(s) = suffix {
56 result.push_str(s);
57 }
58 result
59}
60
61// ─── Text utilities ─────────────────────────────────────────────────────────
62
63pub fn wrap_text(text: &str, font: &Font, max_width: Option<f32>) -> Vec<String> {
64 let explicit_lines: Vec<&str> = text.split('\n').collect();
65
66 let max_w = match max_width {
67 Some(w) => w,
68 None => return explicit_lines.iter().map(|s| s.to_string()).collect(),
69 };
70
71 let mut result = Vec::new();
72 for line in explicit_lines {
73 let words: Vec<&str> = line.split_whitespace().collect();
74 if words.is_empty() {
75 result.push(String::new());
76 continue;
77 }
78
79 let mut current_line = String::new();
80 for word in words {
81 let test = if current_line.is_empty() {
82 word.to_string()
83 } else {
84 format!("{} {}", current_line, word)
85 };
86
87 let (width, _) = font.measure_str(&test, None);
88 if width > max_w && !current_line.is_empty() {
89 result.push(current_line);
90 current_line = word.to_string();
91 } else {
92 current_line = test;
93 }
94 }
95 if !current_line.is_empty() {
96 result.push(current_line);
97 }
98 }
99 result
100}
101
102pub fn make_text_blob_with_spacing(text: &str, font: &Font, spacing: f32) -> Option<TextBlob> {
103 let glyphs = font.str_to_glyphs_vec(text);
104 if glyphs.is_empty() {
105 return None;
106 }
107
108 let mut widths = vec![0.0f32; glyphs.len()];
109 font.get_widths(&glyphs, &mut widths);
110
111 let mut positions = Vec::with_capacity(glyphs.len());
112 let mut x = 0.0f32;
113 for (i, _glyph) in glyphs.iter().enumerate() {
114 positions.push(Point::new(x, 0.0));
115 x += widths[i] + spacing;
116 }
117
118 TextBlob::from_pos_text(text, &positions, font)
119}
120
121// ─── Emoji support ──────────────────────────────────────────────────────────
122
123/// Code points that render as emoji **by default**, in every context,
124/// regardless of any following variation selector — genuine pictograph
125/// blocks (Miscellaneous Symbols and Pictographs, Emoticons, Transport,
126/// Supplemental Symbols/Pictographs, flags, keycaps, ZWJ sequences...).
127/// Nothing in these ranges has a meaningful plain-text rendering, so there
128/// is no narrowing to do here — contrast with
129/// [`is_text_presentation_by_default`], which needs one (audit #8).
130fn is_emoji_presentation_default(c: char) -> bool {
131 let cp = c as u32;
132 matches!(cp,
133 // Miscellaneous Symbols and Pictographs (includes skin tone modifiers 1F3FB-1F3FF)
134 0x1F300..=0x1F5FF |
135 // Emoticons
136 0x1F600..=0x1F64F |
137 // Transport and Map Symbols
138 0x1F680..=0x1F6FF |
139 // Supplemental Symbols and Pictographs
140 0x1F900..=0x1F9FF |
141 // Symbols and Pictographs Extended-A
142 0x1FA00..=0x1FA6F |
143 // Symbols and Pictographs Extended-B
144 0x1FA70..=0x1FAFF |
145 // Zero-Width Joiner
146 0x200D |
147 // Combining Enclosing Keycap
148 0x20E3 |
149 // Regional Indicator Symbols (flags)
150 0x1F1E0..=0x1F1FF |
151 // Tags block (flag subdivisions)
152 0xE0020..=0xE007F |
153 // Playing cards, mahjong
154 0x1F004 | 0x1F0CF |
155 // Misc technical, unconditionally emoji (⏩..⏳ ⏸..⏺)
156 0x23E9..=0x23F3 |
157 0x23F8..=0x23FA |
158 // Arrows and geometric symbols, unconditionally emoji
159 0x2B1B..=0x2B1C |
160 0x2B50 | 0x2B55
161 )
162}
163
164/// Code points that are TEXT-presentation **by default** — a normal glyph
165/// in the primary font, honouring `style.color` — but that Unicode still
166/// marks `Emoji=Yes`: they render as color emoji only when the author
167/// explicitly opts in with a following U+FE0F variation selector (audit
168/// #8). Routing these unconditionally to the emoji font (the previous
169/// behaviour, lumped in with [`is_emoji_presentation_default`]) either
170/// painted a color bitmap that ignores `style.color` (✔, ©, ®, ™ — all
171/// covered by Apple Color Emoji) or a `.notdef` tofu square for code points
172/// the emoji font itself doesn't cover even though the primary font does
173/// (✓ U+2713 — absent from Apple Color Emoji even though U+2714 sits one
174/// code point over and is present).
175fn is_text_presentation_by_default(c: char) -> bool {
176 let cp = c as u32;
177 matches!(cp,
178 // Copyright, registered, trademark
179 0x00A9 | 0x00AE | 0x2122 |
180 // Misc technical (⌚ ⌛)
181 0x231A..=0x231B |
182 // Miscellaneous Symbols (☀️..⛿)
183 0x2600..=0x26FF |
184 // Dingbats (✂️..➰ and arrows/symbols), includes ✓/✔ U+2713/2714
185 0x2702..=0x27B0 |
186 // Arrows and geometric symbols used as emoji only with VS16
187 0x2934..=0x2935 |
188 0x25AA..=0x25AB |
189 0x25B6 | 0x25C0 |
190 0x25FB..=0x25FE |
191 0x2B05..=0x2B07 |
192 // CJK symbols
193 0x3030 | 0x303D |
194 0x3297 | 0x3299
195 )
196}
197
198/// Variation Selector-16 — forces the *preceding* text-presentation-default
199/// code point (see [`is_text_presentation_by_default`]) into emoji
200/// presentation. Narrower than the old catch-all `0xFE00..=0xFE0F` range:
201/// VS-15 (U+FE0E, forces *text* presentation) and the rest of that block
202/// are not emoji-forcing.
203const VARIATION_SELECTOR_EMOJI: char = '\u{FE0F}';
204
205/// True if `c` should be painted/measured with the emoji font, given the
206/// character immediately following it (`next`). Needed because
207/// [`is_text_presentation_by_default`] code points only opt into emoji
208/// presentation when explicitly followed by U+FE0F — a bare lookup of `c`
209/// alone can't tell "©" (text) from "©️" (explicit emoji presentation)
210/// apart.
211fn char_wants_emoji_font(c: char, next: Option<char>) -> bool {
212 if c == VARIATION_SELECTOR_EMOJI {
213 return true; // always grouped with whatever code point selected it
214 }
215 if is_emoji_presentation_default(c) {
216 return true;
217 }
218 if is_text_presentation_by_default(c) {
219 return next == Some(VARIATION_SELECTOR_EMOJI);
220 }
221 false
222}
223
224/// Which font a run of text should be painted/measured with.
225enum RunKind {
226 Primary,
227 Emoji,
228 /// #3: a system fallback typeface resolved for a code point the
229 /// primary font doesn't cover (e.g. CJK/Arabic/Devanagari when only a
230 /// Latin `font-family` was requested).
231 Fallback(Typeface),
232}
233
234/// True if `a` and `b` are the "same" run kind for the purpose of merging
235/// adjacent characters into one run. Two `Fallback` runs merge only if they
236/// resolved to the *same* typeface (compared by Skia's unique id) — two
237/// characters that both need fallback but belong to different scripts
238/// (e.g. mixed CJK + Arabic) must not merge into a single run painted with
239/// only one of the two fonts.
240fn same_run_kind(a: &RunKind, b: &RunKind) -> bool {
241 match (a, b) {
242 (RunKind::Primary, RunKind::Primary) => true,
243 (RunKind::Emoji, RunKind::Emoji) => true,
244 (RunKind::Fallback(ta), RunKind::Fallback(tb)) => ta.unique_id() == tb.unique_id(),
245 _ => false,
246 }
247}
248
249/// True if `font` has an actual glyph (not `.notdef`, glyph id 0) for every
250/// character in `text`. Used both to gate whether a code point needs a
251/// fallback lookup at all (#3), and as a coverage guard before actually
252/// using the emoji font for a run classified as emoji (#8): some code
253/// points Unicode marks emoji-capable are, on a given platform, absent
254/// from the color emoji font even though the primary font has a perfectly
255/// good text glyph for them (U+2713 on Apple Color Emoji).
256fn font_covers(font: &Font, text: &str) -> bool {
257 let glyphs = font.str_to_glyphs_vec(text);
258 !glyphs.is_empty() && glyphs.iter().all(|&g| g != 0)
259}
260
261/// Classify a single character's font choice: emoji presentation first
262/// (#8), then primary-font glyph coverage, then a system fallback typeface
263/// resolved via [`super::fallback_typeface_for_char`] for the primary
264/// font's own `(family, style)` (#3). Whitespace/control code points are
265/// always `Primary` (assumed universally present, or invisible) so the
266/// fallback lookup isn't triggered by the spaces between same-script words.
267fn classify_char(c: char, primary: &Font, next: Option<char>) -> RunKind {
268 if char_wants_emoji_font(c, next) {
269 return RunKind::Emoji;
270 }
271 if c.is_whitespace() || (c as u32) < 0x20 {
272 return RunKind::Primary;
273 }
274 if primary.unichar_to_glyph(c as i32) != 0 {
275 return RunKind::Primary;
276 }
277 let primary_typeface = primary.typeface();
278 let style = primary_typeface.font_style();
279 let family = primary_typeface.family_name();
280 match super::fallback_typeface_for_char(&family, style, c) {
281 Some(tf) => RunKind::Fallback(tf),
282 // No installed font covers `c` either — same `.notdef` tofu as
283 // before this fix, not worse.
284 None => RunKind::Primary,
285 }
286}
287
288/// Segment text into runs, each tagged with which font should paint/measure
289/// it (see [`classify_char`]).
290fn segment_text_runs(text: &str, primary: &Font) -> Vec<TextRun> {
291 let chars: Vec<(usize, char)> = text.char_indices().collect();
292 let mut runs = Vec::new();
293 let mut i = 0;
294 while i < chars.len() {
295 let (start_byte, c) = chars[i];
296 let next = chars.get(i + 1).map(|&(_, ch)| ch);
297 let kind = classify_char(c, primary, next);
298 let mut end_byte = start_byte + c.len_utf8();
299 i += 1;
300
301 while let Some(&(nb, nc)) = chars.get(i) {
302 let nnext = chars.get(i + 1).map(|&(_, ch)| ch);
303 let nkind = classify_char(nc, primary, nnext);
304 if !same_run_kind(&kind, &nkind) {
305 break;
306 }
307 end_byte = nb + nc.len_utf8();
308 i += 1;
309 }
310
311 runs.push(TextRun {
312 start: start_byte,
313 end: end_byte,
314 kind,
315 });
316 }
317 runs
318}
319
320/// A segment of text tagged with which font it should be painted/measured
321/// with (see [`RunKind`]).
322struct TextRun {
323 start: usize, // byte offset
324 end: usize, // byte offset
325 kind: RunKind,
326}
327
328/// True if `text` contains any character that wants emoji presentation
329/// (#8-aware: honours the VS16 opt-in for text-presentation-default code
330/// points, so plain "©"/"✓" no longer count as emoji here).
331pub fn has_emoji(text: &str) -> bool {
332 let chars: Vec<char> = text.chars().collect();
333 chars
334 .iter()
335 .enumerate()
336 .any(|(i, &c)| char_wants_emoji_font(c, chars.get(i + 1).copied()))
337}
338
339/// True if `text` needs the full run-segmentation machinery in
340/// [`draw_text_with_fallback`]/[`measure_text_with_fallback`]: it contains
341/// emoji-presentation content (and an emoji font is actually available), or
342/// at least one non-whitespace/control code point the primary `font`
343/// doesn't cover (#3). When neither is true, every caller's existing
344/// single-font fast path is exactly correct and segmentation would be
345/// wasted work.
346fn needs_segmentation(text: &str, primary: &Font, emoji_font: &Option<Font>) -> bool {
347 if emoji_font.is_some() && has_emoji(text) {
348 return true;
349 }
350 text.chars().any(|c| {
351 // ASCII short-circuits before the `unichar_to_glyph` FFI call: every
352 // font this engine resolves covers printable ASCII, and callers
353 // that draw a lot of short spans per frame (codeblock's per-token
354 // syntax highlighting, in particular) call this once per span —
355 // skipping the Skia round-trip for the overwhelmingly common
356 // all-ASCII case keeps #3's fix from adding per-glyph FFI overhead
357 // to code that was never affected by the tofu/coverage bug it
358 // fixes.
359 !(c.is_ascii() || c.is_whitespace() || (c as u32) < 0x20)
360 && primary.unichar_to_glyph(c as i32) == 0
361 })
362}
363
364/// Resolve which `Font` a run should actually be painted/measured with,
365/// applying the emoji coverage guard (#8) and building a same-size `Font`
366/// from a resolved fallback [`Typeface`] (#3). Returns a borrow of one of
367/// the two inputs, or an owned font stored in `owned` to keep the borrow
368/// alive at the call site.
369fn resolve_run_font<'a>(
370 kind: &RunKind,
371 segment: &str,
372 primary: &'a Font,
373 emoji_font: &'a Option<Font>,
374 owned: &'a mut Option<Font>,
375) -> &'a Font {
376 match kind {
377 RunKind::Primary => primary,
378 RunKind::Emoji => match emoji_font {
379 Some(ef) if font_covers(ef, segment.trim_end_matches(VARIATION_SELECTOR_EMOJI)) => ef,
380 // Coverage guard (#8): the emoji font doesn't actually have
381 // this glyph (or isn't available at all) — fall back to the
382 // primary font rather than paint `.notdef`.
383 _ => primary,
384 },
385 RunKind::Fallback(tf) => {
386 *owned = Some(Font::from_typeface(tf.clone(), primary.size()));
387 owned.as_ref().unwrap()
388 }
389 }
390}
391
392/// Draw a text line with emoji-font and glyph-coverage fallback (#3, #8).
393/// If `emoji_font` is None, falls back to drawing everything with the
394/// primary font (plus any `#3` system fallback the primary font's coverage
395/// gap needs).
396pub fn draw_text_with_fallback(
397 canvas: &Canvas,
398 text: &str,
399 font: &Font,
400 emoji_font: &Option<Font>,
401 letter_spacing: f32,
402 x: f32,
403 y: f32,
404 paint: &Paint,
405) {
406 // Fast path: nothing here needs emoji presentation or a glyph-coverage
407 // fallback — every earlier caller's exact previous behaviour.
408 if !needs_segmentation(text, font, emoji_font) {
409 if letter_spacing.abs() > 0.01 {
410 if let Some(blob) = make_text_blob_with_spacing(text, font, letter_spacing) {
411 canvas.draw_text_blob(&blob, (x, y), paint);
412 }
413 } else if let Some(blob) = TextBlob::new(text, font) {
414 canvas.draw_text_blob(&blob, (x, y), paint);
415 }
416 return;
417 }
418
419 let runs = segment_text_runs(text, font);
420 let mut cursor_x = x;
421
422 for run in &runs {
423 let segment = &text[run.start..run.end];
424 let mut owned = None;
425 let f = resolve_run_font(&run.kind, segment, font, emoji_font, &mut owned);
426
427 if letter_spacing.abs() > 0.01 {
428 if let Some(blob) = make_text_blob_with_spacing(segment, f, letter_spacing) {
429 canvas.draw_text_blob(&blob, (cursor_x, y), paint);
430 }
431 } else if let Some(blob) = TextBlob::new(segment, f) {
432 canvas.draw_text_blob(&blob, (cursor_x, y), paint);
433 }
434
435 // Advance cursor by the measured width of this run
436 let (w, _) = f.measure_str(segment, None);
437 let extra = if letter_spacing.abs() > 0.01 {
438 letter_spacing * (segment.chars().count() as f32 - 1.0).max(0.0)
439 } else {
440 0.0
441 };
442 cursor_x += w + extra;
443 }
444}
445
446/// Measure the width of a text line with emoji-font and glyph-coverage
447/// fallback (#3, #8) — mirrors [`draw_text_with_fallback`] run-for-run so
448/// the width this returns always matches what actually gets painted.
449pub fn measure_text_with_fallback(
450 text: &str,
451 font: &Font,
452 emoji_font: &Option<Font>,
453 letter_spacing: f32,
454) -> f32 {
455 // Fast path
456 if !needs_segmentation(text, font, emoji_font) {
457 let (w, _) = font.measure_str(text, None);
458 let extra = if letter_spacing.abs() > 0.01 {
459 letter_spacing * (text.chars().count() as f32 - 1.0).max(0.0)
460 } else {
461 0.0
462 };
463 return w + extra;
464 }
465
466 let runs = segment_text_runs(text, font);
467 let mut total_w = 0.0f32;
468
469 for run in &runs {
470 let segment = &text[run.start..run.end];
471 let mut owned = None;
472 let f = resolve_run_font(&run.kind, segment, font, emoji_font, &mut owned);
473 let (w, _) = f.measure_str(segment, None);
474 let extra = if letter_spacing.abs() > 0.01 {
475 letter_spacing * (segment.chars().count() as f32 - 1.0).max(0.0)
476 } else {
477 0.0
478 };
479 total_w += w + extra;
480 }
481 total_w
482}
483
484/// Wrap text respecting emoji font fallback for accurate measurement,
485/// honouring `letter_spacing` in the fit test itself (issue #125).
486///
487/// This is the correct entry point for any caller whose paint step applies
488/// non-zero `letter-spacing` (i.e. it also calls
489/// [`measure_text_with_fallback`] / [`draw_text_with_fallback`] with a
490/// non-zero `letter_spacing`): the word-fits-on-this-line test below now
491/// measures with the *same* tracking that will actually be painted, so the
492/// line count this function returns is the line count the paint step will
493/// agree with.
494///
495/// Before this existed, every caller went through
496/// [`wrap_text_with_fallback`], whose fit test always measured at zero
497/// tracking regardless of the real value. That is harmless when
498/// `letter_spacing == 0.0`, but wrong otherwise in a specific and dangerous
499/// way for *negative* tracking (the register this engine targets: tight
500/// negative tracking on 200–400px display type): negative tracking makes
501/// the real painted line narrower than the zero-tracking fit test believes,
502/// so the fit test can decide a line needs to wrap when the real, tighter
503/// text would still have fit on one line — and because the box that holds
504/// the text is sized from one width sample while the paint step re-derives
505/// the same (still zero-tracking) wrap decision against a *different*
506/// available width later in layout, the two passes can disagree on the line
507/// count, leaving painted lines centered inside a box sized for a different
508/// number of lines.
509pub fn wrap_text_with_tracking(
510 text: &str,
511 font: &Font,
512 emoji_font: &Option<Font>,
513 max_width: Option<f32>,
514 letter_spacing: f32,
515) -> Vec<String> {
516 let explicit_lines: Vec<&str> = text.split('\n').collect();
517
518 let max_w = match max_width {
519 Some(w) => w,
520 None => return explicit_lines.iter().map(|s| s.to_string()).collect(),
521 };
522
523 let mut result = Vec::new();
524 for line in explicit_lines {
525 let words: Vec<&str> = line.split_whitespace().collect();
526 if words.is_empty() {
527 result.push(String::new());
528 continue;
529 }
530
531 let mut current_line = String::new();
532 for word in words {
533 let test = if current_line.is_empty() {
534 word.to_string()
535 } else {
536 format!("{} {}", current_line, word)
537 };
538
539 let width = measure_text_with_fallback(&test, font, emoji_font, letter_spacing);
540 if width > max_w && !current_line.is_empty() {
541 result.push(current_line);
542 current_line = word.to_string();
543 } else {
544 current_line = test;
545 }
546 }
547 if !current_line.is_empty() {
548 result.push(current_line);
549 }
550 }
551 result
552}
553
554/// Wrap text respecting emoji font fallback for accurate measurement.
555///
556/// **Known gap (issue #125):** the fit test below always measures at zero
557/// letter-spacing, regardless of what the caller will actually paint with.
558/// Kept at its original signature/behaviour for source compatibility with
559/// existing call sites (`rustmotion-components/src/intrinsic.rs`,
560/// `text.rs`, `shape.rs`, `gradient_text.rs` — outside this workstream's
561/// file scope) that measure and paint with real `letter-spacing` but still
562/// wrap through this function. **New call sites, and any existing call site
563/// whose text can carry non-zero `letter-spacing`, should call
564/// [`wrap_text_with_tracking`] instead** — it is a straight drop-in (same
565/// signature plus one trailing `letter_spacing: f32` argument) that fixes
566/// the measure/paint disagreement described there.
567pub fn wrap_text_with_fallback(
568 text: &str,
569 font: &Font,
570 emoji_font: &Option<Font>,
571 max_width: Option<f32>,
572) -> Vec<String> {
573 wrap_text_with_tracking(text, font, emoji_font, max_width, 0.0)
574}
575
576// ─── Tests: issue #125 — letter-spacing-aware line breaking ───────────────
577
578#[cfg(test)]
579mod tracking_tests {
580 use super::super::typeface_with_fallback;
581 use super::*;
582 use skia_safe::{surfaces, AlphaType, Color, ColorType, FontStyle as SkFontStyle, ImageInfo};
583
584 /// A real bold typeface at `size`, via the same fallback chain
585 /// (`typeface_with_fallback`) the renderer uses. Doesn't depend on any
586 /// particular family being installed (falls through to Helvetica/Arial/
587 /// the OS default), so this is safe in any CI environment.
588 fn bold_font(size: f32) -> Font {
589 let typeface = typeface_with_fallback("Helvetica", SkFontStyle::bold())
590 .expect("host must have a fallback typeface");
591 Font::from_typeface(typeface, size)
592 }
593
594 // ---- 1. The wrap decision must use real tracking, not 0.0 ----
595
596 /// Direct reproduction of issue #125 §1's headline defect ("gains it a
597 /// line break it did not have at zero"): negative tracking narrows the
598 /// real painted width below the zero-tracking fit-test's estimate, so
599 /// there is a width window — real width <= max_w < zero-tracking width —
600 /// where the buggy zero-tracking test wraps to a second line that the
601 /// real, tighter text did not need.
602 #[test]
603 fn negative_tracking_gains_an_unneeded_break_in_old_wrap_but_not_new() {
604 let text = "THAT MOVES";
605 for font_size in [240.0f32, 290.0, 300.0] {
606 let font = bold_font(font_size);
607 let letter_spacing = -9.0f32;
608
609 let real_width = measure_text_with_fallback(text, &font, &None, letter_spacing);
610 let zero_width = measure_text_with_fallback(text, &font, &None, 0.0);
611 assert!(
612 zero_width > real_width,
613 "negative tracking must make the real width narrower than the \
614 zero-tracking estimate at {font_size}px (real={real_width}, zero={zero_width})"
615 );
616
617 // A width inside the window the bug lives in: the real (tracked)
618 // string fits, but the zero-tracking fit test disagrees.
619 let max_w = (real_width + zero_width) / 2.0;
620
621 let old_lines = wrap_text_with_fallback(text, &font, &None, Some(max_w));
622 let new_lines =
623 wrap_text_with_tracking(text, &font, &None, Some(max_w), letter_spacing);
624
625 assert_eq!(
626 old_lines.len(),
627 2,
628 "old (zero-tracking) wrap should wrongly split at {font_size}px, got {old_lines:?}"
629 );
630 assert_eq!(
631 new_lines.len(),
632 1,
633 "new (tracking-aware) wrap should correctly keep one line at {font_size}px, \
634 matching what {max_w}px >= real width {real_width}px allows, got {new_lines:?}"
635 );
636 }
637 }
638
639 /// The fixed wrap decision must be *stable*: re-evaluated at any width
640 /// sample that is consistent with the real (tracked) content width, it
641 /// always agrees on line count. This is the structural property whose
642 /// absence causes issue #125 §1's "the box is sized for one line, paint
643 /// decides two are needed" — two different width samples taken during
644 /// layout (the intrinsic-measure pass vs. the final paint pass) must not
645 /// be able to make the same content wrap differently.
646 #[test]
647 fn tracking_aware_wrap_agrees_across_width_samples_old_wrap_does_not() {
648 let text = "THAT MOVES";
649 let font = bold_font(290.0);
650 let letter_spacing = -9.0f32;
651 let real_width = measure_text_with_fallback(text, &font, &None, letter_spacing);
652 let zero_width = measure_text_with_fallback(text, &font, &None, 0.0);
653
654 // Two plausible width samples that both satisfy "the real content
655 // fits": comfortably above the zero-tracking width, and just above
656 // the real tracked width (inside the bug's window).
657 let sample_a = zero_width + 24.0;
658 let sample_b = real_width + 1.0;
659 assert!(
660 sample_b < zero_width,
661 "test setup: sample_b must be inside the bug window"
662 );
663
664 let old_a = wrap_text_with_fallback(text, &font, &None, Some(sample_a)).len();
665 let old_b = wrap_text_with_fallback(text, &font, &None, Some(sample_b)).len();
666 let new_a =
667 wrap_text_with_tracking(text, &font, &None, Some(sample_a), letter_spacing).len();
668 let new_b =
669 wrap_text_with_tracking(text, &font, &None, Some(sample_b), letter_spacing).len();
670
671 assert_ne!(
672 old_a, old_b,
673 "reproduction: old wrap must disagree across the two width samples \
674 (sample_a={sample_a}, sample_b={sample_b})"
675 );
676 assert_eq!(
677 new_a, new_b,
678 "fix: new wrap must agree across the two width samples \
679 (sample_a={sample_a}, sample_b={sample_b})"
680 );
681 assert_eq!(
682 new_a, 1,
683 "both samples satisfy the real tracked width, so 1 line is correct"
684 );
685 }
686
687 /// `wrap_text_with_fallback` (the pre-existing, still-used-by-default
688 /// name) must keep its exact old behaviour — 0.0 tracking — so every
689 /// existing call site's output is unchanged until it opts in to
690 /// `wrap_text_with_tracking`.
691 #[test]
692 fn wrap_text_with_fallback_is_unchanged_zero_tracking_behaviour() {
693 let text = "THAT MOVES";
694 let font = bold_font(290.0);
695 let max_w = 700.0;
696 assert_eq!(
697 wrap_text_with_fallback(text, &font, &None, Some(max_w)),
698 wrap_text_with_tracking(text, &font, &None, Some(max_w), 0.0)
699 );
700 }
701
702 // ---- 2. Real Skia render + measured ink extent ----
703 //
704 // Reproduces the audit's methodology from issue #125 §1 (render, then
705 // measure the ink bounding box) directly against this module's public
706 // functions — the primitive this workstream owns — rather than through
707 // the full JSON-scenario pipeline. `rustmotion-components/src/
708 // intrinsic.rs` and `text.rs` (the actual `text` component's box-sizing
709 // and paint code) are outside this workstream's file scope and still
710 // call `wrap_text_with_fallback` (0.0 tracking) as of this fix, so this
711 // test proves the corrected primitive is centred and self-consistent
712 // when used correctly end-to-end — i.e. what the render will look like
713 // once those call sites switch to `wrap_text_with_tracking` — not what
714 // today's shipped renderer currently outputs for a `text` component.
715
716 /// Draw `lines`, each centred within a `box_width`-wide box at
717 /// `box_left`, onto a `w`×`h` black raster, and return the horizontal
718 /// ink centre (mean of the leftmost and rightmost non-black pixel
719 /// columns across every line) — the same "ink extent" measurement
720 /// issue #125's audit table reports.
721 fn render_and_measure_ink_centre_x(
722 lines: &[String],
723 font: &Font,
724 letter_spacing: f32,
725 box_left: f32,
726 box_width: f32,
727 line_height: f32,
728 top_y: f32,
729 w: u32,
730 h: u32,
731 ) -> Option<f32> {
732 let mut surface = surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
733 let canvas = surface.canvas();
734 canvas.clear(Color::BLACK);
735 let mut paint = Paint::default();
736 paint.set_color(Color::WHITE);
737 paint.set_anti_alias(true);
738
739 let (_, metrics) = font.metrics();
740 let ascent = -metrics.ascent;
741
742 for (i, line) in lines.iter().enumerate() {
743 if line.is_empty() {
744 continue;
745 }
746 let advance = measure_text_with_fallback(line, font, &None, letter_spacing);
747 let x = box_left + (box_width - advance) / 2.0;
748 let y = top_y + i as f32 * line_height + ascent;
749 draw_text_with_fallback(canvas, line, font, &None, letter_spacing, x, y, &paint);
750 }
751
752 let info = ImageInfo::new(
753 (w as i32, h as i32),
754 ColorType::RGBA8888,
755 AlphaType::Unpremul,
756 None,
757 );
758 let mut buf = vec![0u8; (w * h * 4) as usize];
759 surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
760
761 let mut min_x: Option<u32> = None;
762 let mut max_x: Option<u32> = None;
763 for y in 0..h {
764 for x in 0..w {
765 let idx = ((y * w + x) * 4) as usize;
766 if buf[idx] > 40 {
767 min_x = Some(min_x.map_or(x, |m| m.min(x)));
768 max_x = Some(max_x.map_or(x, |m| m.max(x)));
769 }
770 }
771 }
772 match (min_x, max_x) {
773 (Some(a), Some(b)) => Some((a as f32 + b as f32) / 2.0),
774 _ => None,
775 }
776 }
777
778 /// The sweep from issue #125 §1's audit table — "THAT MOVES", 1920×1080,
779 /// centred, `line-height: 0.85` — rendered and measured end-to-end with
780 /// the fixed primitive. The wrap decision and the paint centring both
781 /// derive from the *same* tracking-aware measurement, so the box the
782 /// text is centred in and the lines painted into it never disagree: ink
783 /// centre must land on the frame centre (960) at every size, whether or
784 /// not tracking causes a wrap.
785 #[test]
786 fn sweep_that_moves_fixed_primitive_stays_centred() {
787 const W: u32 = 1920;
788 const H: u32 = 1080;
789 const FRAME_CENTRE: f32 = 960.0;
790 let text = "THAT MOVES";
791
792 // A max-width tight enough that -9 tracking genuinely changes the
793 // wrap outcome at some of these sizes (mirrors the audit's
794 // font-size sweep; the exact width is a stand-in for "however wide
795 // this headline's card/frame is" — not taken from the audit, which
796 // doesn't publish its scenario JSON).
797 let box_max_width = 1400.0f32;
798
799 let mut results = Vec::new();
800 for &(font_size, letter_spacing) in &[
801 (240.0f32, 0.0f32),
802 (240.0, -9.0),
803 (290.0, -9.0),
804 (300.0, -9.0),
805 ] {
806 let font = bold_font(font_size);
807 let line_height = font_size * 0.85;
808
809 let lines =
810 wrap_text_with_tracking(text, &font, &None, Some(box_max_width), letter_spacing);
811 let box_width = lines
812 .iter()
813 .map(|l| measure_text_with_fallback(l, &font, &None, letter_spacing))
814 .fold(0.0f32, f32::max);
815 let box_left = FRAME_CENTRE - box_width / 2.0;
816 let top_y = (H as f32 - lines.len() as f32 * line_height) / 2.0;
817
818 let ink_centre = render_and_measure_ink_centre_x(
819 &lines,
820 &font,
821 letter_spacing,
822 box_left,
823 box_width,
824 line_height,
825 top_y,
826 W,
827 H,
828 )
829 .unwrap_or_else(|| panic!("expected ink at {font_size}px/{letter_spacing}"));
830
831 results.push((font_size, letter_spacing, lines.len(), ink_centre));
832
833 // Tolerance: advance-width centring (what text-align:center
834 // actually does, here and in the real painter) is not the same
835 // thing as ink-bbox centring — individual glyphs have left/right
836 // side-bearings that don't visually balance out, especially
837 // comparing e.g. "THAT" vs "MOVES" as separate lines. A few px
838 // of optical vs. advance discrepancy is expected and is not the
839 // #125 defect (that defect was a 408px miscentre from a wrap/
840 // paint disagreement, not glyph-metric noise).
841 assert!(
842 (ink_centre - FRAME_CENTRE).abs() < 15.0,
843 "fixed pipeline ink centre {ink_centre:.1} should be within 15px of \
844 {FRAME_CENTRE} at {font_size}px/{letter_spacing} tracking (lines={})",
845 lines.len()
846 );
847 }
848
849 eprintln!("sweep_that_moves_fixed_primitive_stays_centred (after fix):");
850 for (font_size, letter_spacing, line_count, ink_centre) in &results {
851 eprintln!(
852 " {font_size}px / {letter_spacing} tracking -> lines={line_count}, ink_centre={ink_centre:.1}"
853 );
854 }
855 }
856}
857
858// ─── Tests: #8 — emoji presentation must default to text, not tofu/color ──
859
860#[cfg(test)]
861mod emoji_presentation_tests {
862 use super::super::{emoji_typeface, typeface_with_fallback};
863 use super::*;
864
865 // These assertions are pure code-point classification — no font, no
866 // machine dependency — so they hold on every host.
867
868 #[test]
869 fn text_presentation_default_symbols_are_not_emoji_without_vs16() {
870 // #8's headline repro: ✓ (U+2713) and ✔ (U+2714) — both inside the
871 // old unconditional 0x2702..=0x27B0 dingbats range — must NOT be
872 // classified as emoji when they appear bare, since Unicode marks
873 // them text-presentation by default.
874 assert!(
875 !char_wants_emoji_font('\u{2713}', None),
876 "✓ bare must be text"
877 );
878 assert!(
879 !char_wants_emoji_font('\u{2714}', None),
880 "✔ bare must be text"
881 );
882 // Copyright/registered/trademark: also text-presentation by default.
883 assert!(
884 !char_wants_emoji_font('\u{00A9}', None),
885 "© bare must be text"
886 );
887 assert!(
888 !char_wants_emoji_font('\u{00AE}', None),
889 "® bare must be text"
890 );
891 assert!(
892 !char_wants_emoji_font('\u{2122}', None),
893 "™ bare must be text"
894 );
895 }
896
897 #[test]
898 fn text_presentation_default_symbols_opt_into_emoji_with_vs16() {
899 // Explicit author intent (U+FE0F immediately after) must still be
900 // honoured — this is what "presentation *by default*" means.
901 assert!(char_wants_emoji_font('\u{2713}', Some('\u{FE0F}')));
902 assert!(char_wants_emoji_font('\u{00A9}', Some('\u{FE0F}')));
903 }
904
905 #[test]
906 fn genuine_pictographs_are_always_emoji_regardless_of_vs16() {
907 // Regression guard: the narrowing must not touch the actual emoji
908 // blocks (grinning face, etc.) — these have no meaningful text
909 // rendering at all.
910 assert!(
911 char_wants_emoji_font('\u{1F600}', None),
912 "😀 must stay emoji"
913 );
914 assert!(char_wants_emoji_font('\u{1F600}', Some('\u{FE0F}')));
915 }
916
917 #[test]
918 fn variation_selector_16_itself_is_always_emoji() {
919 // So it merges into whatever run selected it, rather than becoming
920 // its own (invisible, harmless either way) primary-font run.
921 assert!(char_wants_emoji_font('\u{FE0F}', None));
922 }
923
924 #[test]
925 fn variation_selector_15_does_not_force_emoji() {
926 // VS-15 (U+FE0E) forces *text* presentation — must not be conflated
927 // with VS-16 the way the old catch-all 0xFE00..=0xFE0F range did.
928 assert!(!char_wants_emoji_font('\u{2713}', Some('\u{FE0E}')));
929 }
930
931 #[test]
932 fn has_emoji_reflects_the_narrowed_classification() {
933 assert!(!has_emoji("2713:\u{2713} copyright:\u{00A9}"));
934 assert!(has_emoji("checked \u{2713}\u{FE0F}"));
935 assert!(has_emoji("grinning \u{1F600}"));
936 }
937
938 // ---- render-level reproduction: color and coverage, no emoji font needed ----
939
940 fn helvetica_font(size: f32) -> Font {
941 let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal())
942 .expect("host must have a fallback typeface");
943 Font::from_typeface(typeface, size)
944 }
945
946 /// Renders `text` at `size` in white and returns `(ink_pixel_count,
947 /// mean_r, mean_g, mean_b)` over every non-transparent pixel.
948 fn render_and_sample(text: &str, size: f32) -> (usize, f64, f64, f64) {
949 use skia_safe::{surfaces, AlphaType, Color, ColorType, ImageInfo};
950 const W: i32 = 200;
951 const H: i32 = 200;
952 let font = helvetica_font(size);
953 let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, size));
954 let mut surface = surfaces::raster_n32_premul((W, H)).unwrap();
955 let canvas = surface.canvas();
956 canvas.clear(Color::BLACK);
957 let mut paint = Paint::default();
958 paint.set_color(Color::WHITE);
959 paint.set_anti_alias(true);
960 let (_, metrics) = font.metrics();
961 let ascent = -metrics.ascent;
962 draw_text_with_fallback(
963 canvas,
964 text,
965 &font,
966 &emoji_font,
967 0.0,
968 10.0,
969 ascent + 10.0,
970 &paint,
971 );
972
973 let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None);
974 let mut buf = vec![0u8; (W * H * 4) as usize];
975 surface.read_pixels(&info, &mut buf, (W * 4) as usize, (0, 0));
976
977 let (mut n, mut sr, mut sg, mut sb) = (0usize, 0f64, 0f64, 0f64);
978 for px in buf.chunks_exact(4) {
979 if px[3] > 40 {
980 n += 1;
981 sr += px[0] as f64;
982 sg += px[1] as f64;
983 sb += px[2] as f64;
984 }
985 }
986 if n == 0 {
987 (0, 0.0, 0.0, 0.0)
988 } else {
989 (n, sr / n as f64, sg / n as f64, sb / n as f64)
990 }
991 }
992
993 #[test]
994 fn bare_check_mark_paints_requested_white_not_tofu_or_color_bitmap() {
995 // #8 render-level reproduction, environment-independent: whether or
996 // not an emoji font is installed on this host is irrelevant here —
997 // with the fix, U+2713 alone is classified as *text*, so
998 // `draw_text_with_fallback` never even looks at `emoji_font` for
999 // it. Guard on the primary font actually covering the glyph so
1000 // this doesn't fail on some exotic host where even Helvetica lacks
1001 // it (the audit's own finding: Helvetica/Menlo DO have it, only
1002 // Apple Color Emoji doesn't).
1003 let font = helvetica_font(64.0);
1004 if !font_covers(&font, "\u{2713}") {
1005 eprintln!("skip: primary font doesn't cover U+2713 on this host");
1006 return;
1007 }
1008 let (ink, r, g, b) = render_and_sample("\u{2713}", 64.0);
1009 assert!(ink > 20, "expected visible ink for ✓, got {ink} pixels");
1010 // White request -> painted channels should be bright and roughly
1011 // neutral (not a dark/colored emoji-bitmap tint).
1012 assert!(
1013 r > 150.0 && g > 150.0 && b > 150.0,
1014 "✓ should paint near-white, got mean rgb=({r:.0},{g:.0},{b:.0})"
1015 );
1016 }
1017}
1018
1019// ─── Tests: #3 — glyph fallback for scripts the primary font doesn't cover ─
1020
1021#[cfg(test)]
1022mod glyph_fallback_tests {
1023 use super::super::{fallback_typeface_for_char, typeface_with_fallback};
1024 use super::*;
1025
1026 fn helvetica_font(size: f32) -> Font {
1027 let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal())
1028 .expect("host must have a fallback typeface");
1029 Font::from_typeface(typeface, size)
1030 }
1031
1032 #[test]
1033 fn font_covers_detects_missing_glyphs_deterministically() {
1034 // The detection mechanism itself, independent of whatever fallback
1035 // font may or may not be installed: Helvetica (guaranteed present
1036 // by `typeface_with_fallback`'s own contract) covers plain ASCII
1037 // and does not cover CJK.
1038 let font = helvetica_font(32.0);
1039 assert!(font_covers(&font, "ABC"), "Helvetica must cover ASCII");
1040 assert!(
1041 !font_covers(&font, "\u{4F60}\u{597D}"), // 你好
1042 "Helvetica must not cover CJK"
1043 );
1044 }
1045
1046 #[test]
1047 fn classify_char_stays_primary_for_a_codepoint_no_font_covers() {
1048 // A Private Use Area code point is uncovered by the primary font
1049 // (nothing standard assigns glyphs there); it may or may not be
1050 // "covered" by *some* installed font's own PUA convention (icon
1051 // fonts, vendor glyphs) depending on the host, so skip gracefully
1052 // rather than assume every machine agrees — the point of this test
1053 // is that `classify_char` doesn't crash/loop when nothing covers a
1054 // code point, falling back to `Primary` (the pre-fix, tofu-
1055 // producing behaviour) rather than panicking.
1056 let font = helvetica_font(32.0);
1057 let pua = '\u{E000}';
1058 assert!(
1059 !font_covers(&font, &pua.to_string()),
1060 "test setup: PUA code point must be uncovered by the primary font"
1061 );
1062 let style = font.typeface().font_style();
1063 let family = font.typeface().family_name();
1064 if fallback_typeface_for_char(&family, style, pua).is_some() {
1065 eprintln!(
1066 "skip: host has some font claiming to cover U+E000 (PUA) — can't exercise the \
1067 'nothing covers it anywhere' branch on this host"
1068 );
1069 return;
1070 }
1071 let kind = classify_char(pua, &font, None);
1072 assert!(
1073 matches!(kind, RunKind::Primary),
1074 "an uncoverable-anywhere code point must resolve to Primary, not panic"
1075 );
1076 }
1077
1078 #[test]
1079 fn classify_char_resolves_a_fallback_when_the_host_has_one() {
1080 // Environment-dependent by nature (issue: CJK coverage depends on
1081 // installed fonts) — skip gracefully, per this workstream's brief,
1082 // rather than asserting a specific glyph renders. When a fallback
1083 // *is* available (true on stock macOS/Windows/most Linux desktops
1084 // via Noto/PingFang/MS-Gothic-class fonts), `classify_char` itself
1085 // — not just the underlying `fallback_typeface_for_char` primitive
1086 // — must actually route to it, and the resolved typeface must
1087 // cover the character that triggered the lookup.
1088 let font = helvetica_font(32.0);
1089 let style = font.typeface().font_style();
1090 let family = font.typeface().family_name();
1091 if fallback_typeface_for_char(&family, style, '\u{4F60}').is_none() {
1092 eprintln!("skip: no CJK-capable font installed on this host");
1093 return;
1094 }
1095 let kind = classify_char('\u{4F60}', &font, None);
1096 let RunKind::Fallback(fallback) = kind else {
1097 panic!(
1098 "classify_char must resolve a Fallback run for an uncovered CJK code point when \
1099 the host has a capable font"
1100 );
1101 };
1102 let fallback_font = Font::from_typeface(fallback, 32.0);
1103 assert!(
1104 font_covers(&fallback_font, "\u{4F60}"),
1105 "resolved fallback typeface must actually cover the code point that triggered it"
1106 );
1107 }
1108
1109 #[test]
1110 fn measure_and_paint_agree_on_cjk_width_when_fallback_is_available() {
1111 // Measure-vs-paint parity (this workstream's core mandate):
1112 // `measure_text_with_fallback` must report the width that actually
1113 // gets painted. Skips gracefully (see above) when the host has no
1114 // CJK-capable font at all — on such a host both measure and paint
1115 // agree on the pre-fix degraded behaviour (0-width primary-font
1116 // tofu), which is a separate, already-covered case.
1117 let font = helvetica_font(48.0);
1118 let text = "\u{4F60}\u{597D}"; // 你好
1119 if !text.chars().any(|c| {
1120 fallback_typeface_for_char("Helvetica", font.typeface().font_style(), c).is_some()
1121 }) {
1122 eprintln!("skip: no CJK-capable font installed on this host");
1123 return;
1124 }
1125
1126 // Directly prove segmentation actually routes through the fallback
1127 // mechanism (not just that `fallback_typeface_for_char` the
1128 // primitive would resolve *something* if called) — this is what
1129 // distinguishes "the fix is wired up" from "the fix exists but
1130 // nothing calls it".
1131 let runs = segment_text_runs(text, &font);
1132 assert!(
1133 runs.iter().any(|r| matches!(r.kind, RunKind::Fallback(_))),
1134 "expected at least one Fallback run when segmenting CJK text with a fallback font \
1135 available, got kinds: {:?}",
1136 runs.iter()
1137 .map(|r| match &r.kind {
1138 RunKind::Primary => "Primary",
1139 RunKind::Emoji => "Emoji",
1140 RunKind::Fallback(_) => "Fallback",
1141 })
1142 .collect::<Vec<_>>()
1143 );
1144
1145 let measured_w = measure_text_with_fallback(text, &font, &None, 0.0);
1146 // Not tofu-narrow: with a real CJK fallback, two full-width
1147 // ideographs at 48px measure well beyond a couple of `.notdef` box
1148 // glyphs' worth of width.
1149 assert!(
1150 measured_w > 30.0,
1151 "expected a real CJK measurement, got suspiciously narrow {measured_w}"
1152 );
1153
1154 use skia_safe::{surfaces, AlphaType, Color, ColorType, ImageInfo};
1155 const W: i32 = 400;
1156 const H: i32 = 200;
1157 let mut surface = surfaces::raster_n32_premul((W, H)).unwrap();
1158 let canvas = surface.canvas();
1159 canvas.clear(Color::BLACK);
1160 let mut paint = Paint::default();
1161 paint.set_color(Color::WHITE);
1162 paint.set_anti_alias(true);
1163 let (_, metrics) = font.metrics();
1164 let ascent = -metrics.ascent;
1165 draw_text_with_fallback(canvas, text, &font, &None, 0.0, 10.0, ascent + 10.0, &paint);
1166
1167 // Ink detection: the canvas is cleared to opaque black (alpha=255
1168 // everywhere), so — unlike a transparent-cleared surface — alpha
1169 // can't distinguish text from background here. White text against
1170 // black shows up as a bright *red* (or green/blue) channel instead
1171 // (same technique `tracking_tests::render_and_measure_ink_centre_x`
1172 // above uses).
1173 let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None);
1174 let mut buf = vec![0u8; (W * H * 4) as usize];
1175 surface.read_pixels(&info, &mut buf, (W * 4) as usize, (0, 0));
1176 let mut min_x: Option<i32> = None;
1177 let mut max_x: Option<i32> = None;
1178 for y in 0..H {
1179 for x in 0..W {
1180 let idx = ((y * W + x) * 4) as usize;
1181 if buf[idx] > 40 {
1182 min_x = Some(min_x.map_or(x, |m| m.min(x)));
1183 max_x = Some(max_x.map_or(x, |m| m.max(x)));
1184 }
1185 }
1186 }
1187 let (min_x, max_x) = (
1188 min_x.expect("must paint something"),
1189 max_x.expect("must paint something"),
1190 );
1191 let painted_width = (max_x - min_x) as f32;
1192
1193 // Loose tolerance: ink-bbox width vs advance width can differ from
1194 // glyph side-bearings/overhang even for a well-behaved font — this
1195 // is not the #125-style "wrap/paint disagreement" defect (a wild,
1196 // multiple-hundred-px miscentre), just normal glyph-metric slack.
1197 assert!(
1198 (painted_width - measured_w).abs() < measured_w * 0.5 + 20.0,
1199 "measured width {measured_w} should roughly match the painted ink width \
1200 {painted_width} (min_x={min_x}, max_x={max_x})"
1201 );
1202 }
1203}