Skip to main content

cranpose_render_common/
software_text_raster.rs

1use ab_glyph::{
2    point, Font, FontArc, FontVec, Glyph, GlyphId, OutlinedGlyph, PxScale, ScaleFont, VariableFont,
3};
4use cranpose_core::hash::default as default_hash;
5use cranpose_ui::text::{
6    AnnotatedString, FontFamily, FontStyle, FontSynthesis, FontWeight, RangeStyle, RenderString,
7    Shadow, SpanStyle, TextDrawStyle, TextMotion, TextShaping, TextStyle,
8};
9use cranpose_ui::text_layout_result::{GlyphLayout, LineLayout, TextLayoutData, TextLayoutResult};
10use cranpose_ui::{TextLinePrefixWidths, TextMeasurer, TextMetrics};
11use cranpose_ui_graphics::{Color, ImageBitmap, Rect};
12use std::hash::{Hash, Hasher};
13use std::rc::Rc;
14use std::sync::{Arc, Mutex, MutexGuard};
15use tiny_skia::{LineCap, LineJoin, Paint, Path, PathBuilder, Pixmap, Stroke, Transform};
16
17use crate::bounded_lru_cache::BoundedLruCache;
18use crate::brush_sampling::{color_to_rgba, sample_brush_rgba};
19#[cfg(test)]
20use crate::font_layout::layout_line_glyphs;
21use crate::font_layout::{
22    align_glyph_to_pixel_grid, line_advance_width, pixel_bounds_from_outlined, vertical_metrics,
23    GlyphPixelBounds,
24};
25use crate::gpos_kerning::KernedFont;
26#[cfg(feature = "text-hyphenation")]
27use crate::text_hyphenation::HyphenationDictionaryError;
28use crate::text_hyphenation::HyphenationDictionaryStore;
29use crate::Brush;
30
31const COMPOSE_STROKE_MITER_LIMIT: f32 = 4.0;
32const SHADOW_SIGMA_SCALE: f32 = 0.57735;
33const SHADOW_SIGMA_BIAS: f32 = 0.5;
34const MAX_GAUSSIAN_KERNEL_HALF: i32 = 128;
35const SOFTWARE_TEXT_GLYPH_METRICS_CACHE_CAPACITY: usize = 8_192;
36const SOFTWARE_TEXT_KERN_METRICS_CACHE_CAPACITY: usize = 16_384;
37const SOFTWARE_TEXT_PREFIX_WIDTH_CACHE_CAPACITY: usize = 512;
38#[cfg(feature = "embedded-default-font")]
39#[doc(hidden)]
40pub const DEFAULT_SOFTWARE_TEXT_FONT_BYTES: &[u8] = include_bytes!("../assets/NotoSansMerged.ttf");
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
43pub enum SoftwareTextFontError {
44    #[error("invalid software text font bytes")]
45    InvalidFont,
46    #[error("embedded default font disabled (feature `embedded-default-font` is off)")]
47    EmbeddedFontDisabled,
48}
49
50#[derive(Clone)]
51pub struct SoftwareTextFont {
52    font: KernedFont,
53    metadata: SoftwareTextFontMetadata,
54    score: TextFontScore,
55    content_hash: u64,
56}
57
58#[derive(Clone)]
59struct SoftwareTextFontMetadata {
60    families: Arc<[String]>,
61    registered_family: Option<FontFamilyKey>,
62    weight: FontWeight,
63    style: FontStyle,
64    ab_glyph_scale_factor: f32,
65}
66
67/// Identity an app-supplied face was registered under.
68///
69/// `FontFamily::FileBacked` and `FontFamily::LoadedTypeface` name a face by its
70/// files rather than by a name inside the font, so resolution cannot compare
71/// strings from the `name` table. Hashing the `FontFamily` value once at
72/// registration and once per resolve keeps the two sides in step without
73/// walking path lists on every frame.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
75pub struct FontFamilyKey(u64);
76
77impl FontFamilyKey {
78    pub fn of(family: &FontFamily) -> Self {
79        let mut state = default_hash::new();
80        family.hash(&mut state);
81        Self(state.finish())
82    }
83}
84
85impl SoftwareTextFont {
86    pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Result<Self, SoftwareTextFontError> {
87        let bytes = bytes.into();
88        let mut hasher = default_hash::new();
89        bytes.hash(&mut hasher);
90        let content_hash = hasher.finish();
91        let metadata = software_text_font_metadata(bytes.as_slice());
92        let kerning = KernedFont::read_kerning(bytes.as_slice(), &[]);
93        let font = FontArc::try_from_vec(bytes).map_err(|_| SoftwareTextFontError::InvalidFont)?;
94        let score =
95            text_font_score_from_parts(&font, metadata.ab_glyph_scale_factor, metadata.weight);
96        Ok(Self {
97            font: KernedFont::new(font, kerning),
98            metadata,
99            score,
100            content_hash,
101        })
102    }
103
104    /// Parse `bytes` as a face an app registered under `family`, declaring
105    /// `weight` and `style` for it.
106    ///
107    /// The declaration wins over the face's own `OS/2` values, the way a
108    /// Compose `Font(resId, FontWeight.Medium)` entry does, and a variable face
109    /// is instanced on its `wght`/`ital` axes so one file can back a whole
110    /// family. That last part is what makes Android's `sans-serif` reachable:
111    /// the platform ships a single variable `Roboto-Regular.ttf` and describes
112    /// every weight of the family as an axis position on it.
113    pub fn from_registered_bytes(
114        family: &FontFamily,
115        weight: FontWeight,
116        style: FontStyle,
117        bytes: impl Into<Vec<u8>>,
118    ) -> Result<Self, SoftwareTextFontError> {
119        let bytes = bytes.into();
120        let mut hasher = default_hash::new();
121        bytes.hash(&mut hasher);
122        let mut metadata = software_text_font_metadata(bytes.as_slice());
123        metadata.registered_family = Some(FontFamilyKey::of(family));
124        metadata.weight = weight;
125        metadata.style = style;
126
127        let mut font =
128            FontVec::try_from_vec(bytes).map_err(|_| SoftwareTextFontError::InvalidFont)?;
129        // Two instances of one variable file draw different outlines, so the
130        // axis values have to reach `content_hash` — it is the glyph atlas key.
131        let variations = apply_declared_variations(&mut font, weight, style);
132        for (tag, value) in &variations {
133            tag.hash(&mut hasher);
134            value.to_bits().hash(&mut hasher);
135        }
136        let content_hash = hasher.finish();
137
138        // Read the kerning at the SAME axis position the outlines were
139        // instanced at. Roboto's GPOS pair values carry `VariationIndex`
140        // deltas worth up to 136 font units between wght 400 and wght 700, so
141        // a table read at the default instance would kern a Medium run as if
142        // it were Regular.
143        let kerning = KernedFont::read_kerning(font.font_data(), &variations);
144
145        let font = FontArc::from(font);
146        let score = text_font_score_from_parts(&font, metadata.ab_glyph_scale_factor, weight);
147        Ok(Self {
148            font: KernedFont::new(font, kerning),
149            metadata,
150            score,
151            content_hash,
152        })
153    }
154
155    pub fn family_names(&self) -> &[String] {
156        &self.metadata.families
157    }
158
159    /// The family an app registered this face under, if any.
160    pub fn registered_family(&self) -> Option<FontFamilyKey> {
161        self.metadata.registered_family
162    }
163
164    pub fn weight(&self) -> FontWeight {
165        self.metadata.weight
166    }
167
168    pub fn style(&self) -> FontStyle {
169        self.metadata.style
170    }
171
172    fn ab_glyph_px_size(&self, logical_font_size: f32) -> f32 {
173        logical_font_size * self.metadata.ab_glyph_scale_factor
174    }
175
176    /// Stable hash of the font binary — cache-key component wherever
177    /// rasterized output depends on which font served the run.
178    pub fn content_hash(&self) -> u64 {
179        self.content_hash
180    }
181}
182
183pub fn try_default_software_text_font() -> Result<SoftwareTextFont, SoftwareTextFontError> {
184    #[cfg(feature = "embedded-default-font")]
185    {
186        SoftwareTextFont::from_bytes(DEFAULT_SOFTWARE_TEXT_FONT_BYTES.to_vec())
187    }
188    #[cfg(not(feature = "embedded-default-font"))]
189    {
190        Err(SoftwareTextFontError::EmbeddedFontDisabled)
191    }
192}
193
194pub fn default_software_text_font() -> Option<SoftwareTextFont> {
195    try_default_software_text_font().ok()
196}
197
198#[derive(Clone)]
199pub struct SoftwareTextFontSet {
200    fonts: Arc<[SoftwareTextFont]>,
201    registered_families: Arc<[FontFamilyKey]>,
202    default_index: Option<usize>,
203}
204
205impl SoftwareTextFontSet {
206    pub fn empty() -> Self {
207        Self::from_faces(Vec::new())
208    }
209
210    pub fn from_font(font: SoftwareTextFont) -> Self {
211        Self::from_faces(vec![font])
212    }
213
214    /// Build a set from already-parsed faces, keeping the default-face choice
215    /// and the registered-family index in one place.
216    pub fn from_faces(fonts: Vec<SoftwareTextFont>) -> Self {
217        let mut registered_families: Vec<FontFamilyKey> = Vec::new();
218        for family in fonts.iter().filter_map(SoftwareTextFont::registered_family) {
219            if !registered_families.contains(&family) {
220                registered_families.push(family);
221            }
222        }
223        let default_index = (!fonts.is_empty()).then(|| default_font_index(&fonts));
224        Self {
225            fonts: Arc::from(fonts),
226            registered_families: Arc::from(registered_families),
227            default_index,
228        }
229    }
230
231    pub fn from_fonts_or_default(fonts: &[&[u8]]) -> Self {
232        let mut parsed = Vec::with_capacity(fonts.len().max(1));
233        for font in fonts {
234            if let Ok(candidate) = SoftwareTextFont::from_bytes((*font).to_vec()) {
235                parsed.push(candidate);
236            }
237        }
238        if parsed.is_empty() {
239            if let Some(default_font) = default_software_text_font() {
240                parsed.push(default_font);
241            }
242        }
243
244        Self::from_faces(parsed)
245    }
246
247    pub fn default_font(&self) -> Option<&SoftwareTextFont> {
248        self.default_index.and_then(|index| self.fonts.get(index))
249    }
250
251    /// Every face in the set, in registration order.
252    pub fn faces(&self) -> &[SoftwareTextFont] {
253        &self.fonts
254    }
255
256    /// Whether any face in the set was registered under `family`.
257    pub fn has_registered_family(&self, family: &FontFamily) -> bool {
258        self.registered_families
259            .contains(&FontFamilyKey::of(family))
260    }
261
262    pub fn resolve(&self, style: &TextStyle) -> Option<&SoftwareTextFont> {
263        let target_weight = style.span_style.font_weight.unwrap_or_default();
264        let target_style = style.span_style.font_style.unwrap_or_default();
265        let request = FontFamilyRequest::resolve(
266            style.span_style.font_family.as_ref(),
267            &self.registered_families,
268        );
269
270        let mut best: Option<(usize, u32)> = None;
271        for (index, font) in self.fonts.iter().enumerate() {
272            let Some(score) = font_match_score(font, target_weight, target_style, request) else {
273                continue;
274            };
275            if best.is_none_or(|(_, best_score)| score < best_score) {
276                best = Some((index, score));
277            }
278        }
279
280        let index = best.map(|(index, _)| index).or(self.default_index);
281        index.and_then(|index| self.fonts.get(index))
282    }
283}
284
285pub fn software_text_font_from_fonts_or_default(fonts: &[&[u8]]) -> Option<SoftwareTextFont> {
286    SoftwareTextFontSet::from_fonts_or_default(fonts)
287        .default_font()
288        .cloned()
289}
290
291pub fn software_text_font_set_from_fonts_or_default(fonts: &[&[u8]]) -> SoftwareTextFontSet {
292    SoftwareTextFontSet::from_fonts_or_default(fonts)
293}
294
295#[derive(Clone, Copy)]
296struct TextFontScore {
297    supported_latin_chars: usize,
298    latin_sample_width: f32,
299}
300
301impl TextFontScore {
302    fn is_complete_default_face(self) -> bool {
303        const LATIN_SAMPLE_CHAR_COUNT: usize = 21;
304        self.supported_latin_chars == LATIN_SAMPLE_CHAR_COUNT && self.latin_sample_width > 1.0
305    }
306
307    fn is_better_than(self, other: Self) -> bool {
308        self.supported_latin_chars > other.supported_latin_chars
309            || (self.supported_latin_chars == other.supported_latin_chars
310                && self.latin_sample_width > other.latin_sample_width)
311    }
312}
313
314fn text_font_score(font: &SoftwareTextFont) -> TextFontScore {
315    font.score
316}
317
318fn text_font_score_from_parts(
319    font: &FontArc,
320    ab_glyph_scale_factor: f32,
321    weight: FontWeight,
322) -> TextFontScore {
323    const SAMPLE: &str = "UNDER The quick brown fox";
324    let glyph_font_size = 18.0 * ab_glyph_scale_factor;
325    let scaled_font = font.as_scaled(PxScale::from(glyph_font_size));
326    let supported_latin_chars = SAMPLE
327        .chars()
328        .filter(|ch| !ch.is_whitespace())
329        .filter(|ch| scaled_font.glyph_id(*ch).0 != 0)
330        .count();
331    let latin_sample_width = measure_text_impl(
332        SAMPLE,
333        &TextStyle::default(),
334        18.0,
335        glyph_font_size,
336        font,
337        FontStyle::Normal,
338        weight,
339    )
340    .width;
341    TextFontScore {
342        supported_latin_chars,
343        latin_sample_width,
344    }
345}
346
347fn default_font_index(fonts: &[SoftwareTextFont]) -> usize {
348    let mut best: Option<(usize, TextFontScore)> = None;
349    for (index, font) in fonts.iter().enumerate() {
350        let score = text_font_score(font);
351        if font.style() == FontStyle::Normal
352            && font.weight() == FontWeight::NORMAL
353            && score.is_complete_default_face()
354        {
355            return index;
356        }
357        if best
358            .as_ref()
359            .is_none_or(|(_, best_score)| score.is_better_than(*best_score))
360        {
361            best = Some((index, score));
362        }
363    }
364    best.map(|(index, _)| index).unwrap_or(0)
365}
366
367/// How a `TextStyle`'s font family narrows the faces a resolve may pick from.
368///
369/// Both measurement and rasterization go through `SoftwareTextFontSet::resolve`,
370/// so deriving the constraint here is what keeps the two from disagreeing.
371#[derive(Clone, Copy)]
372enum FontFamilyRequest<'a> {
373    /// No family was named, or a generic family nothing was registered under.
374    /// Every face is eligible and weight/style alone decide, which is the
375    /// behaviour generic families had before app-supplied fonts existed.
376    Any,
377    /// A face qualifies by carrying `name` in its `name` table, or by having
378    /// been registered under `FontFamily::Named(name)`.
379    Named { name: &'a str, key: FontFamilyKey },
380    /// Only faces registered under exactly this family value qualify.
381    Registered(FontFamilyKey),
382}
383
384impl<'a> FontFamilyRequest<'a> {
385    fn resolve(font_family: Option<&'a FontFamily>, registered: &[FontFamilyKey]) -> Self {
386        match font_family {
387            None | Some(FontFamily::Default) => Self::Any,
388            Some(FontFamily::Named(name)) => Self::Named {
389                name: name.as_str(),
390                key: FontFamilyKey::of(&FontFamily::Named(name.clone())),
391            },
392            Some(family @ (FontFamily::FileBacked(_) | FontFamily::LoadedTypeface(_))) => {
393                Self::Registered(FontFamilyKey::of(family))
394            }
395            Some(family) => {
396                // Generic families (`SansSerif`, `Serif`, …) only constrain the
397                // set once an app has registered a face for one; otherwise they
398                // would strip the weight matching they used to allow.
399                let key = FontFamilyKey::of(family);
400                if registered.contains(&key) {
401                    Self::Registered(key)
402                } else {
403                    Self::Any
404                }
405            }
406        }
407    }
408
409    fn matches(self, font: &SoftwareTextFont) -> bool {
410        match self {
411            Self::Any => true,
412            Self::Named { name, key } => {
413                font_family_matches(font, name) || font.registered_family() == Some(key)
414            }
415            Self::Registered(key) => font.registered_family() == Some(key),
416        }
417    }
418}
419
420fn font_match_score(
421    font: &SoftwareTextFont,
422    target_weight: FontWeight,
423    target_style: FontStyle,
424    request: FontFamilyRequest<'_>,
425) -> Option<u32> {
426    if !request.matches(font) {
427        return None;
428    }
429    let style_penalty = if font.style() == target_style {
430        0
431    } else {
432        10_000
433    };
434    let weight_penalty = (i32::from(font.weight().0) - i32::from(target_weight.0)).unsigned_abs();
435    let coverage_penalty =
436        (21usize.saturating_sub(text_font_score(font).supported_latin_chars) as u32) * 1_000;
437
438    Some(style_penalty + weight_penalty + coverage_penalty)
439}
440
441fn font_family_matches(font: &SoftwareTextFont, requested: &str) -> bool {
442    font.family_names()
443        .iter()
444        .any(|family| family.eq_ignore_ascii_case(requested))
445}
446
447/// Instance a variable face at the weight and slant the app declared for it.
448///
449/// Returns the axis values actually applied so they can join the face's content
450/// hash: two instances of one file share bytes but not outlines, and
451/// `content_hash` is what keys the glyph mask cache and the glyph atlas.
452fn apply_declared_variations(
453    font: &mut FontVec,
454    weight: FontWeight,
455    style: FontStyle,
456) -> Vec<([u8; 4], f32)> {
457    // Faces spell italic either as `ital` (0..1) or as `slnt`, a counter-
458    // clockwise angle where a right-leaning oblique is negative. The angle
459    // matches the slant `TextStyleSynthesis` would shear in, so a face that
460    // carries the axis lands where a synthesized one would.
461    const OBLIQUE_DEGREES: f32 = -12.0;
462
463    let mut applied = Vec::new();
464    for axis in font.variations() {
465        let requested = match &axis.tag {
466            b"wght" => f32::from(weight.value()),
467            b"ital" if style == FontStyle::Italic => 1.0,
468            b"slnt" if style == FontStyle::Italic => OBLIQUE_DEGREES,
469            _ => continue,
470        };
471        let value = requested.clamp(axis.min_value, axis.max_value);
472        if font.set_variation(&axis.tag, value) {
473            applied.push((axis.tag, value));
474        }
475    }
476    applied
477}
478
479fn software_text_font_metadata(bytes: &[u8]) -> SoftwareTextFontMetadata {
480    let Some(face) = ttf_parser::Face::parse(bytes, 0).ok() else {
481        return SoftwareTextFontMetadata {
482            families: Arc::from(Vec::<String>::new()),
483            registered_family: None,
484            weight: FontWeight::NORMAL,
485            style: FontStyle::Normal,
486            ab_glyph_scale_factor: 1.0,
487        };
488    };
489
490    let mut families = Vec::new();
491    for name in face.names() {
492        if matches!(
493            name.name_id,
494            ttf_parser::name_id::TYPOGRAPHIC_FAMILY | ttf_parser::name_id::FAMILY
495        ) {
496            if let Some(value) = name.to_string().filter(|value| !value.is_empty()) {
497                if !families
498                    .iter()
499                    .any(|existing: &String| existing.eq_ignore_ascii_case(&value))
500                {
501                    families.push(value);
502                }
503            }
504        }
505    }
506    let weight = FontWeight::try_new(face.weight().to_number()).unwrap_or(FontWeight::NORMAL);
507    let style = if face.is_italic() {
508        FontStyle::Italic
509    } else {
510        FontStyle::Normal
511    };
512    let units_per_em = face.units_per_em() as f32;
513    let height = (face.ascender() as f32 - face.descender() as f32).abs();
514    let ab_glyph_scale_factor =
515        if units_per_em.is_finite() && units_per_em > 0.0 && height.is_finite() && height > 0.0 {
516            height / units_per_em
517        } else {
518            1.0
519        };
520
521    SoftwareTextFontMetadata {
522        families: Arc::from(families),
523        registered_family: None,
524        weight,
525        style,
526        ab_glyph_scale_factor,
527    }
528}
529
530#[derive(Clone)]
531struct TextMetricsKey {
532    text: Rc<str>,
533    font_size_bits: u32,
534    style_hash: u64,
535    span_styles_hash: u64,
536}
537
538impl PartialEq for TextMetricsKey {
539    fn eq(&self, other: &Self) -> bool {
540        (Rc::ptr_eq(&self.text, &other.text) || *self.text == *other.text)
541            && self.font_size_bits == other.font_size_bits
542            && self.style_hash == other.style_hash
543            && self.span_styles_hash == other.span_styles_hash
544    }
545}
546
547impl Eq for TextMetricsKey {}
548
549impl Hash for TextMetricsKey {
550    fn hash<H: Hasher>(&self, state: &mut H) {
551        self.text.hash(state);
552        self.font_size_bits.hash(state);
553        self.style_hash.hash(state);
554        self.span_styles_hash.hash(state);
555    }
556}
557
558struct SoftwareTextMetricsCache {
559    map: BoundedLruCache<TextMetricsKey, TextMetrics>,
560    line_prefix_widths: BoundedLruCache<LinePrefixWidthsKey, TextLinePrefixWidths>,
561    glyph_metrics: SoftwareTextGlyphMetricsCache,
562}
563
564impl SoftwareTextMetricsCache {
565    fn new(capacity: usize) -> Self {
566        Self {
567            map: BoundedLruCache::with_capacity_at_least_one(capacity),
568            line_prefix_widths: BoundedLruCache::with_capacity_at_least_one(
569                capacity.max(SOFTWARE_TEXT_PREFIX_WIDTH_CACHE_CAPACITY),
570            ),
571            glyph_metrics: SoftwareTextGlyphMetricsCache::new(),
572        }
573    }
574
575    fn get_or_measure(
576        &mut self,
577        fonts: &SoftwareTextFontSet,
578        text: &AnnotatedString,
579        style: &TextStyle,
580    ) -> TextMetrics {
581        let font_size = resolve_font_size(style);
582        let key = TextMetricsKey {
583            text: Rc::from(text.text.as_str()),
584            font_size_bits: font_size.to_bits(),
585            style_hash: style.measurement_hash(),
586            span_styles_hash: text.span_styles_hash(),
587        };
588        if let Some(metrics) = self.map.get(&key).copied() {
589            return metrics;
590        }
591
592        let metrics =
593            measure_annotated_text_with_font_set_cached(text, style, font_size, fonts, self);
594        self.map.put(key, metrics);
595        metrics
596    }
597
598    fn get_or_measure_line_prefix_widths(
599        &mut self,
600        fonts: &SoftwareTextFontSet,
601        text: &AnnotatedString,
602        line_range: std::ops::Range<usize>,
603        style: &TextStyle,
604    ) -> Option<TextLinePrefixWidths> {
605        let key = line_prefix_widths_key(text, line_range.clone(), style)?;
606        if let Some(widths) = self.line_prefix_widths.get(&key) {
607            return Some(widths.clone());
608        }
609
610        let widths = annotated_line_prefix_widths_with_font_set_cached(
611            text, line_range, style, fonts, self,
612        )?;
613        self.line_prefix_widths.put(key, widths.clone());
614        Some(widths)
615    }
616
617    fn get_or_measure_line_width(
618        &mut self,
619        fonts: &SoftwareTextFontSet,
620        text: &AnnotatedString,
621        line_range: std::ops::Range<usize>,
622        style: &TextStyle,
623    ) -> Option<f32> {
624        let key = line_prefix_widths_key(text, line_range.clone(), style)?;
625        if let Some(widths) = self.line_prefix_widths.get(&key) {
626            return widths.width_for_char_range(0, widths.char_count());
627        }
628
629        let widths = annotated_line_prefix_widths_with_font_set_cached(
630            text, line_range, style, fonts, self,
631        )?;
632        let width = widths.width_for_char_range(0, widths.char_count());
633        self.line_prefix_widths.put(key, widths);
634        width
635    }
636}
637
638#[derive(Clone)]
639struct LinePrefixWidthsKey {
640    text: Rc<str>,
641    start: usize,
642    end: usize,
643    style_hash: u64,
644    span_styles_hash: u64,
645}
646
647impl PartialEq for LinePrefixWidthsKey {
648    fn eq(&self, other: &Self) -> bool {
649        (Rc::ptr_eq(&self.text, &other.text) || *self.text == *other.text)
650            && self.start == other.start
651            && self.end == other.end
652            && self.style_hash == other.style_hash
653            && self.span_styles_hash == other.span_styles_hash
654    }
655}
656
657impl Eq for LinePrefixWidthsKey {}
658
659impl Hash for LinePrefixWidthsKey {
660    fn hash<H: Hasher>(&self, state: &mut H) {
661        self.text.hash(state);
662        self.start.hash(state);
663        self.end.hash(state);
664        self.style_hash.hash(state);
665        self.span_styles_hash.hash(state);
666    }
667}
668
669fn line_prefix_widths_key(
670    text: &AnnotatedString,
671    line_range: std::ops::Range<usize>,
672    style: &TextStyle,
673) -> Option<LinePrefixWidthsKey> {
674    if !style_allows_prefix_widths(style)
675        || line_range.start > line_range.end
676        || line_range.end > text.text.len()
677        || !text.text.is_char_boundary(line_range.start)
678        || !text.text.is_char_boundary(line_range.end)
679        || text.text[line_range.clone()].contains('\n')
680    {
681        return None;
682    }
683
684    Some(LinePrefixWidthsKey {
685        text: Rc::from(text.text.as_str()),
686        start: line_range.start,
687        end: line_range.end,
688        style_hash: style.measurement_hash(),
689        span_styles_hash: text.span_styles_hash(),
690    })
691}
692
693/// A glyph's advance, in font units.
694///
695/// Font units rather than pixels, because everything ab_glyph reports for a
696/// scaled font is the unscaled value times one horizontal scale factor. Caching
697/// the scaled number buys nothing and costs the key: the font size has to join
698/// it, and text whose size moves -- a scaling list, a zoom, a spring on a font
699/// size -- then misses on every glyph of every frame and re-reads the font
700/// tables for all of them. In font units such a page pays for each glyph once
701/// and multiplies from then on.
702#[derive(Clone, Copy, Debug)]
703struct CachedGlyphMetrics {
704    glyph_id: GlyphId,
705    advance_unscaled: f32,
706}
707
708#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
709struct GlyphMetricsKey {
710    font_hash: u64,
711    ch: char,
712}
713
714#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
715struct KernMetricsKey {
716    font_hash: u64,
717    previous_id: u32,
718    glyph_id: u32,
719}
720
721#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
722struct SoftwareTextGlyphMetricsStats {
723    glyph_hits: u64,
724    glyph_misses: u64,
725    kern_hits: u64,
726    kern_misses: u64,
727}
728
729struct SoftwareTextGlyphMetricsCache {
730    glyphs: BoundedLruCache<GlyphMetricsKey, CachedGlyphMetrics>,
731    kerns: BoundedLruCache<KernMetricsKey, f32>,
732    stats: SoftwareTextGlyphMetricsStats,
733}
734
735impl SoftwareTextGlyphMetricsCache {
736    fn new() -> Self {
737        Self {
738            glyphs: BoundedLruCache::with_capacity_at_least_one(
739                SOFTWARE_TEXT_GLYPH_METRICS_CACHE_CAPACITY,
740            ),
741            kerns: BoundedLruCache::with_capacity_at_least_one(
742                SOFTWARE_TEXT_KERN_METRICS_CACHE_CAPACITY,
743            ),
744            stats: SoftwareTextGlyphMetricsStats::default(),
745        }
746    }
747
748    #[cfg(test)]
749    fn stats(&self) -> SoftwareTextGlyphMetricsStats {
750        self.stats
751    }
752
753    /// Glyph id and advance in font units. Multiply the advance by the run's
754    /// horizontal scale factor, which is what ab_glyph would have done.
755    fn glyph_metrics<F, S>(
756        &mut self,
757        font: &SoftwareTextFont,
758        scaled_font: &S,
759        ch: char,
760    ) -> CachedGlyphMetrics
761    where
762        F: Font,
763        S: ScaleFont<F>,
764    {
765        let key = GlyphMetricsKey {
766            font_hash: font.content_hash(),
767            ch,
768        };
769        if let Some(metrics) = self.glyphs.get(&key).copied() {
770            self.stats.glyph_hits = self.stats.glyph_hits.saturating_add(1);
771            return metrics;
772        }
773
774        let glyph_id = scaled_font.font().glyph_id(ch);
775        let metrics = CachedGlyphMetrics {
776            glyph_id,
777            advance_unscaled: scaled_font.font().h_advance_unscaled(glyph_id).max(0.0),
778        };
779        self.glyphs.put(key, metrics);
780        self.stats.glyph_misses = self.stats.glyph_misses.saturating_add(1);
781        metrics
782    }
783
784    /// Kerning between two glyphs in font units, scaled by the caller like the
785    /// advance beside it.
786    fn kern<F, S>(
787        &mut self,
788        font: &SoftwareTextFont,
789        scaled_font: &S,
790        previous_id: GlyphId,
791        glyph_id: GlyphId,
792    ) -> f32
793    where
794        F: Font,
795        S: ScaleFont<F>,
796    {
797        let key = KernMetricsKey {
798            font_hash: font.content_hash(),
799            previous_id: previous_id.0.into(),
800            glyph_id: glyph_id.0.into(),
801        };
802        if let Some(kern) = self.kerns.get(&key).copied() {
803            self.stats.kern_hits = self.stats.kern_hits.saturating_add(1);
804            return kern;
805        }
806
807        let kern = scaled_font.font().kern_unscaled(previous_id, glyph_id);
808        self.kerns.put(key, kern);
809        self.stats.kern_misses = self.stats.kern_misses.saturating_add(1);
810        kern
811    }
812}
813
814pub struct SoftwareTextMeasurer {
815    fonts: SoftwareTextFontSet,
816    cache: Mutex<SoftwareTextMetricsCache>,
817    hyphenation: HyphenationDictionaryStore,
818}
819
820impl SoftwareTextMeasurer {
821    pub fn new(font: SoftwareTextFont, cache_capacity: usize) -> Self {
822        Self::from_font_set(SoftwareTextFontSet::from_font(font), cache_capacity)
823    }
824
825    pub fn from_font_set(fonts: SoftwareTextFontSet, cache_capacity: usize) -> Self {
826        Self {
827            fonts,
828            cache: Mutex::new(SoftwareTextMetricsCache::new(cache_capacity)),
829            hyphenation: HyphenationDictionaryStore::new(),
830        }
831    }
832
833    pub fn from_fonts_or_default(fonts: &[&[u8]], cache_capacity: usize) -> Self {
834        Self::from_font_set(
835            software_text_font_set_from_fonts_or_default(fonts),
836            cache_capacity,
837        )
838    }
839
840    fn lock_cache(&self) -> MutexGuard<'_, SoftwareTextMetricsCache> {
841        self.cache
842            .lock()
843            .unwrap_or_else(|poisoned| poisoned.into_inner())
844    }
845
846    #[cfg(feature = "text-hyphenation")]
847    pub fn register_hyphenation_dictionary_path(
848        &self,
849        locale: &str,
850        path: impl AsRef<std::path::Path>,
851    ) -> Result<(), HyphenationDictionaryError> {
852        self.hyphenation.register_dictionary_path(locale, path)
853    }
854
855    #[cfg(feature = "text-hyphenation")]
856    pub fn register_hyphenation_dictionary_reader(
857        &self,
858        locale: &str,
859        reader: &mut impl std::io::Read,
860    ) -> Result<(), HyphenationDictionaryError> {
861        self.hyphenation.register_dictionary_reader(locale, reader)
862    }
863}
864
865impl TextMeasurer for SoftwareTextMeasurer {
866    fn measure(&self, text: &cranpose_ui::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
867        self.lock_cache().get_or_measure(&self.fonts, text, style)
868    }
869
870    fn measure_subsequence(
871        &self,
872        text: &cranpose_ui::text::AnnotatedString,
873        range: std::ops::Range<usize>,
874        style: &TextStyle,
875    ) -> TextMetrics {
876        let text = text.subsequence(range);
877        self.lock_cache().get_or_measure(&self.fonts, &text, style)
878    }
879
880    fn measure_line_prefix_widths(
881        &self,
882        text: &cranpose_ui::text::AnnotatedString,
883        line_range: std::ops::Range<usize>,
884        style: &TextStyle,
885    ) -> Option<TextLinePrefixWidths> {
886        self.lock_cache()
887            .get_or_measure_line_prefix_widths(&self.fonts, text, line_range, style)
888    }
889
890    fn measure_line_width(
891        &self,
892        text: &cranpose_ui::text::AnnotatedString,
893        line_range: std::ops::Range<usize>,
894        style: &TextStyle,
895    ) -> Option<f32> {
896        self.lock_cache()
897            .get_or_measure_line_width(&self.fonts, text, line_range, style)
898    }
899
900    fn line_height(&self, text: &cranpose_ui::text::AnnotatedString, style: &TextStyle) -> f32 {
901        let font_size = resolve_font_size(style);
902        max_line_height_for_annotated_text_with_resolver(text, style, font_size, &self.fonts)
903    }
904
905    fn glyph_line_box(&self, style: &TextStyle) -> Option<(f32, f32)> {
906        let font = self.fonts.resolve(style)?;
907        let font_size = resolve_font_size(style);
908        let metrics = crate::font_layout::vertical_metrics(&font.font, font_size);
909        let asked = line_height_for_render_style(style, font_size);
910        let resolved = line_box_for(style, metrics, asked, measure_grid());
911        // Glyph rows sit in the slot the line box gives them; the tight box is
912        // the font's own ascent+descent extent, clamped to the slot.
913        let height = metrics.natural_line_height.min(resolved.height).max(1.0);
914        Some((((resolved.height - height) * 0.5).max(0.0), height))
915    }
916
917    fn first_baseline(&self, style: &TextStyle) -> Option<f32> {
918        Some(self.line_box(style)?.baseline)
919    }
920
921    fn line_box(&self, style: &TextStyle) -> Option<cranpose_ui::text::LineBox> {
922        let font = self.fonts.resolve(style)?;
923        let font_size = resolve_font_size(style);
924        // Metrics must be read at the font's own px size, not the logical one:
925        // this is the very expression `collect_text_segment_solid_atlas_glyphs`
926        // places glyph origins with, so a baseline-anchored draw lands on the
927        // row the rasterizer will use. (`glyph_line_box` deliberately keeps its
928        // own, coarser box for selection chrome.)
929        let metrics =
930            crate::font_layout::vertical_metrics(&font.font, font.ab_glyph_px_size(font_size));
931        // A measurer works in layout points, so the line box rounds on the
932        // device grid the app is running at rather than on whole points.
933        Some(line_box_for(
934            style,
935            metrics,
936            line_height_for_render_style(style, font_size),
937            measure_grid(),
938        ))
939    }
940
941    fn get_offset_for_position(
942        &self,
943        text: &cranpose_ui::text::AnnotatedString,
944        style: &TextStyle,
945        x: f32,
946        y: f32,
947    ) -> usize {
948        if let Some(font) = self.fonts.resolve(style) {
949            text_offset_for_position_with_font(text.text.as_str(), style, x, y, font)
950        } else {
951            fallback_text_offset_for_position(text.text.as_str(), style, x, y)
952        }
953    }
954
955    fn get_cursor_x_for_offset(
956        &self,
957        text: &cranpose_ui::text::AnnotatedString,
958        style: &TextStyle,
959        offset: usize,
960    ) -> f32 {
961        if let Some(font) = self.fonts.resolve(style) {
962            cursor_x_for_offset_with_font(text.text.as_str(), style, offset, font)
963        } else {
964            fallback_cursor_x_for_offset(text.text.as_str(), style, offset)
965        }
966    }
967
968    fn layout(
969        &self,
970        text: &cranpose_ui::text::AnnotatedString,
971        style: &TextStyle,
972    ) -> TextLayoutResult {
973        if let Some(font) = self.fonts.resolve(style) {
974            layout_text_with_font(text.text.as_str(), style, font)
975        } else {
976            fallback_layout_text(text.text.as_str(), style)
977        }
978    }
979
980    fn choose_auto_hyphen_break(
981        &self,
982        line: &str,
983        style: &TextStyle,
984        segment_start_char: usize,
985        measured_break_char: usize,
986    ) -> Option<usize> {
987        self.hyphenation.choose_auto_hyphen_break(
988            line,
989            style,
990            segment_start_char,
991            measured_break_char,
992        )
993    }
994}
995
996pub fn software_text_content_hash(text: &cranpose_ui::text::AnnotatedString) -> u64 {
997    let mut state = default_hash::new();
998    text.text.hash(&mut state);
999    text.span_styles_hash().hash(&mut state);
1000    state.finish()
1001}
1002
1003#[derive(Clone, Copy)]
1004enum GlyphRasterStyle {
1005    Fill,
1006    Stroke { width_px: f32 },
1007}
1008
1009#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1010pub struct SoftwareGlyphAtlasKey {
1011    pub font_hash: u64,
1012    pub glyph_id: u32,
1013    pub scale_x_bits: u32,
1014    pub scale_y_bits: u32,
1015    pub embolden_px_bits: u32,
1016    pub slant_bits: u32,
1017}
1018
1019#[derive(Clone)]
1020pub struct SoftwareGlyphAtlasMask {
1021    pub alpha: Arc<[f32]>,
1022    pub width: usize,
1023    pub height: usize,
1024}
1025
1026#[derive(Clone)]
1027pub struct SoftwareGlyphAtlasGlyph {
1028    pub key: SoftwareGlyphAtlasKey,
1029    pub mask: SoftwareGlyphAtlasMask,
1030    pub x: i32,
1031    pub y: i32,
1032    pub color: Color,
1033}
1034
1035#[derive(Clone, Copy)]
1036pub struct SoftwareGlyphAtlasPlacement {
1037    pub key: SoftwareGlyphAtlasKey,
1038    pub x: i32,
1039    pub y: i32,
1040    pub width: usize,
1041    pub height: usize,
1042    pub color: Color,
1043}
1044
1045#[derive(Clone)]
1046pub enum SoftwareGlyphAtlasRunGlyph {
1047    Cached(SoftwareGlyphAtlasPlacement),
1048    New(SoftwareGlyphAtlasGlyph),
1049}
1050
1051impl SoftwareGlyphAtlasRunGlyph {
1052    pub fn placement(&self) -> SoftwareGlyphAtlasPlacement {
1053        match self {
1054            Self::Cached(placement) => *placement,
1055            Self::New(glyph) => SoftwareGlyphAtlasPlacement {
1056                key: glyph.key,
1057                x: glyph.x,
1058                y: glyph.y,
1059                width: glyph.mask.width,
1060                height: glyph.mask.height,
1061                color: glyph.color,
1062            },
1063        }
1064    }
1065}
1066
1067#[derive(Clone)]
1068struct GlyphMask {
1069    alpha: Arc<[f32]>,
1070    width: usize,
1071    height: usize,
1072    origin_x: i32,
1073    origin_y: i32,
1074}
1075
1076#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1077pub struct SoftwareGlyphRasterCacheStats {
1078    pub entries: usize,
1079    pub hits: u64,
1080    pub misses: u64,
1081}
1082
1083const RUN_GLYPH_METRICS_CACHE_LIMIT: usize = 64;
1084
1085#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1086enum GlyphRasterStyleKey {
1087    Fill,
1088    Stroke { width_px_bits: u32 },
1089}
1090
1091impl GlyphRasterStyleKey {
1092    fn from_style(style: GlyphRasterStyle) -> Self {
1093        match style {
1094            GlyphRasterStyle::Fill => Self::Fill,
1095            GlyphRasterStyle::Stroke { width_px } => Self::Stroke {
1096                width_px_bits: width_px.to_bits(),
1097            },
1098        }
1099    }
1100}
1101
1102#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1103struct GlyphMaskCacheKey {
1104    font_hash: u64,
1105    glyph_id: u32,
1106    scale_x_bits: u32,
1107    scale_y_bits: u32,
1108    raster_style: GlyphRasterStyleKey,
1109    embolden_px_bits: u32,
1110    slant_bits: u32,
1111}
1112
1113#[derive(Clone)]
1114struct CachedGlyphMask {
1115    alpha: Arc<[f32]>,
1116    width: usize,
1117    height: usize,
1118    origin_offset_x: i32,
1119    origin_offset_y: i32,
1120}
1121
1122impl CachedGlyphMask {
1123    fn from_mask(mask: GlyphMask, glyph: &Glyph) -> Self {
1124        let (glyph_x, glyph_y) = static_glyph_pixel_origin(glyph);
1125        Self {
1126            alpha: mask.alpha,
1127            width: mask.width,
1128            height: mask.height,
1129            origin_offset_x: mask.origin_x - glyph_x,
1130            origin_offset_y: mask.origin_y - glyph_y,
1131        }
1132    }
1133
1134    fn instantiate(&self, glyph: &Glyph) -> GlyphMask {
1135        let (glyph_x, glyph_y) = static_glyph_pixel_origin(glyph);
1136        GlyphMask {
1137            alpha: Arc::clone(&self.alpha),
1138            width: self.width,
1139            height: self.height,
1140            origin_x: glyph_x + self.origin_offset_x,
1141            origin_y: glyph_y + self.origin_offset_y,
1142        }
1143    }
1144
1145    fn placement(&self, glyph: &Glyph) -> (i32, i32, usize, usize) {
1146        let (glyph_x, glyph_y) = static_glyph_pixel_origin(glyph);
1147        (
1148            glyph_x + self.origin_offset_x,
1149            glyph_y + self.origin_offset_y,
1150            self.width,
1151            self.height,
1152        )
1153    }
1154
1155    fn atlas_metrics(&self, key: SoftwareGlyphAtlasKey) -> CachedAtlasGlyphMetrics {
1156        CachedAtlasGlyphMetrics {
1157            key,
1158            width: self.width,
1159            height: self.height,
1160            origin_offset_x: self.origin_offset_x,
1161            origin_offset_y: self.origin_offset_y,
1162        }
1163    }
1164}
1165
1166#[derive(Clone, Copy)]
1167struct CachedAtlasGlyphMetrics {
1168    key: SoftwareGlyphAtlasKey,
1169    width: usize,
1170    height: usize,
1171    origin_offset_x: i32,
1172    origin_offset_y: i32,
1173}
1174
1175impl CachedAtlasGlyphMetrics {
1176    fn placement(self, glyph: &Glyph, color: Color) -> SoftwareGlyphAtlasPlacement {
1177        let (glyph_x, glyph_y) = static_glyph_pixel_origin(glyph);
1178        SoftwareGlyphAtlasPlacement {
1179            key: self.key,
1180            x: glyph_x + self.origin_offset_x,
1181            y: glyph_y + self.origin_offset_y,
1182            width: self.width,
1183            height: self.height,
1184            color,
1185        }
1186    }
1187}
1188
1189pub struct SoftwareGlyphRasterCache {
1190    masks: BoundedLruCache<GlyphMaskCacheKey, CachedGlyphMask>,
1191    hits: u64,
1192    misses: u64,
1193}
1194
1195impl SoftwareGlyphRasterCache {
1196    pub fn with_capacity_at_least_one(capacity: usize) -> Self {
1197        Self {
1198            masks: BoundedLruCache::with_capacity_at_least_one(capacity),
1199            hits: 0,
1200            misses: 0,
1201        }
1202    }
1203
1204    pub fn stats(&self) -> SoftwareGlyphRasterCacheStats {
1205        SoftwareGlyphRasterCacheStats {
1206            entries: self.masks.len(),
1207            hits: self.hits,
1208            misses: self.misses,
1209        }
1210    }
1211
1212    fn get(&mut self, key: &GlyphMaskCacheKey, glyph: &Glyph) -> Option<GlyphMask> {
1213        let mask = self.masks.get(key)?.instantiate(glyph);
1214        self.hits = self.hits.saturating_add(1);
1215        Some(mask)
1216    }
1217
1218    fn get_atlas_placement(
1219        &mut self,
1220        key: &GlyphMaskCacheKey,
1221        glyph: &Glyph,
1222    ) -> Option<(SoftwareGlyphAtlasKey, i32, i32, usize, usize)> {
1223        let atlas_key = glyph_atlas_key_from_mask_key(*key)?;
1224        let (x, y, width, height) = self.masks.get(key)?.placement(glyph);
1225        self.hits = self.hits.saturating_add(1);
1226        Some((atlas_key, x, y, width, height))
1227    }
1228
1229    fn get_atlas_metrics(&mut self, key: &GlyphMaskCacheKey) -> Option<CachedAtlasGlyphMetrics> {
1230        let atlas_key = glyph_atlas_key_from_mask_key(*key)?;
1231        let metrics = self.masks.get(key)?.atlas_metrics(atlas_key);
1232        self.hits = self.hits.saturating_add(1);
1233        Some(metrics)
1234    }
1235
1236    pub fn atlas_glyph_for_placement(
1237        &mut self,
1238        placement: &SoftwareGlyphAtlasPlacement,
1239    ) -> Option<SoftwareGlyphAtlasGlyph> {
1240        let key = GlyphMaskCacheKey {
1241            font_hash: placement.key.font_hash,
1242            glyph_id: placement.key.glyph_id,
1243            scale_x_bits: placement.key.scale_x_bits,
1244            scale_y_bits: placement.key.scale_y_bits,
1245            raster_style: GlyphRasterStyleKey::Fill,
1246            embolden_px_bits: placement.key.embolden_px_bits,
1247            slant_bits: placement.key.slant_bits,
1248        };
1249        let mask = self.masks.get(&key)?;
1250        self.hits = self.hits.saturating_add(1);
1251        Some(SoftwareGlyphAtlasGlyph {
1252            key: placement.key,
1253            mask: SoftwareGlyphAtlasMask {
1254                alpha: Arc::clone(&mask.alpha),
1255                width: mask.width,
1256                height: mask.height,
1257            },
1258            x: placement.x,
1259            y: placement.y,
1260            color: placement.color,
1261        })
1262    }
1263
1264    fn put(&mut self, key: GlyphMaskCacheKey, glyph: &Glyph, mask: GlyphMask) -> GlyphMask {
1265        let cached = CachedGlyphMask::from_mask(mask, glyph);
1266        let mask = cached.instantiate(glyph);
1267        self.masks.put(key, cached);
1268        self.misses = self.misses.saturating_add(1);
1269        mask
1270    }
1271}
1272
1273struct RasterFontRef<'a, F> {
1274    font: &'a F,
1275    ab_glyph_scale_factor: f32,
1276    weight: FontWeight,
1277    style: FontStyle,
1278}
1279
1280#[derive(Clone, Copy)]
1281struct TextWeightSynthesis {
1282    embolden_px: f32,
1283    advance_scale: f32,
1284}
1285
1286impl TextWeightSynthesis {
1287    fn none() -> Self {
1288        Self {
1289            embolden_px: 0.0,
1290            advance_scale: 1.0,
1291        }
1292    }
1293
1294    /// Minikin's `computeFakery`: semibold or darker, **and** at least two
1295    /// grades above the face that answered.
1296    ///
1297    /// ```text
1298    /// bool isFakeBold = wanted.weight() >= 600 && (wanted.weight() - actual.weight()) >= 200;
1299    /// ```
1300    ///
1301    /// — `frameworks/minikin/libs/minikin/FontFamily.cpp`. Both halves matter.
1302    /// Synthesising for *any* positive gap fabricates weights the platform
1303    /// never draws: Wear Material 3 asks `sans-serif` for body 450 against a
1304    /// family declared in hundreds, Android resolves that to the 400 face and
1305    /// applies no fakery at all, while a proportional rule emboldens the 50
1306    /// units of shortfall. That divergence is invisible in a screenshot of one
1307    /// app and obvious next to every other app on the device.
1308    ///
1309    /// It also moves centred text. Only the measure paths scale advances by
1310    /// [`Self::advance_scale`] — the glyph layout loops do not — so a run that
1311    /// synthesises measures wider than it draws, and `TextAlign::Center`
1312    /// offsets it by half that difference. On Wear's Credits screen a 450
1313    /// request measured 231.16 px against 228.14 px drawn and every line sat
1314    /// left of Kotlin's.
1315    const FAKE_BOLD_MIN_WEIGHT: u16 = 600;
1316    const FAKE_BOLD_MIN_DELTA: u16 = 200;
1317
1318    fn for_style(
1319        style: &TextStyle,
1320        resolved_weight: FontWeight,
1321        font_size: f32,
1322        scale: f32,
1323    ) -> Self {
1324        let requested_weight = style.span_style.font_weight.unwrap_or_default();
1325        if requested_weight <= resolved_weight {
1326            return Self::none();
1327        }
1328        if requested_weight.value() < Self::FAKE_BOLD_MIN_WEIGHT
1329            || requested_weight.value() - resolved_weight.value() < Self::FAKE_BOLD_MIN_DELTA
1330        {
1331            return Self::none();
1332        }
1333
1334        let synthesis = style
1335            .span_style
1336            .font_synthesis
1337            .unwrap_or(FontSynthesis::All);
1338        if !matches!(synthesis, FontSynthesis::All | FontSynthesis::Weight) {
1339            return Self::none();
1340        }
1341
1342        let weight_delta = (requested_weight.value() - resolved_weight.value()) as f32;
1343        let strength = (weight_delta / 300.0).clamp(0.0, 1.5);
1344        Self {
1345            embolden_px: (font_size * scale * 0.055 * strength).clamp(0.0, 3.0 * scale),
1346            advance_scale: 1.0 + 0.085 * strength.min(1.0),
1347        }
1348    }
1349
1350    fn apply_width(self, width: f32) -> f32 {
1351        width * self.advance_scale
1352    }
1353}
1354
1355#[derive(Clone, Copy)]
1356struct TextStyleSynthesis {
1357    slant: f32,
1358    font_size: f32,
1359    scale: f32,
1360}
1361
1362impl TextStyleSynthesis {
1363    fn none() -> Self {
1364        Self {
1365            slant: 0.0,
1366            font_size: 0.0,
1367            scale: 1.0,
1368        }
1369    }
1370
1371    fn for_style(style: &TextStyle, resolved_style: FontStyle, font_size: f32, scale: f32) -> Self {
1372        let requested_style = style.span_style.font_style.unwrap_or_default();
1373        if requested_style != FontStyle::Italic || resolved_style == FontStyle::Italic {
1374            return Self::none();
1375        }
1376
1377        let synthesis = style
1378            .span_style
1379            .font_synthesis
1380            .unwrap_or(FontSynthesis::All);
1381        if !matches!(synthesis, FontSynthesis::All | FontSynthesis::Style) {
1382            return Self::none();
1383        }
1384
1385        Self {
1386            slant: 0.22,
1387            font_size,
1388            scale,
1389        }
1390    }
1391
1392    fn visual_overhang_px(self) -> f32 {
1393        if self.slant <= 0.0 || !self.font_size.is_finite() || !self.scale.is_finite() {
1394            return 0.0;
1395        }
1396        (self.font_size * self.scale * self.slant).ceil().max(0.0)
1397    }
1398}
1399
1400pub fn rasterize_text_to_image(
1401    text: &str,
1402    rect: Rect,
1403    style: &TextStyle,
1404    fallback_color: Color,
1405    font_size: f32,
1406    scale: f32,
1407    font: &SoftwareTextFont,
1408) -> Option<ImageBitmap> {
1409    rasterize_text_to_image_impl(
1410        TextRasterImageRequest {
1411            text,
1412            rect,
1413            style,
1414            fallback_color,
1415            font_size,
1416            scale,
1417        },
1418        RasterFontRef {
1419            font: &font.font,
1420            ab_glyph_scale_factor: font.metadata.ab_glyph_scale_factor,
1421            weight: font.weight(),
1422            style: font.style(),
1423        },
1424        font.content_hash(),
1425        None,
1426    )
1427}
1428
1429#[allow(clippy::too_many_arguments)]
1430pub fn rasterize_text_to_image_with_glyph_cache(
1431    text: &str,
1432    rect: Rect,
1433    style: &TextStyle,
1434    fallback_color: Color,
1435    font_size: f32,
1436    scale: f32,
1437    font: &SoftwareTextFont,
1438    glyph_cache: &mut SoftwareGlyphRasterCache,
1439) -> Option<ImageBitmap> {
1440    rasterize_text_to_image_impl(
1441        TextRasterImageRequest {
1442            text,
1443            rect,
1444            style,
1445            fallback_color,
1446            font_size,
1447            scale,
1448        },
1449        RasterFontRef {
1450            font: &font.font,
1451            ab_glyph_scale_factor: font.metadata.ab_glyph_scale_factor,
1452            weight: font.weight(),
1453            style: font.style(),
1454        },
1455        font.content_hash(),
1456        Some(glyph_cache),
1457    )
1458}
1459
1460/// The slice of a text payload that solid-run rasterization reads: content
1461/// plus span styles. Borrowable from both an [`AnnotatedString`] (UI-side
1462/// text) and a [`RenderString`] (a lowered scene's link-handler-free view),
1463/// so the run collectors serve both without copying either.
1464#[derive(Clone, Copy)]
1465pub struct StyledTextRef<'a> {
1466    pub text: &'a str,
1467    pub span_styles: &'a [RangeStyle<SpanStyle>],
1468}
1469
1470impl StyledTextRef<'_> {
1471    fn is_empty(&self) -> bool {
1472        self.text.is_empty()
1473    }
1474
1475    /// Mirrors [`AnnotatedString::span_boundaries`].
1476    fn span_boundaries(&self) -> Vec<usize> {
1477        let mut boundaries = vec![0, self.text.len()];
1478        for span in self.span_styles {
1479            boundaries.push(span.range.start);
1480            boundaries.push(span.range.end);
1481        }
1482        boundaries.sort_unstable();
1483        boundaries.dedup();
1484        boundaries
1485            .into_iter()
1486            .filter(|&b| b <= self.text.len() && self.text.is_char_boundary(b))
1487            .collect()
1488    }
1489}
1490
1491impl<'a> From<&'a AnnotatedString> for StyledTextRef<'a> {
1492    fn from(text: &'a AnnotatedString) -> Self {
1493        Self {
1494            text: text.text.as_str(),
1495            span_styles: &text.span_styles,
1496        }
1497    }
1498}
1499
1500impl<'a> From<&'a RenderString> for StyledTextRef<'a> {
1501    fn from(text: &'a RenderString) -> Self {
1502        Self {
1503            text: text.text.as_str(),
1504            span_styles: &text.span_styles,
1505        }
1506    }
1507}
1508
1509/// The x each line of an annotated block starts at, relative to the block's
1510/// left edge, once `TextAlign` has had its say.
1511///
1512/// The block's own offset inside its parent is the scene builder's job; this
1513/// is the other half, and Compose needs both — a `TextAlign` applies to every
1514/// line of the paragraph, so a wrapped continuation line is centred in its own
1515/// right and does not simply start where the first line started. See
1516/// `scene_builder::text_align_fraction` for why the two telescope.
1517///
1518/// This walks the same span/newline segmentation the collectors below walk,
1519/// and adds up advances with the same kern-plus-letter-spacing rule the glyph
1520/// loops use, so the widths it aligns against are the widths that get drawn.
1521/// `None` when there is nothing to do: start-aligned, or a single line.
1522fn annotated_line_alignment_offsets(
1523    text: &StyledTextRef<'_>,
1524    style: &TextStyle,
1525    font_size: f32,
1526    scale: f32,
1527    fonts: &SoftwareTextFontSet,
1528) -> Option<Vec<f32>> {
1529    let align_fraction = crate::scene_builder::text_align_fraction(style, text.text);
1530    if align_fraction == 0.0 || !text.text.contains('\n') {
1531        return None;
1532    }
1533
1534    let mut advances = vec![0.0f32];
1535    for range in annotated_segment_boundaries(text).windows(2) {
1536        let (start, end) = (range[0], range[1]);
1537        if start == end {
1538            continue;
1539        }
1540        let segment_style = effective_style_for_range(text.span_styles, style, start, end);
1541        for part in text.text[start..end].split_inclusive('\n') {
1542            let has_newline = part.ends_with('\n');
1543            let content = if has_newline {
1544                &part[..part.len().saturating_sub(1)]
1545            } else {
1546                part
1547            };
1548            if !content.is_empty() {
1549                let segment_font_size = segment_style.resolve_font_size(font_size);
1550                let font = fonts.resolve(&segment_style)?;
1551                let font_px_size = font.ab_glyph_px_size(segment_font_size) * scale;
1552                let letter_spacing =
1553                    resolve_letter_spacing(&segment_style, segment_font_size) * scale;
1554                if let Some(last) = advances.last_mut() {
1555                    *last += segment_advance_px(&font.font, content, font_px_size, letter_spacing);
1556                }
1557            }
1558            if has_newline {
1559                advances.push(0.0);
1560            }
1561        }
1562    }
1563
1564    let block = advances.iter().copied().fold(0.0f32, f32::max);
1565    Some(
1566        advances
1567            .iter()
1568            .map(|advance| ((block - advance) * align_fraction).max(0.0))
1569            .collect(),
1570    )
1571}
1572
1573/// Where an annotated block changes style or breaks a line — every offset the
1574/// collectors below cut a segment at.
1575fn annotated_segment_boundaries(text: &StyledTextRef<'_>) -> Vec<usize> {
1576    let mut boundaries = text.span_boundaries();
1577    for (offset, ch) in text.text.char_indices() {
1578        if ch == '\n' {
1579            boundaries.push(offset);
1580            boundaries.push(offset + ch.len_utf8());
1581        }
1582    }
1583    boundaries.sort_unstable();
1584    boundaries.dedup();
1585    boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
1586    boundaries
1587}
1588
1589/// One line's advance, walked exactly as the glyph loops walk it.
1590fn segment_advance_px(
1591    font: &impl Font,
1592    content: &str,
1593    font_px_size: f32,
1594    letter_spacing: f32,
1595) -> f32 {
1596    let scaled_font = font.as_scaled(PxScale::from(font_px_size));
1597    let mut caret = 0.0f32;
1598    let mut previous = None;
1599    for ch in content.chars() {
1600        let glyph_id = scaled_font.glyph_id(ch);
1601        if let Some(previous_id) = previous {
1602            caret += scaled_font.kern(previous_id, glyph_id);
1603        }
1604        // One letter space PER CHARACTER, not per gap -- see `run_tracking`.
1605        caret += letter_spacing + scaled_font.h_advance(glyph_id);
1606        previous = Some(glyph_id);
1607    }
1608    caret.max(0.0)
1609}
1610
1611#[allow(clippy::too_many_arguments)]
1612pub fn rasterize_annotated_text_to_image_with_glyph_cache<'a>(
1613    text: impl Into<StyledTextRef<'a>>,
1614    rect: Rect,
1615    style: &TextStyle,
1616    fallback_color: Color,
1617    font_size: f32,
1618    scale: f32,
1619    fonts: &SoftwareTextFontSet,
1620    glyph_cache: &mut SoftwareGlyphRasterCache,
1621) -> Option<ImageBitmap> {
1622    let text: StyledTextRef<'a> = text.into();
1623    if text.span_styles.is_empty() {
1624        let font = fonts.resolve(style)?;
1625        return rasterize_text_to_image_with_glyph_cache(
1626            text.text,
1627            rect,
1628            style,
1629            fallback_color,
1630            font_size,
1631            scale,
1632            font,
1633            glyph_cache,
1634        );
1635    }
1636    if text.is_empty()
1637        || rect.width <= 0.0
1638        || rect.height <= 0.0
1639        || !font_size.is_finite()
1640        || font_size <= 0.0
1641        || !scale.is_finite()
1642        || scale <= 0.0
1643    {
1644        return None;
1645    }
1646
1647    let width = rect.width.ceil().max(1.0) as u32;
1648    let height = rect.height.ceil().max(1.0) as u32;
1649    let boundaries = text.span_boundaries();
1650    let mut segment_plan = Vec::with_capacity(boundaries.len().saturating_sub(1));
1651    for window in boundaries.windows(2) {
1652        let start = window[0];
1653        let end = window[1];
1654        if start == end {
1655            continue;
1656        }
1657        let segment_style = effective_style_for_range(text.span_styles, style, start, end);
1658        if !style_can_rasterize_direct_solid(&segment_style) {
1659            return None;
1660        }
1661        let static_text_motion = segment_style
1662            .paragraph_style
1663            .text_motion
1664            .unwrap_or(TextMotion::Static)
1665            == TextMotion::Static;
1666        if !static_text_motion {
1667            return None;
1668        }
1669        segment_plan.push((start, end, segment_style));
1670    }
1671
1672    let mut canvas = vec![0_u8; (width as usize) * (height as usize) * 4];
1673    let base_line_height = line_height_for_render_style(style, font_size);
1674    let mut current_line_height = base_line_height;
1675    // Each line of the block is aligned in its own right; see
1676    // `annotated_line_alignment_offsets`.
1677    let line_offsets = annotated_line_alignment_offsets(&text, style, font_size, scale, fonts);
1678    let mut line_idx = 0usize;
1679    let mut cursor_x = rect.x + line_offset(&line_offsets, 0);
1680    let mut cursor_y = rect.y;
1681
1682    for (start, end, segment_style) in segment_plan {
1683        let segment = &text.text[start..end];
1684        for part in segment.split_inclusive('\n') {
1685            let has_newline = part.ends_with('\n');
1686            let content = if has_newline {
1687                &part[..part.len().saturating_sub(1)]
1688            } else {
1689                part
1690            };
1691
1692            if !content.is_empty() {
1693                let segment_font_size = segment_style.resolve_font_size(font_size);
1694                if let Some(font) = fonts.resolve(&segment_style) {
1695                    let local_rect = Rect {
1696                        x: (cursor_x - rect.x).round(),
1697                        y: (cursor_y - rect.y).round(),
1698                        width: width as f32,
1699                        height: height as f32,
1700                    };
1701                    let color = segment_style.resolve_text_color(fallback_color);
1702                    let advance_px = draw_text_segment_solid_to_rgba(
1703                        &mut canvas,
1704                        width,
1705                        height,
1706                        content,
1707                        local_rect,
1708                        &segment_style,
1709                        color,
1710                        segment_font_size,
1711                        scale,
1712                        font,
1713                        glyph_cache,
1714                    );
1715                    cursor_x += advance_px;
1716                    current_line_height = current_line_height.max(line_height_for_render_style(
1717                        &segment_style,
1718                        segment_font_size,
1719                    ));
1720                }
1721            }
1722
1723            if has_newline {
1724                line_idx += 1;
1725                cursor_x = rect.x + line_offset(&line_offsets, line_idx);
1726                cursor_y += current_line_height * scale;
1727                current_line_height = base_line_height;
1728            }
1729        }
1730    }
1731
1732    ImageBitmap::from_rgba8(width, height, canvas).ok()
1733}
1734
1735#[allow(clippy::too_many_arguments)]
1736pub fn collect_solid_text_atlas_glyphs(
1737    text: &AnnotatedString,
1738    rect: Rect,
1739    style: &TextStyle,
1740    fallback_color: Color,
1741    font_size: f32,
1742    scale: f32,
1743    fonts: &SoftwareTextFontSet,
1744    glyph_cache: &mut SoftwareGlyphRasterCache,
1745    out: &mut Vec<SoftwareGlyphAtlasGlyph>,
1746) -> Option<()> {
1747    if text.is_empty()
1748        || rect.width <= 0.0
1749        || rect.height <= 0.0
1750        || !font_size.is_finite()
1751        || font_size <= 0.0
1752        || !scale.is_finite()
1753        || scale <= 0.0
1754    {
1755        return Some(());
1756    }
1757
1758    let base_line_height = line_height_for_render_style(style, font_size);
1759    let mut current_line_height = base_line_height;
1760    // Each line of the block is aligned in its own right; see
1761    // `annotated_line_alignment_offsets`.
1762    let line_offsets = annotated_line_alignment_offsets(
1763        &StyledTextRef::from(text),
1764        style,
1765        font_size,
1766        scale,
1767        fonts,
1768    );
1769    let mut line_idx = 0usize;
1770    let mut cursor_x = rect.x + line_offset(&line_offsets, 0);
1771    let mut cursor_y = rect.y;
1772    let initial_len = out.len();
1773
1774    let mut boundaries = text.span_boundaries();
1775    for (offset, ch) in text.text.char_indices() {
1776        if ch == '\n' {
1777            boundaries.push(offset);
1778            boundaries.push(offset + ch.len_utf8());
1779        }
1780    }
1781    boundaries.sort_unstable();
1782    boundaries.dedup();
1783    boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
1784
1785    for range in boundaries.windows(2) {
1786        let start = range[0];
1787        let end = range[1];
1788        if start == end {
1789            continue;
1790        }
1791        let segment_style = effective_style_for_range(&text.span_styles, style, start, end);
1792        if !style_can_atlas_solid_fill(&segment_style) {
1793            out.truncate(initial_len);
1794            return None;
1795        }
1796        let static_text_motion = segment_style
1797            .paragraph_style
1798            .text_motion
1799            .unwrap_or(TextMotion::Static)
1800            == TextMotion::Static;
1801        if !static_text_motion {
1802            out.truncate(initial_len);
1803            return None;
1804        }
1805
1806        let segment = &text.text[start..end];
1807        for part in segment.split_inclusive('\n') {
1808            let has_newline = part.ends_with('\n');
1809            let content = if has_newline {
1810                &part[..part.len().saturating_sub(1)]
1811            } else {
1812                part
1813            };
1814
1815            if !content.is_empty() {
1816                let segment_font_size = segment_style.resolve_font_size(font_size);
1817                let Some(font) = fonts.resolve(&segment_style) else {
1818                    out.truncate(initial_len);
1819                    return None;
1820                };
1821                let local_rect = Rect {
1822                    x: (cursor_x - rect.x).round(),
1823                    y: (cursor_y - rect.y).round(),
1824                    width: rect.width,
1825                    height: rect.height,
1826                };
1827                let color = segment_style.resolve_text_color(fallback_color);
1828                let advance_px = collect_text_segment_solid_atlas_glyphs(
1829                    content,
1830                    local_rect,
1831                    &segment_style,
1832                    color,
1833                    segment_font_size,
1834                    scale,
1835                    font,
1836                    glyph_cache,
1837                    out,
1838                )?;
1839                cursor_x += advance_px;
1840                current_line_height = current_line_height.max(line_height_for_render_style(
1841                    &segment_style,
1842                    segment_font_size,
1843                ));
1844            }
1845
1846            if has_newline {
1847                line_idx += 1;
1848                cursor_x = rect.x + line_offset(&line_offsets, line_idx);
1849                cursor_y += current_line_height * scale;
1850                current_line_height = base_line_height;
1851            }
1852        }
1853    }
1854
1855    Some(())
1856}
1857
1858#[allow(clippy::too_many_arguments)]
1859pub fn collect_cached_solid_text_atlas_placements(
1860    text: &AnnotatedString,
1861    rect: Rect,
1862    style: &TextStyle,
1863    fallback_color: Color,
1864    font_size: f32,
1865    scale: f32,
1866    fonts: &SoftwareTextFontSet,
1867    glyph_cache: &mut SoftwareGlyphRasterCache,
1868    out: &mut Vec<SoftwareGlyphAtlasPlacement>,
1869) -> Option<()> {
1870    if text.is_empty()
1871        || rect.width <= 0.0
1872        || rect.height <= 0.0
1873        || !font_size.is_finite()
1874        || font_size <= 0.0
1875        || !scale.is_finite()
1876        || scale <= 0.0
1877    {
1878        return Some(());
1879    }
1880
1881    let base_line_height = line_height_for_render_style(style, font_size);
1882    let mut current_line_height = base_line_height;
1883    // Each line of the block is aligned in its own right; see
1884    // `annotated_line_alignment_offsets`.
1885    let line_offsets = annotated_line_alignment_offsets(
1886        &StyledTextRef::from(text),
1887        style,
1888        font_size,
1889        scale,
1890        fonts,
1891    );
1892    let mut line_idx = 0usize;
1893    let mut cursor_x = rect.x + line_offset(&line_offsets, 0);
1894    let mut cursor_y = rect.y;
1895    let initial_len = out.len();
1896
1897    let mut boundaries = text.span_boundaries();
1898    for (offset, ch) in text.text.char_indices() {
1899        if ch == '\n' {
1900            boundaries.push(offset);
1901            boundaries.push(offset + ch.len_utf8());
1902        }
1903    }
1904    boundaries.sort_unstable();
1905    boundaries.dedup();
1906    boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
1907
1908    for range in boundaries.windows(2) {
1909        let start = range[0];
1910        let end = range[1];
1911        if start == end {
1912            continue;
1913        }
1914        let segment_style = effective_style_for_range(&text.span_styles, style, start, end);
1915        if !style_can_atlas_solid_fill(&segment_style) {
1916            out.truncate(initial_len);
1917            return None;
1918        }
1919        let static_text_motion = segment_style
1920            .paragraph_style
1921            .text_motion
1922            .unwrap_or(TextMotion::Static)
1923            == TextMotion::Static;
1924        if !static_text_motion {
1925            out.truncate(initial_len);
1926            return None;
1927        }
1928
1929        let segment = &text.text[start..end];
1930        for part in segment.split_inclusive('\n') {
1931            let has_newline = part.ends_with('\n');
1932            let content = if has_newline {
1933                &part[..part.len().saturating_sub(1)]
1934            } else {
1935                part
1936            };
1937
1938            if !content.is_empty() {
1939                let segment_font_size = segment_style.resolve_font_size(font_size);
1940                let Some(font) = fonts.resolve(&segment_style) else {
1941                    out.truncate(initial_len);
1942                    return None;
1943                };
1944                let local_rect = Rect {
1945                    x: (cursor_x - rect.x).round(),
1946                    y: (cursor_y - rect.y).round(),
1947                    width: rect.width,
1948                    height: rect.height,
1949                };
1950                let color = segment_style.resolve_text_color(fallback_color);
1951                let advance_px = collect_text_segment_cached_solid_atlas_placements(
1952                    content,
1953                    local_rect,
1954                    &segment_style,
1955                    color,
1956                    segment_font_size,
1957                    scale,
1958                    font,
1959                    glyph_cache,
1960                    out,
1961                )?;
1962                cursor_x += advance_px;
1963                current_line_height = current_line_height.max(line_height_for_render_style(
1964                    &segment_style,
1965                    segment_font_size,
1966                ));
1967            }
1968
1969            if has_newline {
1970                line_idx += 1;
1971                cursor_x = rect.x + line_offset(&line_offsets, line_idx);
1972                cursor_y += current_line_height * scale;
1973                current_line_height = base_line_height;
1974            }
1975        }
1976    }
1977
1978    Some(())
1979}
1980
1981#[allow(clippy::too_many_arguments)]
1982pub fn collect_solid_text_atlas_run<'a>(
1983    text: impl Into<StyledTextRef<'a>>,
1984    rect: Rect,
1985    style: &TextStyle,
1986    fallback_color: Color,
1987    font_size: f32,
1988    scale: f32,
1989    fonts: &SoftwareTextFontSet,
1990    glyph_cache: &mut SoftwareGlyphRasterCache,
1991    out: &mut Vec<SoftwareGlyphAtlasRunGlyph>,
1992) -> Option<()> {
1993    let text: StyledTextRef<'a> = text.into();
1994    if text.is_empty()
1995        || rect.width <= 0.0
1996        || rect.height <= 0.0
1997        || !font_size.is_finite()
1998        || font_size <= 0.0
1999        || !scale.is_finite()
2000        || scale <= 0.0
2001    {
2002        return Some(());
2003    }
2004
2005    let base_line_height = line_height_for_render_style(style, font_size);
2006    let mut current_line_height = base_line_height;
2007    // Each line of the block is aligned in its own right; see
2008    // `annotated_line_alignment_offsets`.
2009    let line_offsets = annotated_line_alignment_offsets(&text, style, font_size, scale, fonts);
2010    let mut line_idx = 0usize;
2011    let mut cursor_x = rect.x + line_offset(&line_offsets, 0);
2012    let mut cursor_y = rect.y;
2013    let initial_len = out.len();
2014
2015    let mut boundaries = text.span_boundaries();
2016    for (offset, ch) in text.text.char_indices() {
2017        if ch == '\n' {
2018            boundaries.push(offset);
2019            boundaries.push(offset + ch.len_utf8());
2020        }
2021    }
2022    boundaries.sort_unstable();
2023    boundaries.dedup();
2024    boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
2025
2026    for range in boundaries.windows(2) {
2027        let start = range[0];
2028        let end = range[1];
2029        if start == end {
2030            continue;
2031        }
2032        let segment_style = effective_style_for_range(text.span_styles, style, start, end);
2033        if !style_can_atlas_solid_fill(&segment_style) {
2034            out.truncate(initial_len);
2035            return None;
2036        }
2037        let static_text_motion = segment_style
2038            .paragraph_style
2039            .text_motion
2040            .unwrap_or(TextMotion::Static)
2041            == TextMotion::Static;
2042        if !static_text_motion {
2043            out.truncate(initial_len);
2044            return None;
2045        }
2046
2047        let segment = &text.text[start..end];
2048        for part in segment.split_inclusive('\n') {
2049            let has_newline = part.ends_with('\n');
2050            let content = if has_newline {
2051                &part[..part.len().saturating_sub(1)]
2052            } else {
2053                part
2054            };
2055
2056            if !content.is_empty() {
2057                let segment_font_size = segment_style.resolve_font_size(font_size);
2058                let Some(font) = fonts.resolve(&segment_style) else {
2059                    out.truncate(initial_len);
2060                    return None;
2061                };
2062                let local_rect = Rect {
2063                    x: (cursor_x - rect.x).round(),
2064                    y: (cursor_y - rect.y).round(),
2065                    width: rect.width,
2066                    height: rect.height,
2067                };
2068                let color = segment_style.resolve_text_color(fallback_color);
2069                let advance_px = collect_text_segment_solid_atlas_run(
2070                    content,
2071                    local_rect,
2072                    &segment_style,
2073                    color,
2074                    segment_font_size,
2075                    scale,
2076                    font,
2077                    glyph_cache,
2078                    out,
2079                )?;
2080                cursor_x += advance_px;
2081                current_line_height = current_line_height.max(line_height_for_render_style(
2082                    &segment_style,
2083                    segment_font_size,
2084                ));
2085            }
2086
2087            if has_newline {
2088                line_idx += 1;
2089                cursor_x = rect.x + line_offset(&line_offsets, line_idx);
2090                cursor_y += current_line_height * scale;
2091                current_line_height = base_line_height;
2092            }
2093        }
2094    }
2095
2096    Some(())
2097}
2098
2099pub fn measure_text_with_font(
2100    text: &str,
2101    style: &TextStyle,
2102    font_size: f32,
2103    font: &SoftwareTextFont,
2104) -> TextMetrics {
2105    measure_text_impl(
2106        text,
2107        style,
2108        font_size,
2109        font.ab_glyph_px_size(font_size),
2110        &font.font,
2111        font.style(),
2112        font.weight(),
2113    )
2114}
2115
2116fn measure_text_with_font_cached(
2117    text: &str,
2118    style: &TextStyle,
2119    font_size: f32,
2120    font: &SoftwareTextFont,
2121    cache: &mut SoftwareTextMetricsCache,
2122) -> TextMetrics {
2123    measure_text_impl_cached(text, style, font_size, font, cache)
2124}
2125
2126pub fn measure_annotated_text_with_font(
2127    text: &AnnotatedString,
2128    style: &TextStyle,
2129    font_size: f32,
2130    font: &SoftwareTextFont,
2131) -> TextMetrics {
2132    if text.span_styles.is_empty() {
2133        return measure_text_with_font(text.text.as_str(), style, font_size, font);
2134    }
2135    measure_annotated_text_with_resolver(
2136        text,
2137        style,
2138        font_size,
2139        &SoftwareTextFontSet::from_font(font.clone()),
2140        None,
2141    )
2142}
2143
2144pub fn measure_annotated_text_with_font_set(
2145    text: &AnnotatedString,
2146    style: &TextStyle,
2147    font_size: f32,
2148    fonts: &SoftwareTextFontSet,
2149) -> TextMetrics {
2150    if text.span_styles.is_empty() {
2151        if let Some(font) = fonts.resolve(style) {
2152            return measure_text_with_font(text.text.as_str(), style, font_size, font);
2153        }
2154        return fallback_text_metrics(text.text.as_str(), style, font_size);
2155    }
2156    measure_annotated_text_with_resolver(text, style, font_size, fonts, None)
2157}
2158
2159fn measure_annotated_text_with_font_set_cached(
2160    text: &AnnotatedString,
2161    style: &TextStyle,
2162    font_size: f32,
2163    fonts: &SoftwareTextFontSet,
2164    cache: &mut SoftwareTextMetricsCache,
2165) -> TextMetrics {
2166    if text.span_styles.is_empty() {
2167        if let Some(font) = fonts.resolve(style) {
2168            return measure_text_with_font_cached(
2169                text.text.as_str(),
2170                style,
2171                font_size,
2172                font,
2173                cache,
2174            );
2175        }
2176        return fallback_text_metrics(text.text.as_str(), style, font_size);
2177    }
2178    measure_annotated_text_with_resolver(text, style, font_size, fonts, Some(cache))
2179}
2180
2181pub fn text_offset_for_position_with_font(
2182    text: &str,
2183    style: &TextStyle,
2184    x: f32,
2185    y: f32,
2186    font: &SoftwareTextFont,
2187) -> usize {
2188    if text.is_empty() {
2189        return 0;
2190    }
2191
2192    let font_size = resolve_font_size(style);
2193    let glyph_font_size = font.ab_glyph_px_size(font_size);
2194    let line_height = resolve_line_height(style, font_size * 1.4);
2195
2196    let line_index = (y / line_height).floor().max(0.0) as usize;
2197    let lines: Vec<&str> = text.split('\n').collect();
2198    let target_line = line_index.min(lines.len().saturating_sub(1));
2199
2200    let mut line_start_byte = 0;
2201    for line in lines.iter().take(target_line) {
2202        line_start_byte += line.len() + 1;
2203    }
2204
2205    let line_text = lines.get(target_line).unwrap_or(&"");
2206    if line_text.is_empty() {
2207        return line_start_byte;
2208    }
2209
2210    let mut best_offset = 0;
2211    let mut best_distance = f32::INFINITY;
2212    let mut current_byte_offset = 0;
2213
2214    for c in line_text.chars() {
2215        let prefix = &line_text[..current_byte_offset];
2216        let glyph_x = measure_text_impl(
2217            prefix,
2218            style,
2219            font_size,
2220            glyph_font_size,
2221            &font.font,
2222            font.style(),
2223            font.weight(),
2224        )
2225        .width;
2226
2227        let char_str = &line_text[current_byte_offset..current_byte_offset + c.len_utf8()];
2228        let char_width = measure_text_impl(
2229            char_str,
2230            style,
2231            font_size,
2232            glyph_font_size,
2233            &font.font,
2234            font.style(),
2235            font.weight(),
2236        )
2237        .width
2238        .max(font_size * 0.5);
2239
2240        let left_dist = (x - glyph_x).abs();
2241        if left_dist < best_distance {
2242            best_distance = left_dist;
2243            best_offset = current_byte_offset;
2244        }
2245
2246        let right_x = glyph_x + char_width;
2247        let right_dist = (x - right_x).abs();
2248        if right_dist < best_distance {
2249            best_distance = right_dist;
2250            best_offset = current_byte_offset + c.len_utf8();
2251        }
2252
2253        current_byte_offset += c.len_utf8();
2254    }
2255
2256    let total_width = measure_text_impl(
2257        line_text,
2258        style,
2259        font_size,
2260        glyph_font_size,
2261        &font.font,
2262        font.style(),
2263        font.weight(),
2264    )
2265    .width;
2266    let end_dist = (x - total_width).abs();
2267    if end_dist < best_distance {
2268        best_offset = line_text.len();
2269    }
2270
2271    line_start_byte + best_offset.min(line_text.len())
2272}
2273
2274pub fn cursor_x_for_offset_with_font(
2275    text: &str,
2276    style: &TextStyle,
2277    offset: usize,
2278    font: &SoftwareTextFont,
2279) -> f32 {
2280    let clamped_offset = clamp_to_char_boundary(text, offset.min(text.len()));
2281    if clamped_offset == 0 {
2282        return 0.0;
2283    }
2284
2285    let font_size = resolve_font_size(style);
2286    measure_text_impl(
2287        &text[..clamped_offset],
2288        style,
2289        font_size,
2290        font.ab_glyph_px_size(font_size),
2291        &font.font,
2292        font.style(),
2293        font.weight(),
2294    )
2295    .width
2296}
2297
2298pub fn layout_text_with_font(
2299    text: &str,
2300    style: &TextStyle,
2301    font: &SoftwareTextFont,
2302) -> TextLayoutResult {
2303    let font_size = resolve_font_size(style);
2304    let glyph_font_size = font.ab_glyph_px_size(font_size);
2305    let resolved_weight = font.weight();
2306    let resolved_style = font.style();
2307    let weight_synthesis = TextWeightSynthesis::for_style(style, resolved_weight, font_size, 1.0);
2308    let font = &font.font;
2309    let line_height = resolve_line_height(style, font_size * 1.4);
2310    let letter_spacing = resolve_letter_spacing(style, font_size);
2311    let scaled_font = font.as_scaled(PxScale::from(glyph_font_size));
2312
2313    let mut glyph_x_positions = Vec::new();
2314    let mut char_to_byte = Vec::new();
2315    let mut glyph_layouts = Vec::new();
2316    let mut lines = Vec::new();
2317    let mut current_x = 0.0f32;
2318    let mut line_start = 0;
2319    let mut y = 0.0f32;
2320
2321    let mut iter = text.char_indices().peekable();
2322    while let Some((byte_offset, c)) = iter.next() {
2323        glyph_x_positions.push(current_x);
2324        char_to_byte.push(byte_offset);
2325
2326        if c == '\n' {
2327            lines.push(LineLayout {
2328                start_offset: line_start,
2329                end_offset: byte_offset,
2330                y,
2331                height: line_height,
2332            });
2333            line_start = byte_offset + 1;
2334            y += line_height;
2335            current_x = 0.0;
2336        } else {
2337            let glyph_id = scaled_font.glyph_id(c);
2338            let glyph_width =
2339                weight_synthesis.apply_width(scaled_font.h_advance(glyph_id).max(0.0));
2340            let glyph_end = byte_offset + c.len_utf8();
2341            if glyph_end > byte_offset {
2342                glyph_layouts.push(GlyphLayout {
2343                    line_index: lines.len(),
2344                    start_offset: byte_offset,
2345                    end_offset: glyph_end,
2346                    x: current_x,
2347                    y,
2348                    width: glyph_width,
2349                    height: line_height,
2350                });
2351            }
2352            current_x += glyph_width;
2353            if let Some((_, next)) = iter.peek() {
2354                if *next != '\n' {
2355                    current_x += letter_spacing;
2356                }
2357            }
2358        }
2359    }
2360
2361    glyph_x_positions.push(current_x);
2362    char_to_byte.push(text.len());
2363
2364    lines.push(LineLayout {
2365        start_offset: line_start,
2366        end_offset: text.len(),
2367        y,
2368        height: line_height,
2369    });
2370
2371    let metrics = measure_text_impl(
2372        text,
2373        style,
2374        font_size,
2375        glyph_font_size,
2376        font,
2377        resolved_style,
2378        resolved_weight,
2379    );
2380    TextLayoutResult::new(
2381        text,
2382        TextLayoutData {
2383            width: metrics.width,
2384            height: metrics.height,
2385            line_height,
2386            glyph_x_positions,
2387            char_to_byte,
2388            lines,
2389            glyph_layouts,
2390        },
2391    )
2392}
2393
2394pub fn rasterize_text_to_image_with_font(
2395    text: &str,
2396    rect: Rect,
2397    style: &TextStyle,
2398    fallback_color: Color,
2399    font_size: f32,
2400    scale: f32,
2401    font: &impl Font,
2402) -> Option<ImageBitmap> {
2403    rasterize_text_to_image_impl(
2404        TextRasterImageRequest {
2405            text,
2406            rect,
2407            style,
2408            fallback_color,
2409            font_size,
2410            scale,
2411        },
2412        RasterFontRef {
2413            font,
2414            ab_glyph_scale_factor: 1.0,
2415            weight: FontWeight::NORMAL,
2416            style: FontStyle::Normal,
2417        },
2418        0,
2419        None,
2420    )
2421}
2422
2423struct TextRasterImageRequest<'a> {
2424    text: &'a str,
2425    rect: Rect,
2426    style: &'a TextStyle,
2427    fallback_color: Color,
2428    font_size: f32,
2429    scale: f32,
2430}
2431
2432fn rasterize_text_to_image_impl(
2433    request: TextRasterImageRequest<'_>,
2434    font_ref: RasterFontRef<'_, impl Font>,
2435    font_cache_key: u64,
2436    mut glyph_cache: Option<&mut SoftwareGlyphRasterCache>,
2437) -> Option<ImageBitmap> {
2438    let TextRasterImageRequest {
2439        text,
2440        rect,
2441        style,
2442        fallback_color,
2443        font_size,
2444        scale,
2445    } = request;
2446
2447    if text.is_empty()
2448        || rect.width <= 0.0
2449        || rect.height <= 0.0
2450        || !font_size.is_finite()
2451        || font_size <= 0.0
2452        || !scale.is_finite()
2453        || scale <= 0.0
2454    {
2455        return None;
2456    }
2457
2458    let width = rect.width.ceil().max(1.0) as u32;
2459    let height = rect.height.ceil().max(1.0) as u32;
2460
2461    let fallback_brush = Brush::solid(fallback_color);
2462    let (brush, brush_alpha_multiplier) = match style.span_style.brush.as_ref() {
2463        Some(brush) => (brush, style.span_style.alpha.unwrap_or(1.0).clamp(0.0, 1.0)),
2464        None => (&fallback_brush, 1.0),
2465    };
2466    let raster_style = match style.span_style.draw_style.unwrap_or(TextDrawStyle::Fill) {
2467        TextDrawStyle::Fill => GlyphRasterStyle::Fill,
2468        TextDrawStyle::Stroke { width } => {
2469            if width.is_finite() && width > 0.0 {
2470                GlyphRasterStyle::Stroke {
2471                    width_px: width * scale,
2472                }
2473            } else {
2474                GlyphRasterStyle::Fill
2475            }
2476        }
2477    };
2478    let shadow = style
2479        .span_style
2480        .shadow
2481        .filter(|shadow| shadow.color.3 > 0.0);
2482    let static_text_motion = style
2483        .paragraph_style
2484        .text_motion
2485        .unwrap_or(TextMotion::Static)
2486        == TextMotion::Static;
2487
2488    let origin_x = if static_text_motion {
2489        0.0
2490    } else {
2491        rect.x.fract()
2492    };
2493    let origin_y = if static_text_motion {
2494        0.0
2495    } else {
2496        rect.y.fract()
2497    };
2498
2499    let font = font_ref.font;
2500    let font_px_size = font_size * scale * font_ref.ab_glyph_scale_factor;
2501    let letter_spacing = resolve_letter_spacing(style, font_size) * scale;
2502    let align_fraction = crate::scene_builder::text_align_fraction(style, text);
2503    let weight_synthesis = TextWeightSynthesis::for_style(style, font_ref.weight, font_size, scale);
2504    let style_synthesis = TextStyleSynthesis::for_style(style, font_ref.style, font_size, scale);
2505    let metrics = vertical_metrics(font, font_px_size);
2506    // Everything here is already in raster device pixels, so the line
2507    // box rounds on a grid of 1.
2508    let line_box = line_box_for(
2509        style,
2510        metrics,
2511        (style.resolve_line_height(14.0, font_size * 1.4) * scale).max(1.0),
2512        1.0,
2513    );
2514    let line_height = line_box.height;
2515    let first_baseline_y = line_box.baseline;
2516
2517    if let Brush::Solid(color) = brush {
2518        if shadow.is_none() {
2519            let color = color_to_rgba(*color);
2520            let mut rgba = vec![0u8; (width * height * 4) as usize];
2521            visit_text_glyph_masks(
2522                text,
2523                font,
2524                font_cache_key,
2525                font_px_size,
2526                line_height,
2527                first_baseline_y,
2528                origin_x,
2529                origin_y,
2530                letter_spacing,
2531                align_fraction,
2532                static_text_motion,
2533                raster_style,
2534                weight_synthesis,
2535                style_synthesis,
2536                glyph_cache.as_deref_mut(),
2537                |mask| {
2538                    draw_mask_glyph_solid_u8(
2539                        &mut rgba,
2540                        width,
2541                        height,
2542                        mask,
2543                        color,
2544                        brush_alpha_multiplier,
2545                    );
2546                },
2547            );
2548
2549            return ImageBitmap::from_rgba8(width, height, rgba).ok();
2550        }
2551    }
2552
2553    let mut canvas = vec![[0.0f32; 4]; (width * height) as usize];
2554    visit_text_glyph_masks(
2555        text,
2556        font,
2557        font_cache_key,
2558        font_px_size,
2559        line_height,
2560        first_baseline_y,
2561        origin_x,
2562        origin_y,
2563        letter_spacing,
2564        align_fraction,
2565        static_text_motion,
2566        raster_style,
2567        weight_synthesis,
2568        style_synthesis,
2569        glyph_cache,
2570        |mask| {
2571            if let Some(shadow) = shadow {
2572                draw_shadow_mask(
2573                    &mut canvas,
2574                    width,
2575                    height,
2576                    mask,
2577                    shadow,
2578                    scale,
2579                    static_text_motion,
2580                );
2581            }
2582
2583            draw_mask_glyph(
2584                &mut canvas,
2585                width,
2586                height,
2587                mask,
2588                brush,
2589                brush_alpha_multiplier,
2590                rect,
2591            );
2592        },
2593    );
2594
2595    let mut rgba = vec![0u8; canvas.len() * 4];
2596    for (index, pixel) in canvas.iter().enumerate() {
2597        let base = index * 4;
2598        rgba[base] = (pixel[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2599        rgba[base + 1] = (pixel[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2600        rgba[base + 2] = (pixel[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2601        rgba[base + 3] = (pixel[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2602    }
2603
2604    ImageBitmap::from_rgba8(width, height, rgba).ok()
2605}
2606
2607fn style_can_rasterize_direct_solid(style: &TextStyle) -> bool {
2608    if style
2609        .span_style
2610        .shadow
2611        .is_some_and(|shadow| shadow.color.3 > 0.0)
2612    {
2613        return false;
2614    }
2615    matches!(
2616        style.span_style.brush.as_ref(),
2617        None | Some(Brush::Solid(_))
2618    )
2619}
2620
2621fn style_can_atlas_solid_fill(style: &TextStyle) -> bool {
2622    if style
2623        .span_style
2624        .shadow
2625        .is_some_and(|shadow| shadow.color.3 > 0.0)
2626    {
2627        return false;
2628    }
2629    if !matches!(
2630        style.span_style.brush.as_ref(),
2631        None | Some(Brush::Solid(_))
2632    ) {
2633        return false;
2634    }
2635    match style.span_style.draw_style.unwrap_or(TextDrawStyle::Fill) {
2636        TextDrawStyle::Fill => true,
2637        TextDrawStyle::Stroke { width } => !width.is_finite() || width <= 0.0,
2638    }
2639}
2640
2641#[allow(clippy::too_many_arguments)]
2642fn draw_text_segment_solid_to_rgba(
2643    canvas: &mut [u8],
2644    canvas_width: u32,
2645    canvas_height: u32,
2646    text: &str,
2647    local_rect: Rect,
2648    style: &TextStyle,
2649    color: Color,
2650    font_size: f32,
2651    scale: f32,
2652    font: &SoftwareTextFont,
2653    glyph_cache: &mut SoftwareGlyphRasterCache,
2654) -> f32 {
2655    if text.is_empty()
2656        || local_rect.width <= 0.0
2657        || local_rect.height <= 0.0
2658        || !font_size.is_finite()
2659        || font_size <= 0.0
2660        || !scale.is_finite()
2661        || scale <= 0.0
2662    {
2663        return 0.0;
2664    }
2665
2666    let raster_style = match style.span_style.draw_style.unwrap_or(TextDrawStyle::Fill) {
2667        TextDrawStyle::Fill => GlyphRasterStyle::Fill,
2668        TextDrawStyle::Stroke { width } => {
2669            if width.is_finite() && width > 0.0 {
2670                GlyphRasterStyle::Stroke {
2671                    width_px: width * scale,
2672                }
2673            } else {
2674                GlyphRasterStyle::Fill
2675            }
2676        }
2677    };
2678    let text_motion_static = style
2679        .paragraph_style
2680        .text_motion
2681        .unwrap_or(TextMotion::Static)
2682        == TextMotion::Static;
2683    let font_px_size = font.ab_glyph_px_size(font_size) * scale;
2684    let letter_spacing = resolve_letter_spacing(style, font_size) * scale;
2685    let align_fraction = crate::scene_builder::text_align_fraction(style, text);
2686    let weight_synthesis = TextWeightSynthesis::for_style(style, font.weight(), font_size, scale);
2687    let style_synthesis = TextStyleSynthesis::for_style(style, font.style(), font_size, scale);
2688    let metrics = vertical_metrics(&font.font, font_px_size);
2689    // Everything here is already in raster device pixels, so the line
2690    // box rounds on a grid of 1.
2691    let line_box = line_box_for(
2692        style,
2693        metrics,
2694        (style.resolve_line_height(14.0, font_size * 1.4) * scale).max(1.0),
2695        1.0,
2696    );
2697    let line_height = line_box.height;
2698    let first_baseline_y = local_rect.y + line_box.baseline;
2699    let origin_x = if text_motion_static {
2700        local_rect.x.round()
2701    } else {
2702        local_rect.x + local_rect.x.fract()
2703    };
2704    let color = color_to_rgba(color);
2705
2706    visit_text_glyph_masks(
2707        text,
2708        &font.font,
2709        font.content_hash(),
2710        font_px_size,
2711        line_height,
2712        first_baseline_y,
2713        origin_x,
2714        0.0,
2715        letter_spacing,
2716        align_fraction,
2717        text_motion_static,
2718        raster_style,
2719        weight_synthesis,
2720        style_synthesis,
2721        Some(glyph_cache),
2722        |mask| draw_mask_glyph_solid_u8(canvas, canvas_width, canvas_height, mask, color, 1.0),
2723    )
2724}
2725
2726#[allow(clippy::too_many_arguments)]
2727fn collect_text_segment_solid_atlas_glyphs(
2728    text: &str,
2729    local_rect: Rect,
2730    style: &TextStyle,
2731    color: Color,
2732    font_size: f32,
2733    scale: f32,
2734    font: &SoftwareTextFont,
2735    glyph_cache: &mut SoftwareGlyphRasterCache,
2736    out: &mut Vec<SoftwareGlyphAtlasGlyph>,
2737) -> Option<f32> {
2738    if text.is_empty()
2739        || local_rect.width <= 0.0
2740        || local_rect.height <= 0.0
2741        || !font_size.is_finite()
2742        || font_size <= 0.0
2743        || !scale.is_finite()
2744        || scale <= 0.0
2745    {
2746        return Some(0.0);
2747    }
2748    if !style_can_atlas_solid_fill(style) {
2749        return None;
2750    }
2751
2752    let text_motion_static = style
2753        .paragraph_style
2754        .text_motion
2755        .unwrap_or(TextMotion::Static)
2756        == TextMotion::Static;
2757    if !text_motion_static {
2758        return None;
2759    }
2760
2761    let font_px_size = font.ab_glyph_px_size(font_size) * scale;
2762    let letter_spacing = resolve_letter_spacing(style, font_size) * scale;
2763    let align_fraction = crate::scene_builder::text_align_fraction(style, text);
2764    let weight_synthesis = TextWeightSynthesis::for_style(style, font.weight(), font_size, scale);
2765    let style_synthesis = TextStyleSynthesis::for_style(style, font.style(), font_size, scale);
2766    let metrics = vertical_metrics(&font.font, font_px_size);
2767    // Everything here is already in raster device pixels, so the line
2768    // box rounds on a grid of 1.
2769    let line_box = line_box_for(
2770        style,
2771        metrics,
2772        (style.resolve_line_height(14.0, font_size * 1.4) * scale).max(1.0),
2773        1.0,
2774    );
2775    let line_height = line_box.height;
2776    let first_baseline_y = local_rect.y + line_box.baseline;
2777    let origin_x = local_rect.x.round();
2778    let initial_len = out.len();
2779
2780    let advance = visit_text_glyph_masks_with_key(
2781        text,
2782        &font.font,
2783        font.content_hash(),
2784        font_px_size,
2785        line_height,
2786        first_baseline_y,
2787        origin_x,
2788        0.0,
2789        letter_spacing,
2790        align_fraction,
2791        true,
2792        GlyphRasterStyle::Fill,
2793        weight_synthesis,
2794        style_synthesis,
2795        Some(glyph_cache),
2796        |key, mask| {
2797            if mask.width == 0 || mask.height == 0 {
2798                return;
2799            }
2800            out.push(SoftwareGlyphAtlasGlyph {
2801                key,
2802                mask: SoftwareGlyphAtlasMask {
2803                    alpha: Arc::clone(&mask.alpha),
2804                    width: mask.width,
2805                    height: mask.height,
2806                },
2807                x: mask.origin_x,
2808                y: mask.origin_y,
2809                color,
2810            });
2811        },
2812    );
2813
2814    if advance.is_finite() {
2815        Some(advance)
2816    } else {
2817        out.truncate(initial_len);
2818        None
2819    }
2820}
2821
2822#[allow(clippy::too_many_arguments)]
2823fn collect_text_segment_cached_solid_atlas_placements(
2824    text: &str,
2825    local_rect: Rect,
2826    style: &TextStyle,
2827    color: Color,
2828    font_size: f32,
2829    scale: f32,
2830    font: &SoftwareTextFont,
2831    glyph_cache: &mut SoftwareGlyphRasterCache,
2832    out: &mut Vec<SoftwareGlyphAtlasPlacement>,
2833) -> Option<f32> {
2834    if text.is_empty()
2835        || local_rect.width <= 0.0
2836        || local_rect.height <= 0.0
2837        || !font_size.is_finite()
2838        || font_size <= 0.0
2839        || !scale.is_finite()
2840        || scale <= 0.0
2841    {
2842        return Some(0.0);
2843    }
2844    if !style_can_atlas_solid_fill(style) {
2845        return None;
2846    }
2847
2848    let text_motion_static = style
2849        .paragraph_style
2850        .text_motion
2851        .unwrap_or(TextMotion::Static)
2852        == TextMotion::Static;
2853    if !text_motion_static {
2854        return None;
2855    }
2856
2857    let font_px_size = font.ab_glyph_px_size(font_size) * scale;
2858    let letter_spacing = resolve_letter_spacing(style, font_size) * scale;
2859    let align_fraction = crate::scene_builder::text_align_fraction(style, text);
2860    let weight_synthesis = TextWeightSynthesis::for_style(style, font.weight(), font_size, scale);
2861    let style_synthesis = TextStyleSynthesis::for_style(style, font.style(), font_size, scale);
2862    let metrics = vertical_metrics(&font.font, font_px_size);
2863    // Everything here is already in raster device pixels, so the line
2864    // box rounds on a grid of 1.
2865    let line_box = line_box_for(
2866        style,
2867        metrics,
2868        (style.resolve_line_height(14.0, font_size * 1.4) * scale).max(1.0),
2869        1.0,
2870    );
2871    let line_height = line_box.height;
2872    let first_baseline_y = local_rect.y + line_box.baseline;
2873    let origin_x = local_rect.x.round();
2874    let initial_len = out.len();
2875
2876    let advance = visit_cached_text_glyph_atlas_placements(
2877        text,
2878        &font.font,
2879        font.content_hash(),
2880        font_px_size,
2881        line_height,
2882        first_baseline_y,
2883        origin_x,
2884        0.0,
2885        letter_spacing,
2886        align_fraction,
2887        GlyphRasterStyle::Fill,
2888        weight_synthesis,
2889        style_synthesis,
2890        glyph_cache,
2891        |placement| {
2892            if placement.width == 0 || placement.height == 0 {
2893                return;
2894            }
2895            out.push(SoftwareGlyphAtlasPlacement { color, ..placement });
2896        },
2897    );
2898
2899    if advance.is_finite() {
2900        Some(advance)
2901    } else {
2902        out.truncate(initial_len);
2903        None
2904    }
2905}
2906
2907#[allow(clippy::too_many_arguments)]
2908fn collect_text_segment_solid_atlas_run(
2909    text: &str,
2910    local_rect: Rect,
2911    style: &TextStyle,
2912    color: Color,
2913    font_size: f32,
2914    scale: f32,
2915    font: &SoftwareTextFont,
2916    glyph_cache: &mut SoftwareGlyphRasterCache,
2917    out: &mut Vec<SoftwareGlyphAtlasRunGlyph>,
2918) -> Option<f32> {
2919    if text.is_empty()
2920        || local_rect.width <= 0.0
2921        || local_rect.height <= 0.0
2922        || !font_size.is_finite()
2923        || font_size <= 0.0
2924        || !scale.is_finite()
2925        || scale <= 0.0
2926    {
2927        return Some(0.0);
2928    }
2929    if !style_can_atlas_solid_fill(style) {
2930        return None;
2931    }
2932
2933    let text_motion_static = style
2934        .paragraph_style
2935        .text_motion
2936        .unwrap_or(TextMotion::Static)
2937        == TextMotion::Static;
2938    if !text_motion_static {
2939        return None;
2940    }
2941
2942    let font_px_size = font.ab_glyph_px_size(font_size) * scale;
2943    let letter_spacing = resolve_letter_spacing(style, font_size) * scale;
2944    let align_fraction = crate::scene_builder::text_align_fraction(style, text);
2945    let weight_synthesis = TextWeightSynthesis::for_style(style, font.weight(), font_size, scale);
2946    let style_synthesis = TextStyleSynthesis::for_style(style, font.style(), font_size, scale);
2947    let metrics = vertical_metrics(&font.font, font_px_size);
2948    // Everything here is already in raster device pixels, so the line
2949    // box rounds on a grid of 1.
2950    let line_box = line_box_for(
2951        style,
2952        metrics,
2953        (style.resolve_line_height(14.0, font_size * 1.4) * scale).max(1.0),
2954        1.0,
2955    );
2956    let line_height = line_box.height;
2957    let first_baseline_y = local_rect.y + line_box.baseline;
2958    let origin_x = local_rect.x.round();
2959    let initial_len = out.len();
2960
2961    let advance = visit_text_glyph_atlas_run(
2962        text,
2963        &font.font,
2964        font.content_hash(),
2965        font_px_size,
2966        line_height,
2967        first_baseline_y,
2968        origin_x,
2969        0.0,
2970        letter_spacing,
2971        align_fraction,
2972        GlyphRasterStyle::Fill,
2973        weight_synthesis,
2974        style_synthesis,
2975        glyph_cache,
2976        |run_glyph| {
2977            let run_glyph = match run_glyph {
2978                SoftwareGlyphAtlasRunGlyph::Cached(mut placement) => {
2979                    if placement.width == 0 || placement.height == 0 {
2980                        return;
2981                    }
2982                    placement.color = color;
2983                    SoftwareGlyphAtlasRunGlyph::Cached(placement)
2984                }
2985                SoftwareGlyphAtlasRunGlyph::New(mut glyph) => {
2986                    if glyph.mask.width == 0 || glyph.mask.height == 0 {
2987                        return;
2988                    }
2989                    glyph.color = color;
2990                    SoftwareGlyphAtlasRunGlyph::New(glyph)
2991                }
2992            };
2993            out.push(run_glyph);
2994        },
2995    );
2996
2997    if advance.is_finite() {
2998        Some(advance)
2999    } else {
3000        out.truncate(initial_len);
3001        None
3002    }
3003}
3004
3005fn resolve_font_size(style: &TextStyle) -> f32 {
3006    style.resolve_font_size(14.0)
3007}
3008
3009/// The line box a style asks for, in the unit `metrics` and `line_height` are
3010/// already in.
3011///
3012/// `grid` is how many device pixels one of those units is worth — `1.0` on the
3013/// raster paths, where everything has already been multiplied by the render
3014/// scale, and the density on the measure paths, which work in layout points.
3015///
3016/// A style that leaves `line_height_style` unset — every style in the framework
3017/// but the Wear ones — comes back through the same arithmetic this function
3018/// used before it took a style at all.
3019fn line_box_for(
3020    style: &TextStyle,
3021    metrics: crate::font_layout::FontVerticalMetrics,
3022    line_height: f32,
3023    grid: f32,
3024) -> cranpose_ui::text::LineBox {
3025    cranpose_ui::text::line_box(
3026        style,
3027        cranpose_ui::text::FontExtent::new(metrics.ascent, -metrics.descent, metrics.line_gap),
3028        line_height,
3029        grid,
3030    )
3031}
3032
3033/// The device grid a measurement in layout points rounds on.
3034///
3035/// A measurer runs inside an app context in the running app, but the offline
3036/// raster paths and this file's own tests call it without one, and asking for
3037/// the ambient density there is a panic rather than a default. Those callers
3038/// are all working at one pixel per point by definition.
3039fn measure_grid() -> f32 {
3040    if cranpose_ui::has_current_app_context() {
3041        cranpose_ui::current_density()
3042    } else {
3043        1.0
3044    }
3045}
3046
3047fn resolve_line_height(style: &TextStyle, font_size: f32) -> f32 {
3048    style.resolve_line_height(14.0, font_size)
3049}
3050
3051fn line_height_for_render_style(style: &TextStyle, font_size: f32) -> f32 {
3052    resolve_line_height(style, font_size * 1.4).max(1.0)
3053}
3054
3055fn resolve_letter_spacing(style: &TextStyle, font_size: f32) -> f32 {
3056    let _ = font_size;
3057    style.resolve_letter_spacing(14.0)
3058}
3059
3060/// How much width `char_count` characters of tracking add to a run.
3061///
3062/// **A run of `n` characters carries `n` letter spaces, not `n - 1`.** Android
3063/// resolves `letterSpacing` in Minikin, which distributes it as HALF a letter
3064/// space on each side of every cluster: `LayoutCore.cpp` adds
3065/// `letterSpaceHalf` to the pen before the first glyph of a script run, a full
3066/// `letterSpace` at each cluster boundary, and `letterSpaceHalf` again after
3067/// the last glyph. Every character therefore ends up carrying exactly one
3068/// letter space in `mAdvances[]`, which is what `StaticLayout` sums to get a
3069/// line's width. (Compose reaches that code by putting the value on the paint:
3070/// `LetterSpacingSpanPx` divides the px value by `textSize * textScaleX` and
3071/// assigns `TextPaint.letterSpacing`, and `TextPaintExtensions.android.kt`
3072/// does the same for a paragraph-level `Sp` tracking.)
3073///
3074/// The trailing half is real on the platform this is measured against — the
3075/// `sdk_gwear` emulator runs Android 14, whose `Layout.cpp` has no letter
3076/// spacing code at all. The edge-trimming that removes the two half spaces
3077/// (`adjustAdvanceLetterSpacingEdge`) only arrives in Android 15, and is
3078/// opt-in per run there.
3079///
3080/// An empty run carries none: the loop that adds them never runs.
3081fn run_tracking(char_count: usize, letter_spacing: f32) -> f32 {
3082    char_count as f32 * letter_spacing
3083}
3084
3085/// The lead-in before a run's first glyph: half a letter space, or nothing at
3086/// all for an empty run. See [`run_tracking`].
3087fn run_lead_in(char_count: usize, letter_spacing: f32) -> f32 {
3088    if char_count == 0 {
3089        0.0
3090    } else {
3091        letter_spacing * 0.5
3092    }
3093}
3094
3095fn fallback_char_width(font_size: f32) -> f32 {
3096    font_size.max(1.0) * 0.55
3097}
3098
3099fn fallback_line_height(style: &TextStyle, font_size: f32) -> f32 {
3100    resolve_line_height(style, font_size.max(1.0) * 1.2)
3101}
3102
3103fn fallback_line_heights(text: &str, style: &TextStyle, font_size: f32) -> Vec<f32> {
3104    let line_count = text.split('\n').count().max(1);
3105    vec![fallback_line_height(style, font_size); line_count]
3106}
3107
3108fn fallback_text_metrics(text: &str, style: &TextStyle, font_size: f32) -> TextMetrics {
3109    let line_height = fallback_line_height(style, font_size);
3110    let char_width = fallback_char_width(font_size);
3111    let letter_spacing = resolve_letter_spacing(style, font_size);
3112    let mut line_count = 0usize;
3113    let mut max_width = 0.0f32;
3114
3115    for line in text.split('\n') {
3116        line_count += 1;
3117        let char_count = line.chars().count();
3118        let spacing = run_tracking(char_count, letter_spacing);
3119        max_width = max_width.max(char_count as f32 * char_width + spacing);
3120    }
3121
3122    let line_count = line_count.max(1);
3123    TextMetrics {
3124        width: max_width,
3125        height: line_count as f32 * line_height,
3126        line_height,
3127        line_count,
3128    }
3129}
3130
3131fn fallback_cursor_x_for_offset(text: &str, style: &TextStyle, offset: usize) -> f32 {
3132    let font_size = resolve_font_size(style);
3133    let clamped = clamp_to_char_boundary(text, offset.min(text.len()));
3134    let line_start = text[..clamped].rfind('\n').map_or(0, |index| index + 1);
3135    let char_count = text[line_start..clamped].chars().count();
3136    // The caret after `k` characters sits at the sum of their advances, and
3137    // every character's advance carries a whole letter space (`run_tracking`).
3138    let spacing = run_tracking(char_count, resolve_letter_spacing(style, font_size));
3139    char_count as f32 * fallback_char_width(font_size) + spacing
3140}
3141
3142fn fallback_text_offset_for_position(text: &str, style: &TextStyle, x: f32, y: f32) -> usize {
3143    if text.is_empty() {
3144        return 0;
3145    }
3146
3147    let font_size = resolve_font_size(style);
3148    let line_height = fallback_line_height(style, font_size);
3149    let line_index = (y / line_height).floor().max(0.0) as usize;
3150    let lines: Vec<&str> = text.split('\n').collect();
3151    let target_line = line_index.min(lines.len().saturating_sub(1));
3152
3153    let mut line_start_byte = 0;
3154    for line in lines.iter().take(target_line) {
3155        line_start_byte += line.len() + 1;
3156    }
3157
3158    let line_text = lines.get(target_line).copied().unwrap_or("");
3159    if line_text.is_empty() {
3160        return line_start_byte;
3161    }
3162
3163    let advance =
3164        (fallback_char_width(font_size) + resolve_letter_spacing(style, font_size)).max(1.0);
3165    let target_char = (x / advance).round().max(0.0) as usize;
3166    line_start_byte + byte_offset_for_char_index(line_text, target_char)
3167}
3168
3169fn fallback_layout_text(text: &str, style: &TextStyle) -> TextLayoutResult {
3170    let font_size = resolve_font_size(style);
3171    let line_height = fallback_line_height(style, font_size);
3172    let char_width = fallback_char_width(font_size);
3173    let letter_spacing = resolve_letter_spacing(style, font_size);
3174
3175    let mut glyph_x_positions = Vec::new();
3176    let mut char_to_byte = Vec::new();
3177    let mut glyph_layouts = Vec::new();
3178    let mut lines = Vec::new();
3179    let mut current_x = 0.0f32;
3180    let mut line_start = 0;
3181    let mut y = 0.0f32;
3182
3183    let mut iter = text.char_indices().peekable();
3184    while let Some((byte_offset, ch)) = iter.next() {
3185        glyph_x_positions.push(current_x);
3186        char_to_byte.push(byte_offset);
3187
3188        if ch == '\n' {
3189            lines.push(LineLayout {
3190                start_offset: line_start,
3191                end_offset: byte_offset,
3192                y,
3193                height: line_height,
3194            });
3195            line_start = byte_offset + 1;
3196            y += line_height;
3197            current_x = 0.0;
3198        } else {
3199            glyph_layouts.push(GlyphLayout {
3200                line_index: lines.len(),
3201                start_offset: byte_offset,
3202                end_offset: byte_offset + ch.len_utf8(),
3203                x: current_x,
3204                y,
3205                width: char_width,
3206                height: line_height,
3207            });
3208            current_x += char_width;
3209            if let Some((_, next)) = iter.peek() {
3210                if *next != '\n' {
3211                    current_x += letter_spacing;
3212                }
3213            }
3214        }
3215    }
3216
3217    glyph_x_positions.push(current_x);
3218    char_to_byte.push(text.len());
3219    lines.push(LineLayout {
3220        start_offset: line_start,
3221        end_offset: text.len(),
3222        y,
3223        height: line_height,
3224    });
3225
3226    let metrics = fallback_text_metrics(text, style, font_size);
3227    TextLayoutResult::new(
3228        text,
3229        TextLayoutData {
3230            width: metrics.width,
3231            height: metrics.height,
3232            line_height,
3233            glyph_x_positions,
3234            char_to_byte,
3235            glyph_layouts,
3236            lines,
3237        },
3238    )
3239}
3240
3241fn style_allows_prefix_widths(style: &TextStyle) -> bool {
3242    !matches!(
3243        style
3244            .paragraph_style
3245            .platform_style
3246            .and_then(|platform| platform.shaping),
3247        Some(TextShaping::Advanced)
3248    )
3249}
3250
3251fn cached_line_advance_width(
3252    font: &SoftwareTextFont,
3253    text: &str,
3254    glyph_font_size: f32,
3255    glyph_metrics: &mut SoftwareTextGlyphMetricsCache,
3256) -> f32 {
3257    let scaled_font = font.font.as_scaled(PxScale::from(glyph_font_size));
3258    // One scale factor for the whole run: the cache keeps font units, and this
3259    // is the multiply ab_glyph would have applied per lookup.
3260    let h_scale = scaled_font.h_scale_factor();
3261    let mut width = 0.0f32;
3262    let mut previous = None;
3263
3264    for ch in text.chars() {
3265        let metrics = glyph_metrics.glyph_metrics(font, &scaled_font, ch);
3266        if let Some(previous_id) = previous {
3267            width +=
3268                glyph_metrics.kern(font, &scaled_font, previous_id, metrics.glyph_id) * h_scale;
3269        }
3270        width += metrics.advance_unscaled * h_scale;
3271        previous = Some(metrics.glyph_id);
3272    }
3273
3274    width.max(0.0)
3275}
3276
3277fn annotated_line_prefix_widths_with_font_set_cached(
3278    text: &AnnotatedString,
3279    line_range: std::ops::Range<usize>,
3280    style: &TextStyle,
3281    fonts: &SoftwareTextFontSet,
3282    cache: &mut SoftwareTextMetricsCache,
3283) -> Option<TextLinePrefixWidths> {
3284    let mut boundaries = text.span_boundaries();
3285    boundaries.push(line_range.start);
3286    boundaries.push(line_range.end);
3287    boundaries.sort_unstable();
3288    boundaries.dedup();
3289    boundaries.retain(|offset| {
3290        *offset >= line_range.start
3291            && *offset <= line_range.end
3292            && text.text.is_char_boundary(*offset)
3293    });
3294
3295    let char_count = text.text[line_range.clone()].chars().count();
3296    let mut prefix_widths = Vec::with_capacity(char_count + 1);
3297    let mut separator_before = Vec::with_capacity(char_count);
3298    let non_empty_overhang = {
3299        let mut sink = PrefixWidthSegmentSink {
3300            prefix_widths: &mut prefix_widths,
3301            separator_before: &mut separator_before,
3302            width: 0.0,
3303            non_empty_overhang: 0.0,
3304        };
3305        sink.prefix_widths.push(sink.width);
3306
3307        for range in boundaries.windows(2) {
3308            let start = range[0];
3309            let end = range[1];
3310            if start >= end {
3311                continue;
3312            }
3313            let segment = &text.text[start..end];
3314            let segment_style = effective_style_for_range(&text.span_styles, style, start, end);
3315            append_prefix_width_segment_cached(segment, &segment_style, fonts, cache, &mut sink);
3316        }
3317
3318        sink.non_empty_overhang
3319    };
3320
3321    TextLinePrefixWidths::from_parts(prefix_widths, separator_before, non_empty_overhang)
3322}
3323
3324struct PrefixWidthSegmentSink<'a> {
3325    prefix_widths: &'a mut Vec<f32>,
3326    separator_before: &'a mut Vec<f32>,
3327    width: f32,
3328    non_empty_overhang: f32,
3329}
3330
3331fn append_prefix_width_segment_cached(
3332    segment: &str,
3333    style: &TextStyle,
3334    fonts: &SoftwareTextFontSet,
3335    cache: &mut SoftwareTextMetricsCache,
3336    sink: &mut PrefixWidthSegmentSink<'_>,
3337) {
3338    if segment.is_empty() {
3339        return;
3340    }
3341
3342    let font_size = resolve_font_size(style);
3343    if let Some(font) = fonts.resolve(style) {
3344        append_font_prefix_width_segment_cached(segment, style, font_size, font, cache, sink);
3345    } else {
3346        append_fallback_prefix_width_segment(segment, style, font_size, sink);
3347    }
3348}
3349
3350fn append_font_prefix_width_segment_cached(
3351    segment: &str,
3352    style: &TextStyle,
3353    font_size: f32,
3354    font: &SoftwareTextFont,
3355    cache: &mut SoftwareTextMetricsCache,
3356    sink: &mut PrefixWidthSegmentSink<'_>,
3357) {
3358    let glyph_font_size = font.ab_glyph_px_size(font_size);
3359    let scaled_font = font.font.as_scaled(PxScale::from(glyph_font_size));
3360    let letter_spacing = resolve_letter_spacing(style, font_size);
3361    let weight_synthesis = TextWeightSynthesis::for_style(style, font.weight(), font_size, 1.0);
3362    let style_synthesis = TextStyleSynthesis::for_style(style, font.style(), font_size, 1.0);
3363    sink.non_empty_overhang = sink
3364        .non_empty_overhang
3365        .max(style_synthesis.visual_overhang_px());
3366
3367    let mut previous = None;
3368    // One scale factor for the whole run: the cache keeps font units.
3369    let h_scale = scaled_font.h_scale_factor();
3370
3371    for (index, ch) in segment.chars().enumerate() {
3372        let metrics = cache.glyph_metrics.glyph_metrics(font, &scaled_font, ch);
3373        // `separator_before` is the KERN alone. The letter space belongs to
3374        // the character rather than to the gap before it (`run_tracking`), so
3375        // it goes into `width` unconditionally -- and a line that starts
3376        // mid-string, which drops `separator_before[start]`, still keeps one
3377        // letter space for every character it contains.
3378        let separator = if index == 0 {
3379            0.0
3380        } else {
3381            previous
3382                .map(|previous_id| {
3383                    weight_synthesis.apply_width(
3384                        cache
3385                            .glyph_metrics
3386                            .kern(font, &scaled_font, previous_id, metrics.glyph_id)
3387                            * h_scale,
3388                    )
3389                })
3390                .unwrap_or(0.0)
3391        };
3392        sink.separator_before.push(separator);
3393        sink.width += separator
3394            + letter_spacing
3395            + weight_synthesis.apply_width(metrics.advance_unscaled * h_scale);
3396        sink.prefix_widths.push(sink.width.max(0.0));
3397        previous = Some(metrics.glyph_id);
3398    }
3399}
3400
3401fn append_fallback_prefix_width_segment(
3402    segment: &str,
3403    style: &TextStyle,
3404    font_size: f32,
3405    sink: &mut PrefixWidthSegmentSink<'_>,
3406) {
3407    let char_width = fallback_char_width(font_size);
3408    let letter_spacing = resolve_letter_spacing(style, font_size);
3409    for _ in segment.chars() {
3410        // No kerning in the fallback, so nothing is dropped at a line's
3411        // leading edge; the letter space rides the character (`run_tracking`).
3412        sink.separator_before.push(0.0);
3413        sink.width += letter_spacing + char_width;
3414        sink.prefix_widths.push(sink.width.max(0.0));
3415    }
3416}
3417
3418fn byte_offset_for_char_index(text: &str, char_index: usize) -> usize {
3419    text.char_indices()
3420        .map(|(index, _)| index)
3421        .nth(char_index)
3422        .unwrap_or(text.len())
3423}
3424
3425fn measure_text_impl(
3426    text: &str,
3427    style: &TextStyle,
3428    font_size: f32,
3429    glyph_font_size: f32,
3430    font: &impl Font,
3431    resolved_style: FontStyle,
3432    resolved_weight: FontWeight,
3433) -> TextMetrics {
3434    let line_height = line_box_for(
3435        style,
3436        vertical_metrics(font, glyph_font_size),
3437        resolve_line_height(style, font_size * 1.4),
3438        measure_grid(),
3439    )
3440    .height;
3441    let letter_spacing = resolve_letter_spacing(style, font_size);
3442    let weight_synthesis = TextWeightSynthesis::for_style(style, resolved_weight, font_size, 1.0);
3443    let style_synthesis = TextStyleSynthesis::for_style(style, resolved_style, font_size, 1.0);
3444
3445    let lines: Vec<&str> = text.split('\n').collect();
3446    let line_count = lines.len().max(1);
3447
3448    let mut max_width: f32 = 0.0;
3449    for line in &lines {
3450        let line_width = line_advance_width(font, line, glyph_font_size);
3451        let char_spacing = run_tracking(line.chars().count(), letter_spacing);
3452        let line_width = (weight_synthesis.apply_width(line_width) + char_spacing).max(0.0);
3453        let line_width = if line.is_empty() {
3454            line_width
3455        } else {
3456            line_width + style_synthesis.visual_overhang_px()
3457        };
3458        max_width = max_width.max(line_width);
3459    }
3460
3461    TextMetrics {
3462        width: max_width,
3463        height: line_count as f32 * line_height,
3464        line_height,
3465        line_count,
3466    }
3467}
3468
3469fn measure_text_impl_cached(
3470    text: &str,
3471    style: &TextStyle,
3472    font_size: f32,
3473    font: &SoftwareTextFont,
3474    cache: &mut SoftwareTextMetricsCache,
3475) -> TextMetrics {
3476    let letter_spacing = resolve_letter_spacing(style, font_size);
3477    let weight_synthesis = TextWeightSynthesis::for_style(style, font.weight(), font_size, 1.0);
3478    let style_synthesis = TextStyleSynthesis::for_style(style, font.style(), font_size, 1.0);
3479    let glyph_font_size = font.ab_glyph_px_size(font_size);
3480    let line_height = line_box_for(
3481        style,
3482        vertical_metrics(&font.font, glyph_font_size),
3483        resolve_line_height(style, font_size * 1.4),
3484        measure_grid(),
3485    )
3486    .height;
3487
3488    let lines: Vec<&str> = text.split('\n').collect();
3489    let line_count = lines.len().max(1);
3490
3491    let mut max_width: f32 = 0.0;
3492    for line in &lines {
3493        let line_width =
3494            cached_line_advance_width(font, line, glyph_font_size, &mut cache.glyph_metrics);
3495        let char_spacing = run_tracking(line.chars().count(), letter_spacing);
3496        let line_width = (weight_synthesis.apply_width(line_width) + char_spacing).max(0.0);
3497        let line_width = if line.is_empty() {
3498            line_width
3499        } else {
3500            line_width + style_synthesis.visual_overhang_px()
3501        };
3502        max_width = max_width.max(line_width);
3503    }
3504
3505    TextMetrics {
3506        width: max_width,
3507        height: line_count as f32 * line_height,
3508        line_height,
3509        line_count,
3510    }
3511}
3512
3513fn measure_annotated_text_with_resolver(
3514    text: &AnnotatedString,
3515    style: &TextStyle,
3516    font_size: f32,
3517    fonts: &SoftwareTextFontSet,
3518    mut cache: Option<&mut SoftwareTextMetricsCache>,
3519) -> TextMetrics {
3520    let Some(base_font) = fonts.resolve(style) else {
3521        return fallback_text_metrics(text.text.as_str(), style, font_size);
3522    };
3523    let base_line_height = line_height_for_style(style, font_size, base_font);
3524    let mut boundaries = text.span_boundaries();
3525    for (offset, ch) in text.text.char_indices() {
3526        if ch == '\n' {
3527            boundaries.push(offset);
3528            boundaries.push(offset + ch.len_utf8());
3529        }
3530    }
3531    boundaries.sort_unstable();
3532    boundaries.dedup();
3533    boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
3534
3535    let mut line_count = 1usize;
3536    let mut max_width = 0.0f32;
3537    let mut current_line_width = 0.0f32;
3538
3539    for range in boundaries.windows(2) {
3540        let start = range[0];
3541        let end = range[1];
3542        if start == end {
3543            continue;
3544        }
3545        let segment = &text.text[start..end];
3546        let segment_style = effective_style_for_range(&text.span_styles, style, start, end);
3547        let segment_font_size = resolve_font_size(&segment_style);
3548        let Some(segment_font) = fonts.resolve(&segment_style) else {
3549            let mut remaining = segment;
3550            loop {
3551                if let Some(newline_offset) = remaining.find('\n') {
3552                    let before_newline = &remaining[..newline_offset];
3553                    if !before_newline.is_empty() {
3554                        current_line_width += fallback_text_metrics(
3555                            before_newline,
3556                            &segment_style,
3557                            segment_font_size,
3558                        )
3559                        .width;
3560                    }
3561                    max_width = max_width.max(current_line_width);
3562                    current_line_width = 0.0;
3563                    line_count += 1;
3564                    remaining = &remaining[newline_offset + 1..];
3565                    if remaining.is_empty() {
3566                        break;
3567                    }
3568                } else {
3569                    if !remaining.is_empty() {
3570                        current_line_width +=
3571                            fallback_text_metrics(remaining, &segment_style, segment_font_size)
3572                                .width;
3573                    }
3574                    break;
3575                }
3576            }
3577            continue;
3578        };
3579
3580        let mut remaining = segment;
3581        loop {
3582            if let Some(newline_offset) = remaining.find('\n') {
3583                let before_newline = &remaining[..newline_offset];
3584                if !before_newline.is_empty() {
3585                    let metrics = if let Some(cache) = cache.as_deref_mut() {
3586                        measure_text_with_font_cached(
3587                            before_newline,
3588                            &segment_style,
3589                            segment_font_size,
3590                            segment_font,
3591                            cache,
3592                        )
3593                    } else {
3594                        measure_text_with_font(
3595                            before_newline,
3596                            &segment_style,
3597                            segment_font_size,
3598                            segment_font,
3599                        )
3600                    };
3601                    current_line_width += metrics.width;
3602                }
3603                max_width = max_width.max(current_line_width);
3604                current_line_width = 0.0;
3605                line_count += 1;
3606                remaining = &remaining[newline_offset + 1..];
3607                if remaining.is_empty() {
3608                    break;
3609                }
3610            } else {
3611                if !remaining.is_empty() {
3612                    let metrics = if let Some(cache) = cache.as_deref_mut() {
3613                        measure_text_with_font_cached(
3614                            remaining,
3615                            &segment_style,
3616                            segment_font_size,
3617                            segment_font,
3618                            cache,
3619                        )
3620                    } else {
3621                        measure_text_with_font(
3622                            remaining,
3623                            &segment_style,
3624                            segment_font_size,
3625                            segment_font,
3626                        )
3627                    };
3628                    current_line_width += metrics.width;
3629                }
3630                break;
3631            }
3632        }
3633    }
3634
3635    max_width = max_width.max(current_line_width);
3636
3637    let line_heights = annotated_line_heights_with_resolver(text, style, font_size, fonts);
3638    let total_height = line_heights.iter().sum();
3639    let max_line_height = line_heights.into_iter().fold(base_line_height, f32::max);
3640
3641    TextMetrics {
3642        width: max_width,
3643        height: total_height,
3644        line_height: max_line_height,
3645        line_count,
3646    }
3647}
3648
3649fn annotated_line_heights_with_resolver(
3650    text: &AnnotatedString,
3651    style: &TextStyle,
3652    font_size: f32,
3653    fonts: &SoftwareTextFontSet,
3654) -> Vec<f32> {
3655    let Some(base_font) = fonts.resolve(style) else {
3656        return fallback_line_heights(text.text.as_str(), style, font_size);
3657    };
3658    let base_line_height = line_height_for_style(style, font_size, base_font);
3659    let mut line_heights = vec![base_line_height];
3660    let mut boundaries = text.span_boundaries();
3661    for (offset, ch) in text.text.char_indices() {
3662        if ch == '\n' {
3663            boundaries.push(offset);
3664            boundaries.push(offset + ch.len_utf8());
3665        }
3666    }
3667    boundaries.sort_unstable();
3668    boundaries.dedup();
3669    boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
3670
3671    let mut line_index = 0usize;
3672    for range in boundaries.windows(2) {
3673        let start = range[0];
3674        let end = range[1];
3675        if start == end {
3676            continue;
3677        }
3678        let segment = &text.text[start..end];
3679        let segment_style = effective_style_for_range(&text.span_styles, style, start, end);
3680        let segment_font_size = resolve_font_size(&segment_style);
3681        let segment_line_height = if let Some(segment_font) = fonts.resolve(&segment_style) {
3682            line_height_for_style(&segment_style, segment_font_size, segment_font)
3683        } else {
3684            fallback_line_height(&segment_style, segment_font_size)
3685        };
3686        for ch in segment.chars() {
3687            line_heights[line_index] = line_heights[line_index].max(segment_line_height);
3688            if ch == '\n' {
3689                line_index += 1;
3690                if line_heights.len() <= line_index {
3691                    line_heights.push(base_line_height);
3692                }
3693            }
3694        }
3695    }
3696
3697    line_heights
3698}
3699
3700fn max_line_height_for_annotated_text_with_resolver(
3701    text: &AnnotatedString,
3702    style: &TextStyle,
3703    font_size: f32,
3704    fonts: &SoftwareTextFontSet,
3705) -> f32 {
3706    let base_line_height = fonts
3707        .resolve(style)
3708        .map(|font| line_height_for_style(style, font_size, font))
3709        .unwrap_or_else(|| fallback_line_height(style, font_size));
3710    if text.span_styles.is_empty() {
3711        return base_line_height;
3712    }
3713
3714    let mut max_line_height = base_line_height;
3715    for range in text.span_boundaries().windows(2) {
3716        let start = range[0];
3717        let end = range[1];
3718        if start == end {
3719            continue;
3720        }
3721        let segment_style = effective_style_for_range(&text.span_styles, style, start, end);
3722        let segment_font_size = resolve_font_size(&segment_style);
3723        let segment_line_height = fonts
3724            .resolve(&segment_style)
3725            .map(|font| line_height_for_style(&segment_style, segment_font_size, font))
3726            .unwrap_or_else(|| fallback_line_height(&segment_style, segment_font_size));
3727        max_line_height = max_line_height.max(segment_line_height);
3728    }
3729    max_line_height
3730}
3731
3732fn effective_style_for_range(
3733    span_styles: &[RangeStyle<SpanStyle>],
3734    style: &TextStyle,
3735    start: usize,
3736    end: usize,
3737) -> TextStyle {
3738    let mut effective = style.clone();
3739    for span in span_styles {
3740        if span.range.start < end && span.range.end > start {
3741            effective.span_style = effective.span_style.merge(&span.item);
3742        }
3743    }
3744    effective
3745}
3746
3747/// The line advance a style asks for on a given font, in layout points.
3748///
3749/// The font used to be discarded here, which is why the "natural" line height
3750/// was a flat `font_size * 1.4` guess. A style that asks for a line-height
3751/// policy needs the font's real ascent and descent, and reading them at the
3752/// font's own pixel size is what makes them em-relative rather than a restated
3753/// `font_size`.
3754fn line_height_for_style(style: &TextStyle, font_size: f32, font: &SoftwareTextFont) -> f32 {
3755    let asked = resolve_line_height(style, font_size * 1.4);
3756    if style.paragraph_style.line_height_style.is_none() {
3757        return asked;
3758    }
3759    let metrics =
3760        crate::font_layout::vertical_metrics(&font.font, font.ab_glyph_px_size(font_size));
3761    line_box_for(style, metrics, asked, measure_grid()).height
3762}
3763
3764fn clamp_to_char_boundary(text: &str, mut offset: usize) -> usize {
3765    offset = offset.min(text.len());
3766    while offset > 0 && !text.is_char_boundary(offset) {
3767        offset -= 1;
3768    }
3769    offset
3770}
3771
3772fn align_glyph_for_text_motion(glyph: Glyph, static_text_motion: bool) -> Glyph {
3773    align_glyph_to_pixel_grid(glyph, static_text_motion)
3774}
3775
3776fn static_glyph_pixel_origin(glyph: &Glyph) -> (i32, i32) {
3777    (
3778        glyph.position.x.round() as i32,
3779        glyph.position.y.round() as i32,
3780    )
3781}
3782
3783fn glyph_mask_cache_key(
3784    font_hash: u64,
3785    glyph: &Glyph,
3786    raster_style: GlyphRasterStyle,
3787    weight_synthesis: TextWeightSynthesis,
3788    style_synthesis: TextStyleSynthesis,
3789) -> GlyphMaskCacheKey {
3790    GlyphMaskCacheKey {
3791        font_hash,
3792        glyph_id: u32::from(glyph.id.0),
3793        scale_x_bits: glyph.scale.x.to_bits(),
3794        scale_y_bits: glyph.scale.y.to_bits(),
3795        raster_style: GlyphRasterStyleKey::from_style(raster_style),
3796        embolden_px_bits: weight_synthesis.embolden_px.to_bits(),
3797        slant_bits: style_synthesis.slant.to_bits(),
3798    }
3799}
3800
3801fn glyph_atlas_key_from_mask_key(key: GlyphMaskCacheKey) -> Option<SoftwareGlyphAtlasKey> {
3802    if !matches!(key.raster_style, GlyphRasterStyleKey::Fill) {
3803        return None;
3804    }
3805    Some(SoftwareGlyphAtlasKey {
3806        font_hash: key.font_hash,
3807        glyph_id: key.glyph_id,
3808        scale_x_bits: key.scale_x_bits,
3809        scale_y_bits: key.scale_y_bits,
3810        embolden_px_bits: key.embolden_px_bits,
3811        slant_bits: key.slant_bits,
3812    })
3813}
3814
3815fn build_complete_glyph_mask(
3816    font: &impl Font,
3817    glyph: &Glyph,
3818    raster_style: GlyphRasterStyle,
3819    weight_synthesis: TextWeightSynthesis,
3820    style_synthesis: TextStyleSynthesis,
3821) -> Option<GlyphMask> {
3822    let (outlined, bounds) = outline_glyph_with_bounds(font, glyph)?;
3823    let mask = build_glyph_mask(font, glyph, &outlined, bounds, raster_style)?;
3824    let mask = synthesize_glyph_weight(mask, weight_synthesis);
3825    Some(synthesize_glyph_style(mask, style_synthesis))
3826}
3827
3828fn cached_static_glyph_mask_with_key(
3829    cache: &mut SoftwareGlyphRasterCache,
3830    font_hash: u64,
3831    font: &impl Font,
3832    glyph: &Glyph,
3833    raster_style: GlyphRasterStyle,
3834    weight_synthesis: TextWeightSynthesis,
3835    style_synthesis: TextStyleSynthesis,
3836) -> Option<(GlyphMaskCacheKey, GlyphMask)> {
3837    let key = glyph_mask_cache_key(
3838        font_hash,
3839        glyph,
3840        raster_style,
3841        weight_synthesis,
3842        style_synthesis,
3843    );
3844    if let Some(mask) = cache.get(&key, glyph) {
3845        return Some((key, mask));
3846    }
3847    let mask =
3848        build_complete_glyph_mask(font, glyph, raster_style, weight_synthesis, style_synthesis)?;
3849    Some((key, cache.put(key, glyph, mask)))
3850}
3851
3852fn cached_static_glyph_mask(
3853    cache: &mut SoftwareGlyphRasterCache,
3854    font_hash: u64,
3855    font: &impl Font,
3856    glyph: &Glyph,
3857    raster_style: GlyphRasterStyle,
3858    weight_synthesis: TextWeightSynthesis,
3859    style_synthesis: TextStyleSynthesis,
3860) -> Option<GlyphMask> {
3861    cached_static_glyph_mask_with_key(
3862        cache,
3863        font_hash,
3864        font,
3865        glyph,
3866        raster_style,
3867        weight_synthesis,
3868        style_synthesis,
3869    )
3870    .map(|(_, mask)| mask)
3871}
3872
3873/// Where each line of a paragraph starts, relative to the block's own left
3874/// edge, once `TextAlign` has had its say.
3875///
3876/// A `TextAlign` is a property of the paragraph and it applies to **every
3877/// line**: Compose centres each line in the paragraph's width. Every glyph
3878/// loop below used to start each line at `origin_x`, which centres a
3879/// single-line paragraph correctly — the scene builder offsets the whole block
3880/// — and leaves every wrapped continuation line jammed under the start of the
3881/// first. On the Credits screen that is the difference between
3882/// `"Designed and built for Wear" / "OS."` centred and `"OS."` hard against
3883/// the left of the block.
3884///
3885/// The offsets are measured against the widest line rather than against the
3886/// parent's width, because the block offset already covers the rest; see
3887/// `scene_builder::text_align_fraction`. `None` for the start-aligned case, so
3888/// the common path does not measure anything twice.
3889fn line_alignment_offsets<F: Font, S: ScaleFont<F>>(
3890    scaled_font: &S,
3891    text: &str,
3892    letter_spacing: f32,
3893    align_fraction: f32,
3894) -> Option<Vec<f32>> {
3895    if align_fraction == 0.0 || !text.contains('\n') {
3896        return None;
3897    }
3898    let advances: Vec<f32> = text
3899        .split('\n')
3900        .map(|line| {
3901            let mut advance = 0.0f32;
3902            let mut previous = None;
3903            for ch in line.chars() {
3904                let glyph_id = scaled_font.glyph_id(ch);
3905                if let Some(previous_id) = previous {
3906                    advance += scaled_font.kern(previous_id, glyph_id);
3907                }
3908                // One letter space PER CHARACTER -- see `run_tracking`. An
3909                // empty line gets none, which is why a blank line between two
3910                // tracked paragraphs no longer aligns as though it were a
3911                // character wide.
3912                advance += letter_spacing + scaled_font.h_advance(glyph_id);
3913                previous = Some(glyph_id);
3914            }
3915            advance.max(0.0)
3916        })
3917        .collect();
3918    let block = advances.iter().copied().fold(0.0f32, f32::max);
3919    Some(
3920        advances
3921            .iter()
3922            .map(|advance| ((block - advance) * align_fraction).max(0.0))
3923            .collect(),
3924    )
3925}
3926
3927fn line_offset(offsets: &Option<Vec<f32>>, line_idx: usize) -> f32 {
3928    offsets
3929        .as_ref()
3930        .and_then(|offsets| offsets.get(line_idx).copied())
3931        .unwrap_or(0.0)
3932}
3933
3934#[allow(clippy::too_many_arguments)]
3935fn visit_text_glyph_masks(
3936    text: &str,
3937    font: &impl Font,
3938    font_hash: u64,
3939    font_px_size: f32,
3940    line_height: f32,
3941    first_baseline_y: f32,
3942    origin_x: f32,
3943    origin_y: f32,
3944    letter_spacing: f32,
3945    align_fraction: f32,
3946    static_text_motion: bool,
3947    raster_style: GlyphRasterStyle,
3948    weight_synthesis: TextWeightSynthesis,
3949    style_synthesis: TextStyleSynthesis,
3950    mut glyph_cache: Option<&mut SoftwareGlyphRasterCache>,
3951    mut visit: impl FnMut(&GlyphMask),
3952) -> f32 {
3953    let scale = PxScale::from(font_px_size);
3954    let scaled_font = font.as_scaled(scale);
3955    let line_offsets = line_alignment_offsets(&scaled_font, text, letter_spacing, align_fraction);
3956    let mut max_advance = 0.0f32;
3957    for (line_idx, line) in text.split('\n').enumerate() {
3958        let baseline_y = first_baseline_y + line_idx as f32 * line_height + origin_y;
3959        // Half a letter space of lead-in before the first glyph, and half
3960        // again after the last -- see `run_tracking`.
3961        let lead_in = run_lead_in(line.chars().count(), letter_spacing);
3962        let mut caret_x = origin_x + line_offset(&line_offsets, line_idx) + lead_in;
3963        let mut previous = None;
3964        for ch in line.chars() {
3965            let glyph_id = scaled_font.glyph_id(ch);
3966            if let Some(previous_id) = previous {
3967                caret_x += scaled_font.kern(previous_id, glyph_id) + letter_spacing;
3968            }
3969            let glyph = glyph_id.with_scale_and_position(scale, point(caret_x, baseline_y));
3970            caret_x += scaled_font.h_advance(glyph_id);
3971            previous = Some(glyph_id);
3972            let glyph = align_glyph_for_text_motion(glyph, static_text_motion);
3973            let Some(mask) = (if static_text_motion {
3974                glyph_cache.as_deref_mut().and_then(|cache| {
3975                    cached_static_glyph_mask(
3976                        cache,
3977                        font_hash,
3978                        font,
3979                        &glyph,
3980                        raster_style,
3981                        weight_synthesis,
3982                        style_synthesis,
3983                    )
3984                })
3985            } else {
3986                None
3987            })
3988            .or_else(|| {
3989                build_complete_glyph_mask(
3990                    font,
3991                    &glyph,
3992                    raster_style,
3993                    weight_synthesis,
3994                    style_synthesis,
3995                )
3996            }) else {
3997                continue;
3998            };
3999            visit(&mask);
4000        }
4001        max_advance = max_advance.max((caret_x - origin_x + lead_in).max(0.0));
4002    }
4003    max_advance
4004}
4005
4006#[allow(clippy::too_many_arguments)]
4007fn visit_text_glyph_masks_with_key(
4008    text: &str,
4009    font: &impl Font,
4010    font_hash: u64,
4011    font_px_size: f32,
4012    line_height: f32,
4013    first_baseline_y: f32,
4014    origin_x: f32,
4015    origin_y: f32,
4016    letter_spacing: f32,
4017    align_fraction: f32,
4018    static_text_motion: bool,
4019    raster_style: GlyphRasterStyle,
4020    weight_synthesis: TextWeightSynthesis,
4021    style_synthesis: TextStyleSynthesis,
4022    mut glyph_cache: Option<&mut SoftwareGlyphRasterCache>,
4023    mut visit: impl FnMut(SoftwareGlyphAtlasKey, &GlyphMask),
4024) -> f32 {
4025    if !static_text_motion {
4026        return 0.0;
4027    }
4028
4029    let scale = PxScale::from(font_px_size);
4030    let scaled_font = font.as_scaled(scale);
4031    let line_offsets = line_alignment_offsets(&scaled_font, text, letter_spacing, align_fraction);
4032    let mut max_advance = 0.0f32;
4033    for (line_idx, line) in text.split('\n').enumerate() {
4034        let baseline_y = first_baseline_y + line_idx as f32 * line_height + origin_y;
4035        // Half a letter space of lead-in before the first glyph, and half
4036        // again after the last -- see `run_tracking`.
4037        let lead_in = run_lead_in(line.chars().count(), letter_spacing);
4038        let mut caret_x = origin_x + line_offset(&line_offsets, line_idx) + lead_in;
4039        let mut previous = None;
4040        for ch in line.chars() {
4041            let glyph_id = scaled_font.glyph_id(ch);
4042            if let Some(previous_id) = previous {
4043                caret_x += scaled_font.kern(previous_id, glyph_id) + letter_spacing;
4044            }
4045            let glyph = glyph_id.with_scale_and_position(scale, point(caret_x, baseline_y));
4046            caret_x += scaled_font.h_advance(glyph_id);
4047            previous = Some(glyph_id);
4048            let glyph = align_glyph_for_text_motion(glyph, true);
4049            let Some((cache_key, mask)) = glyph_cache.as_deref_mut().and_then(|cache| {
4050                cached_static_glyph_mask_with_key(
4051                    cache,
4052                    font_hash,
4053                    font,
4054                    &glyph,
4055                    raster_style,
4056                    weight_synthesis,
4057                    style_synthesis,
4058                )
4059            }) else {
4060                continue;
4061            };
4062            let Some(atlas_key) = glyph_atlas_key_from_mask_key(cache_key) else {
4063                continue;
4064            };
4065            visit(atlas_key, &mask);
4066        }
4067        max_advance = max_advance.max((caret_x - origin_x + lead_in).max(0.0));
4068    }
4069    max_advance
4070}
4071
4072#[allow(clippy::too_many_arguments)]
4073fn visit_cached_text_glyph_atlas_placements(
4074    text: &str,
4075    font: &impl Font,
4076    font_hash: u64,
4077    font_px_size: f32,
4078    line_height: f32,
4079    first_baseline_y: f32,
4080    origin_x: f32,
4081    origin_y: f32,
4082    letter_spacing: f32,
4083    align_fraction: f32,
4084    raster_style: GlyphRasterStyle,
4085    weight_synthesis: TextWeightSynthesis,
4086    style_synthesis: TextStyleSynthesis,
4087    glyph_cache: &mut SoftwareGlyphRasterCache,
4088    mut visit: impl FnMut(SoftwareGlyphAtlasPlacement),
4089) -> f32 {
4090    let scale = PxScale::from(font_px_size);
4091    let scaled_font = font.as_scaled(scale);
4092    let line_offsets = line_alignment_offsets(&scaled_font, text, letter_spacing, align_fraction);
4093    let mut max_advance = 0.0f32;
4094    for (line_idx, line) in text.split('\n').enumerate() {
4095        let baseline_y = first_baseline_y + line_idx as f32 * line_height + origin_y;
4096        // Half a letter space of lead-in before the first glyph, and half
4097        // again after the last -- see `run_tracking`.
4098        let lead_in = run_lead_in(line.chars().count(), letter_spacing);
4099        let mut caret_x = origin_x + line_offset(&line_offsets, line_idx) + lead_in;
4100        let mut previous = None;
4101        for ch in line.chars() {
4102            let glyph_id = scaled_font.glyph_id(ch);
4103            if let Some(previous_id) = previous {
4104                caret_x += scaled_font.kern(previous_id, glyph_id) + letter_spacing;
4105            }
4106            let glyph = glyph_id.with_scale_and_position(scale, point(caret_x, baseline_y));
4107            caret_x += scaled_font.h_advance(glyph_id);
4108            previous = Some(glyph_id);
4109            let glyph = align_glyph_for_text_motion(glyph, true);
4110            let cache_key = glyph_mask_cache_key(
4111                font_hash,
4112                &glyph,
4113                raster_style,
4114                weight_synthesis,
4115                style_synthesis,
4116            );
4117            let Some((key, x, y, width, height)) =
4118                glyph_cache.get_atlas_placement(&cache_key, &glyph)
4119            else {
4120                if font.outline(glyph.id).is_none() {
4121                    continue;
4122                }
4123                return f32::NAN;
4124            };
4125            visit(SoftwareGlyphAtlasPlacement {
4126                key,
4127                x,
4128                y,
4129                width,
4130                height,
4131                color: Color::WHITE,
4132            });
4133        }
4134        max_advance = max_advance.max((caret_x - origin_x + lead_in).max(0.0));
4135    }
4136    max_advance
4137}
4138
4139#[allow(clippy::too_many_arguments)]
4140fn visit_text_glyph_atlas_run(
4141    text: &str,
4142    font: &impl Font,
4143    font_hash: u64,
4144    font_px_size: f32,
4145    line_height: f32,
4146    first_baseline_y: f32,
4147    origin_x: f32,
4148    origin_y: f32,
4149    letter_spacing: f32,
4150    align_fraction: f32,
4151    raster_style: GlyphRasterStyle,
4152    weight_synthesis: TextWeightSynthesis,
4153    style_synthesis: TextStyleSynthesis,
4154    glyph_cache: &mut SoftwareGlyphRasterCache,
4155    mut visit: impl FnMut(SoftwareGlyphAtlasRunGlyph),
4156) -> f32 {
4157    let scale = PxScale::from(font_px_size);
4158    let scaled_font = font.as_scaled(scale);
4159    let line_offsets = line_alignment_offsets(&scaled_font, text, letter_spacing, align_fraction);
4160    let mut max_advance = 0.0f32;
4161    let mut run_metrics_cache: Vec<(GlyphMaskCacheKey, CachedAtlasGlyphMetrics)> = Vec::new();
4162    for (line_idx, line) in text.split('\n').enumerate() {
4163        let baseline_y = first_baseline_y + line_idx as f32 * line_height + origin_y;
4164        // Half a letter space of lead-in before the first glyph, and half
4165        // again after the last -- see `run_tracking`.
4166        let lead_in = run_lead_in(line.chars().count(), letter_spacing);
4167        let mut caret_x = origin_x + line_offset(&line_offsets, line_idx) + lead_in;
4168        let mut previous = None;
4169        for ch in line.chars() {
4170            let glyph_id = scaled_font.glyph_id(ch);
4171            if let Some(previous_id) = previous {
4172                caret_x += scaled_font.kern(previous_id, glyph_id) + letter_spacing;
4173            }
4174            let glyph = glyph_id.with_scale_and_position(scale, point(caret_x, baseline_y));
4175            caret_x += scaled_font.h_advance(glyph_id);
4176            previous = Some(glyph_id);
4177            let glyph = align_glyph_for_text_motion(glyph, true);
4178            let cache_key = glyph_mask_cache_key(
4179                font_hash,
4180                &glyph,
4181                raster_style,
4182                weight_synthesis,
4183                style_synthesis,
4184            );
4185            if let Some((_, metrics)) = run_metrics_cache
4186                .iter()
4187                .find(|(cached_key, _)| *cached_key == cache_key)
4188            {
4189                visit(SoftwareGlyphAtlasRunGlyph::Cached(
4190                    metrics.placement(&glyph, Color::WHITE),
4191                ));
4192                continue;
4193            }
4194            if let Some(metrics) = glyph_cache.get_atlas_metrics(&cache_key) {
4195                if run_metrics_cache.len() < RUN_GLYPH_METRICS_CACHE_LIMIT {
4196                    run_metrics_cache.push((cache_key, metrics));
4197                }
4198                visit(SoftwareGlyphAtlasRunGlyph::Cached(
4199                    metrics.placement(&glyph, Color::WHITE),
4200                ));
4201                continue;
4202            }
4203
4204            if font.outline(glyph.id).is_none() {
4205                continue;
4206            }
4207            let Some(mask) = build_complete_glyph_mask(
4208                font,
4209                &glyph,
4210                raster_style,
4211                weight_synthesis,
4212                style_synthesis,
4213            ) else {
4214                continue;
4215            };
4216            let mask = glyph_cache.put(cache_key, &glyph, mask);
4217            let Some(key) = glyph_atlas_key_from_mask_key(cache_key) else {
4218                continue;
4219            };
4220            let (glyph_x, glyph_y) = static_glyph_pixel_origin(&glyph);
4221            if run_metrics_cache.len() < RUN_GLYPH_METRICS_CACHE_LIMIT {
4222                run_metrics_cache.push((
4223                    cache_key,
4224                    CachedAtlasGlyphMetrics {
4225                        key,
4226                        width: mask.width,
4227                        height: mask.height,
4228                        origin_offset_x: mask.origin_x - glyph_x,
4229                        origin_offset_y: mask.origin_y - glyph_y,
4230                    },
4231                ));
4232            }
4233            visit(SoftwareGlyphAtlasRunGlyph::New(SoftwareGlyphAtlasGlyph {
4234                key,
4235                mask: SoftwareGlyphAtlasMask {
4236                    alpha: Arc::clone(&mask.alpha),
4237                    width: mask.width,
4238                    height: mask.height,
4239                },
4240                x: mask.origin_x,
4241                y: mask.origin_y,
4242                color: Color::WHITE,
4243            }));
4244        }
4245        max_advance = max_advance.max((caret_x - origin_x + lead_in).max(0.0));
4246    }
4247    max_advance
4248}
4249
4250fn blend_src_over(dst: &mut [f32; 4], src: [f32; 4]) {
4251    let src_alpha = src[3].clamp(0.0, 1.0);
4252    if src_alpha <= 0.0 {
4253        return;
4254    }
4255
4256    let dst_alpha = dst[3].clamp(0.0, 1.0);
4257    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
4258
4259    if out_alpha <= f32::EPSILON {
4260        *dst = [0.0, 0.0, 0.0, 0.0];
4261        return;
4262    }
4263
4264    for channel in 0..3 {
4265        let src_premult = src[channel].clamp(0.0, 1.0) * src_alpha;
4266        let dst_premult = dst[channel].clamp(0.0, 1.0) * dst_alpha;
4267        dst[channel] =
4268            ((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0);
4269    }
4270    dst[3] = out_alpha;
4271}
4272
4273fn draw_mask_glyph(
4274    canvas: &mut [[f32; 4]],
4275    width: u32,
4276    height: u32,
4277    mask: &GlyphMask,
4278    brush: &Brush,
4279    brush_alpha_multiplier: f32,
4280    brush_rect: Rect,
4281) {
4282    for y in 0..mask.height {
4283        let py = mask.origin_y + y as i32;
4284        if py < 0 || py >= height as i32 {
4285            continue;
4286        }
4287
4288        for x in 0..mask.width {
4289            let px = mask.origin_x + x as i32;
4290            if px < 0 || px >= width as i32 {
4291                continue;
4292            }
4293
4294            let coverage = mask.alpha[y * mask.width + x];
4295            if coverage <= 0.0 {
4296                continue;
4297            }
4298
4299            let sample = sample_brush_rgba(
4300                brush,
4301                brush_rect,
4302                brush_rect.x + px as f32 + 0.5,
4303                brush_rect.y + py as f32 + 0.5,
4304            );
4305            let alpha = coverage * sample[3] * brush_alpha_multiplier;
4306            if alpha <= 0.0 {
4307                continue;
4308            }
4309            let idx = (py as u32 * width + px as u32) as usize;
4310            blend_src_over(
4311                &mut canvas[idx],
4312                [sample[0], sample[1], sample[2], alpha.clamp(0.0, 1.0)],
4313            );
4314        }
4315    }
4316}
4317
4318fn blend_src_over_u8(dst: &mut [u8], src: [f32; 4]) {
4319    let src_alpha = src[3].clamp(0.0, 1.0);
4320    if src_alpha <= 0.0 {
4321        return;
4322    }
4323
4324    let dst_alpha = dst[3] as f32 / 255.0;
4325    if dst_alpha <= 0.0 {
4326        dst[0] = (src[0].clamp(0.0, 1.0) * 255.0).round() as u8;
4327        dst[1] = (src[1].clamp(0.0, 1.0) * 255.0).round() as u8;
4328        dst[2] = (src[2].clamp(0.0, 1.0) * 255.0).round() as u8;
4329        dst[3] = (src_alpha * 255.0).round() as u8;
4330        return;
4331    }
4332
4333    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
4334    if out_alpha <= f32::EPSILON {
4335        dst.fill(0);
4336        return;
4337    }
4338
4339    for channel in 0..3 {
4340        let src_premult = src[channel].clamp(0.0, 1.0) * src_alpha;
4341        let dst_premult = (dst[channel] as f32 / 255.0) * dst_alpha;
4342        dst[channel] =
4343            ((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha * 255.0).round() as u8;
4344    }
4345    dst[3] = (out_alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
4346}
4347
4348fn draw_mask_glyph_solid_u8(
4349    canvas: &mut [u8],
4350    width: u32,
4351    height: u32,
4352    mask: &GlyphMask,
4353    color: [f32; 4],
4354    alpha_multiplier: f32,
4355) {
4356    let red = (color[0].clamp(0.0, 1.0) * 255.0).round() as u8;
4357    let green = (color[1].clamp(0.0, 1.0) * 255.0).round() as u8;
4358    let blue = (color[2].clamp(0.0, 1.0) * 255.0).round() as u8;
4359    let alpha_scale = color[3].clamp(0.0, 1.0) * alpha_multiplier.clamp(0.0, 1.0);
4360    if alpha_scale <= 0.0 {
4361        return;
4362    }
4363
4364    for y in 0..mask.height {
4365        let py = mask.origin_y + y as i32;
4366        if py < 0 || py >= height as i32 {
4367            continue;
4368        }
4369
4370        for x in 0..mask.width {
4371            let px = mask.origin_x + x as i32;
4372            if px < 0 || px >= width as i32 {
4373                continue;
4374            }
4375
4376            let coverage = mask.alpha[y * mask.width + x];
4377            if coverage <= 0.0 {
4378                continue;
4379            }
4380
4381            let alpha = (coverage * alpha_scale).clamp(0.0, 1.0);
4382            let alpha_u8 = (alpha * 255.0).round() as u8;
4383            if alpha_u8 == 0 {
4384                continue;
4385            }
4386            let idx = ((py as u32 * width + px as u32) * 4) as usize;
4387            let dst = &mut canvas[idx..idx + 4];
4388            if dst[3] == 0 {
4389                dst[0] = red;
4390                dst[1] = green;
4391                dst[2] = blue;
4392                dst[3] = alpha_u8;
4393            } else {
4394                blend_src_over_u8(dst, [color[0], color[1], color[2], alpha]);
4395            }
4396        }
4397    }
4398}
4399
4400fn draw_shadow_mask(
4401    canvas: &mut [[f32; 4]],
4402    width: u32,
4403    height: u32,
4404    mask: &GlyphMask,
4405    shadow: Shadow,
4406    text_scale: f32,
4407    static_text_motion: bool,
4408) {
4409    if mask.width == 0 || mask.height == 0 {
4410        return;
4411    }
4412
4413    let shadow_dx = shadow.offset.x * text_scale;
4414    let shadow_dy = shadow.offset.y * text_scale;
4415    let blur_radius = (shadow.blur_radius * text_scale).max(0.0);
4416    let sigma = shadow_blur_sigma(blur_radius);
4417    let blur_margin = if sigma > 0.0 {
4418        (sigma * 3.0).ceil() as i32
4419    } else {
4420        0
4421    };
4422
4423    let padded_width = mask.width + (blur_margin as usize) * 2;
4424    let padded_height = mask.height + (blur_margin as usize) * 2;
4425    let mut padded_mask = vec![0.0f32; padded_width * padded_height];
4426
4427    for y in 0..mask.height {
4428        let src_offset = y * mask.width;
4429        let dst_offset = (y + blur_margin as usize) * padded_width + blur_margin as usize;
4430        padded_mask[dst_offset..dst_offset + mask.width]
4431            .copy_from_slice(&mask.alpha[src_offset..src_offset + mask.width]);
4432    }
4433
4434    let blurred = if sigma > 0.0 {
4435        gaussian_blur_alpha(&padded_mask, padded_width, padded_height, sigma)
4436    } else {
4437        padded_mask
4438    };
4439
4440    let shadow_rgba = color_to_rgba(shadow.color);
4441    let shadow_origin_x = mask.origin_x - blur_margin;
4442    let shadow_origin_y = mask.origin_y - blur_margin;
4443
4444    for y in 0..padded_height {
4445        for x in 0..padded_width {
4446            let alpha = blurred[y * padded_width + x] * shadow_rgba[3];
4447            if alpha <= 0.0 {
4448                continue;
4449            }
4450
4451            let target_x = shadow_origin_x as f32 + x as f32 + shadow_dx;
4452            let target_y = shadow_origin_y as f32 + y as f32 + shadow_dy;
4453            if static_text_motion {
4454                blend_shadow_pixel(
4455                    canvas,
4456                    width,
4457                    height,
4458                    target_x.round() as i32,
4459                    target_y.round() as i32,
4460                    shadow_rgba,
4461                    alpha.clamp(0.0, 1.0),
4462                );
4463            } else {
4464                blend_shadow_pixel_subpixel(
4465                    canvas,
4466                    width,
4467                    height,
4468                    target_x,
4469                    target_y,
4470                    shadow_rgba,
4471                    alpha.clamp(0.0, 1.0),
4472                );
4473            }
4474        }
4475    }
4476}
4477
4478fn blend_shadow_pixel(
4479    canvas: &mut [[f32; 4]],
4480    width: u32,
4481    height: u32,
4482    px: i32,
4483    py: i32,
4484    color: [f32; 4],
4485    alpha: f32,
4486) {
4487    if px < 0 || py < 0 || px >= width as i32 || py >= height as i32 || alpha <= 0.0 {
4488        return;
4489    }
4490    let idx = (py as u32 * width + px as u32) as usize;
4491    blend_src_over(
4492        &mut canvas[idx],
4493        [color[0], color[1], color[2], alpha.clamp(0.0, 1.0)],
4494    );
4495}
4496
4497fn blend_shadow_pixel_subpixel(
4498    canvas: &mut [[f32; 4]],
4499    width: u32,
4500    height: u32,
4501    x: f32,
4502    y: f32,
4503    color: [f32; 4],
4504    alpha: f32,
4505) {
4506    if alpha <= 0.0 {
4507        return;
4508    }
4509
4510    let base_x = x.floor();
4511    let base_y = y.floor();
4512    let frac_x = x - base_x;
4513    let frac_y = y - base_y;
4514    let base_x_i32 = base_x as i32;
4515    let base_y_i32 = base_y as i32;
4516    let weights = [
4517        ((1.0 - frac_x) * (1.0 - frac_y), 0i32, 0i32),
4518        (frac_x * (1.0 - frac_y), 1, 0),
4519        ((1.0 - frac_x) * frac_y, 0, 1),
4520        (frac_x * frac_y, 1, 1),
4521    ];
4522
4523    for (weight, dx, dy) in weights {
4524        if weight <= 0.0 {
4525            continue;
4526        }
4527        blend_shadow_pixel(
4528            canvas,
4529            width,
4530            height,
4531            base_x_i32 + dx,
4532            base_y_i32 + dy,
4533            color,
4534            alpha * weight,
4535        );
4536    }
4537}
4538
4539fn shadow_blur_sigma(blur_radius: f32) -> f32 {
4540    if blur_radius <= 0.0 {
4541        0.0
4542    } else {
4543        (blur_radius * SHADOW_SIGMA_SCALE + SHADOW_SIGMA_BIAS).max(0.5)
4544    }
4545}
4546
4547fn gaussian_blur_alpha(src: &[f32], width: usize, height: usize, sigma: f32) -> Vec<f32> {
4548    let kernel = gaussian_kernel_1d(sigma);
4549    if kernel.len() == 1 {
4550        return src.to_vec();
4551    }
4552    let half = (kernel.len() / 2) as i32;
4553
4554    let mut horizontal = vec![0.0f32; src.len()];
4555    for y in 0..height {
4556        for x in 0..width {
4557            let mut sum = 0.0f32;
4558            for (index, weight) in kernel.iter().enumerate() {
4559                let offset = index as i32 - half;
4560                let sample_x = (x as i32 + offset).clamp(0, width as i32 - 1) as usize;
4561                sum += src[y * width + sample_x] * *weight;
4562            }
4563            horizontal[y * width + x] = sum;
4564        }
4565    }
4566
4567    let mut output = vec![0.0f32; src.len()];
4568    for y in 0..height {
4569        for x in 0..width {
4570            let mut sum = 0.0f32;
4571            for (index, weight) in kernel.iter().enumerate() {
4572                let offset = index as i32 - half;
4573                let sample_y = (y as i32 + offset).clamp(0, height as i32 - 1) as usize;
4574                sum += horizontal[sample_y * width + x] * *weight;
4575            }
4576            output[y * width + x] = sum;
4577        }
4578    }
4579
4580    output
4581}
4582
4583fn gaussian_kernel_1d(sigma: f32) -> Vec<f32> {
4584    let half = ((sigma * 3.0).ceil() as i32).clamp(1, MAX_GAUSSIAN_KERNEL_HALF);
4585    if half <= 0 {
4586        return vec![1.0];
4587    }
4588
4589    let mut kernel = Vec::with_capacity((half * 2 + 1) as usize);
4590    let mut sum = 0.0f32;
4591    for offset in -half..=half {
4592        let distance = offset as f32;
4593        let weight = (-0.5 * (distance / sigma).powi(2)).exp();
4594        kernel.push(weight);
4595        sum += weight;
4596    }
4597
4598    if sum > f32::EPSILON {
4599        for weight in &mut kernel {
4600            *weight /= sum;
4601        }
4602    }
4603
4604    kernel
4605}
4606
4607fn outline_glyph_with_bounds(
4608    font: &impl Font,
4609    glyph: &Glyph,
4610) -> Option<(OutlinedGlyph, GlyphPixelBounds)> {
4611    let outlined = font.outline_glyph(glyph.clone())?;
4612    let bounds = pixel_bounds_from_outlined(&outlined);
4613    Some((outlined, bounds))
4614}
4615
4616fn build_glyph_mask(
4617    font: &impl Font,
4618    glyph: &Glyph,
4619    outlined: &OutlinedGlyph,
4620    bounds: GlyphPixelBounds,
4621    style: GlyphRasterStyle,
4622) -> Option<GlyphMask> {
4623    match style {
4624        GlyphRasterStyle::Fill => build_fill_mask(outlined, bounds),
4625        GlyphRasterStyle::Stroke { width_px } => {
4626            build_stroke_mask(font, glyph, outlined, bounds, width_px)
4627        }
4628    }
4629}
4630
4631fn build_fill_mask(outlined: &OutlinedGlyph, bounds: GlyphPixelBounds) -> Option<GlyphMask> {
4632    let mask_width = bounds.width();
4633    let mask_height = bounds.height();
4634    if mask_width == 0 || mask_height == 0 {
4635        return None;
4636    }
4637
4638    let mut alpha = vec![0.0f32; mask_width * mask_height];
4639    outlined.draw(|gx, gy, value| {
4640        let idx = gy as usize * mask_width + gx as usize;
4641        alpha[idx] = value;
4642    });
4643
4644    Some(GlyphMask {
4645        alpha: Arc::from(alpha),
4646        width: mask_width,
4647        height: mask_height,
4648        origin_x: bounds.min_x,
4649        origin_y: bounds.min_y,
4650    })
4651}
4652
4653fn build_stroke_mask(
4654    font: &impl Font,
4655    glyph: &Glyph,
4656    outlined: &OutlinedGlyph,
4657    bounds: GlyphPixelBounds,
4658    stroke_width_px: f32,
4659) -> Option<GlyphMask> {
4660    if !stroke_width_px.is_finite() || stroke_width_px <= 0.0 {
4661        return build_fill_mask(outlined, bounds);
4662    }
4663
4664    let mask_width = bounds.max_x - bounds.min_x;
4665    let mask_height = bounds.max_y - bounds.min_y;
4666    if mask_width <= 0 || mask_height <= 0 {
4667        return None;
4668    }
4669
4670    let half_width = stroke_width_px * 0.5;
4671    let miter_pad = (half_width * COMPOSE_STROKE_MITER_LIMIT).ceil();
4672    let pad = miter_pad.max(1.0) as i32 + 1;
4673    let path = build_outline_path(font, glyph, bounds, pad)?;
4674    let raster_width = mask_width + pad * 2;
4675    let raster_height = mask_height + pad * 2;
4676    if raster_width <= 0 || raster_height <= 0 {
4677        return None;
4678    }
4679
4680    let mut pixmap = Pixmap::new(raster_width as u32, raster_height as u32)?;
4681    let mut paint = Paint::default();
4682    paint.set_color_rgba8(255, 255, 255, 255);
4683    paint.anti_alias = true;
4684
4685    let stroke = Stroke {
4686        width: stroke_width_px,
4687        line_cap: LineCap::Butt,
4688        line_join: LineJoin::Miter,
4689        miter_limit: COMPOSE_STROKE_MITER_LIMIT,
4690        ..Stroke::default()
4691    };
4692
4693    pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
4694
4695    let alpha: Vec<f32> = pixmap
4696        .data()
4697        .as_chunks::<4>()
4698        .0
4699        .iter()
4700        .map(|pixel| pixel[3] as f32 / 255.0)
4701        .collect();
4702
4703    Some(GlyphMask {
4704        alpha: Arc::from(alpha),
4705        width: raster_width as usize,
4706        height: raster_height as usize,
4707        origin_x: bounds.min_x - pad,
4708        origin_y: bounds.min_y - pad,
4709    })
4710}
4711
4712fn synthesize_glyph_weight(mask: GlyphMask, synthesis: TextWeightSynthesis) -> GlyphMask {
4713    let horizontal_shift = synthetic_weight_shift_px(synthesis.embolden_px);
4714    if horizontal_shift == 0 || mask.width == 0 || mask.height == 0 {
4715        return mask;
4716    }
4717
4718    let vertical_shift = (horizontal_shift / 2).min(1);
4719    let output_width = mask.width + horizontal_shift;
4720    let output_height = mask.height + vertical_shift * 2;
4721    let mut alpha = vec![0.0f32; output_width * output_height];
4722    for y in 0..mask.height {
4723        for x in 0..mask.width {
4724            let coverage = mask.alpha[y * mask.width + x];
4725            if coverage <= 0.0 {
4726                continue;
4727            }
4728            for dy in 0..=(vertical_shift * 2) {
4729                let output_y = y + dy;
4730                for dx in 0..=horizontal_shift {
4731                    let output_x = x + dx;
4732                    let output_index = output_y * output_width + output_x;
4733                    if coverage > alpha[output_index] {
4734                        alpha[output_index] = coverage;
4735                    }
4736                }
4737            }
4738        }
4739    }
4740
4741    GlyphMask {
4742        alpha: Arc::from(alpha),
4743        width: output_width,
4744        height: output_height,
4745        origin_x: mask.origin_x,
4746        origin_y: mask.origin_y - vertical_shift as i32,
4747    }
4748}
4749
4750fn synthesize_glyph_style(mask: GlyphMask, synthesis: TextStyleSynthesis) -> GlyphMask {
4751    if synthesis.slant <= 0.0 || mask.width == 0 || mask.height == 0 {
4752        return mask;
4753    }
4754
4755    let max_shift = ((mask.height.saturating_sub(1)) as f32 * synthesis.slant).ceil() as usize;
4756    if max_shift == 0 {
4757        return mask;
4758    }
4759
4760    let output_width = mask.width + max_shift + 1;
4761    let mut alpha = vec![0.0f32; output_width * mask.height];
4762    for y in 0..mask.height {
4763        let shift = (mask.height.saturating_sub(1) - y) as f32 * synthesis.slant;
4764        let shift_floor = shift.floor() as usize;
4765        let shift_fraction = shift - shift.floor();
4766        for x in 0..mask.width {
4767            let coverage = mask.alpha[y * mask.width + x];
4768            if coverage <= 0.0 {
4769                continue;
4770            }
4771
4772            let output_x = x + shift_floor;
4773            let left_index = y * output_width + output_x;
4774            let left_coverage = coverage * (1.0 - shift_fraction);
4775            if left_coverage > alpha[left_index] {
4776                alpha[left_index] = left_coverage;
4777            }
4778
4779            if shift_fraction > 0.0 {
4780                let right_index = left_index + 1;
4781                let right_coverage = coverage * shift_fraction;
4782                if right_coverage > alpha[right_index] {
4783                    alpha[right_index] = right_coverage;
4784                }
4785            }
4786        }
4787    }
4788
4789    GlyphMask {
4790        alpha: Arc::from(alpha),
4791        width: output_width,
4792        height: mask.height,
4793        origin_x: mask.origin_x,
4794        origin_y: mask.origin_y,
4795    }
4796}
4797
4798fn synthetic_weight_shift_px(embolden_px: f32) -> usize {
4799    if !embolden_px.is_finite() || embolden_px < 0.35 {
4800        return 0;
4801    }
4802    embolden_px.ceil().max(1.0) as usize
4803}
4804
4805fn build_outline_path(
4806    font: &impl Font,
4807    glyph: &Glyph,
4808    bounds: GlyphPixelBounds,
4809    pad: i32,
4810) -> Option<Path> {
4811    let outline = font.outline(glyph.id)?;
4812    let scale_factor = font.as_scaled(glyph.scale).scale_factor();
4813    let mut builder = PathBuilder::new();
4814    let mut has_segments = false;
4815    let mut current_end = None;
4816    let mut subpath_start = None;
4817
4818    for curve in outline.curves {
4819        match curve {
4820            ab_glyph::OutlineCurve::Line(p0, p1) => {
4821                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
4822                let end = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
4823                if current_end != Some(start) {
4824                    if current_end.is_some() {
4825                        builder.close();
4826                    }
4827                    builder.move_to(start.0, start.1);
4828                    subpath_start = Some(start);
4829                }
4830                builder.line_to(end.0, end.1);
4831                if subpath_start == Some(end) {
4832                    builder.close();
4833                    current_end = None;
4834                    subpath_start = None;
4835                } else {
4836                    current_end = Some(end);
4837                }
4838            }
4839            ab_glyph::OutlineCurve::Quad(p0, p1, p2) => {
4840                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
4841                let control = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
4842                let end = transform_outline_point(p2, scale_factor, glyph, bounds, pad);
4843                if current_end != Some(start) {
4844                    if current_end.is_some() {
4845                        builder.close();
4846                    }
4847                    builder.move_to(start.0, start.1);
4848                    subpath_start = Some(start);
4849                }
4850                builder.quad_to(control.0, control.1, end.0, end.1);
4851                if subpath_start == Some(end) {
4852                    builder.close();
4853                    current_end = None;
4854                    subpath_start = None;
4855                } else {
4856                    current_end = Some(end);
4857                }
4858            }
4859            ab_glyph::OutlineCurve::Cubic(p0, p1, p2, p3) => {
4860                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
4861                let control1 = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
4862                let control2 = transform_outline_point(p2, scale_factor, glyph, bounds, pad);
4863                let end = transform_outline_point(p3, scale_factor, glyph, bounds, pad);
4864                if current_end != Some(start) {
4865                    if current_end.is_some() {
4866                        builder.close();
4867                    }
4868                    builder.move_to(start.0, start.1);
4869                    subpath_start = Some(start);
4870                }
4871                builder.cubic_to(control1.0, control1.1, control2.0, control2.1, end.0, end.1);
4872                if subpath_start == Some(end) {
4873                    builder.close();
4874                    current_end = None;
4875                    subpath_start = None;
4876                } else {
4877                    current_end = Some(end);
4878                }
4879            }
4880        }
4881        has_segments = true;
4882    }
4883
4884    if !has_segments {
4885        return None;
4886    }
4887
4888    if current_end.is_some() {
4889        builder.close();
4890    }
4891
4892    builder.finish()
4893}
4894
4895fn transform_outline_point(
4896    point: ab_glyph::Point,
4897    scale_factor: ab_glyph::PxScaleFactor,
4898    glyph: &Glyph,
4899    bounds: GlyphPixelBounds,
4900    pad: i32,
4901) -> (f32, f32) {
4902    (
4903        point.x * scale_factor.horizontal + glyph.position.x - bounds.min_x as f32 + pad as f32,
4904        point.y * -scale_factor.vertical + glyph.position.y - bounds.min_y as f32 + pad as f32,
4905    )
4906}
4907
4908#[cfg(test)]
4909mod tests {
4910    use super::*;
4911    use cranpose_ui::text::{RangeStyle, SpanStyle};
4912    use cranpose_ui_graphics::Point;
4913
4914    fn count_ink_pixels(image: &ImageBitmap) -> usize {
4915        image
4916            .pixels()
4917            .chunks_exact(4)
4918            .filter(|px| px[3] > 0)
4919            .count()
4920    }
4921
4922    #[test]
4923    fn software_glyph_raster_cache_reuses_static_masks_across_positions() {
4924        let font = default_software_text_font().expect("bundled default font");
4925        let style = TextStyle::default();
4926        let rect = Rect {
4927            x: 0.0,
4928            y: 0.0,
4929            width: 160.0,
4930            height: 32.0,
4931        };
4932        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(64);
4933
4934        let uncached = rasterize_text_to_image(
4935            "aaaa",
4936            rect,
4937            &style,
4938            Color(1.0, 1.0, 1.0, 1.0),
4939            18.0,
4940            1.0,
4941            &font,
4942        )
4943        .expect("uncached image");
4944        let cached = rasterize_text_to_image_with_glyph_cache(
4945            "aaaa",
4946            rect,
4947            &style,
4948            Color(1.0, 1.0, 1.0, 1.0),
4949            18.0,
4950            1.0,
4951            &font,
4952            &mut cache,
4953        )
4954        .expect("cached image");
4955
4956        assert_eq!(cached.pixels(), uncached.pixels());
4957        let stats = cache.stats();
4958        assert_eq!(stats.entries, 1);
4959        assert_eq!(stats.misses, 1);
4960        assert_eq!(stats.hits, 3);
4961
4962        let shifted_rect = Rect {
4963            x: 24.0,
4964            y: 17.0,
4965            ..rect
4966        };
4967        let _ = rasterize_text_to_image_with_glyph_cache(
4968            "aaaa",
4969            shifted_rect,
4970            &style,
4971            Color(1.0, 1.0, 1.0, 1.0),
4972            18.0,
4973            1.0,
4974            &font,
4975            &mut cache,
4976        )
4977        .expect("cached shifted image");
4978
4979        let shifted_stats = cache.stats();
4980        assert_eq!(shifted_stats.entries, 1);
4981        assert_eq!(shifted_stats.misses, 1);
4982        assert_eq!(shifted_stats.hits, 7);
4983    }
4984
4985    #[test]
4986    fn annotated_solid_text_direct_raster_matches_plain_text_pixels() {
4987        let font = default_software_text_font().expect("bundled default font");
4988        let font_set = SoftwareTextFontSet::from_font(font.clone());
4989        let style = TextStyle::default();
4990        let rect = Rect {
4991            x: 0.0,
4992            y: 0.0,
4993            width: 240.0,
4994            height: 40.0,
4995        };
4996        let color = Color(1.0, 1.0, 1.0, 1.0);
4997        let annotated = AnnotatedString {
4998            text: "plain link".to_string(),
4999            span_styles: vec![RangeStyle {
5000                item: SpanStyle {
5001                    color: Some(color),
5002                    ..Default::default()
5003                },
5004                range: 0..10,
5005            }],
5006            ..Default::default()
5007        };
5008        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(64);
5009
5010        let plain = rasterize_text_to_image(
5011            annotated.text.as_str(),
5012            rect,
5013            &style,
5014            color,
5015            18.0,
5016            1.0,
5017            &font,
5018        )
5019        .expect("plain text image");
5020        let direct = rasterize_annotated_text_to_image_with_glyph_cache(
5021            &annotated, rect, &style, color, 18.0, 1.0, &font_set, &mut cache,
5022        )
5023        .expect("annotated text image");
5024
5025        assert_eq!(direct.pixels(), plain.pixels());
5026    }
5027
5028    #[test]
5029    fn solid_annotated_text_collects_atlas_glyphs_with_stable_keys() {
5030        let font = default_software_text_font().expect("bundled default font");
5031        let font_set = SoftwareTextFontSet::from_font(font);
5032        let style = TextStyle::default();
5033        let rect = Rect {
5034            x: 12.0,
5035            y: 4.0,
5036            width: 260.0,
5037            height: 48.0,
5038        };
5039        let annotated = AnnotatedString {
5040            text: "markdown link".to_string(),
5041            span_styles: vec![RangeStyle {
5042                item: SpanStyle {
5043                    color: Some(Color(0.4, 0.7, 1.0, 1.0)),
5044                    ..Default::default()
5045                },
5046                range: 9..13,
5047            }],
5048            ..Default::default()
5049        };
5050        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(64);
5051        let mut glyphs = Vec::new();
5052
5053        collect_solid_text_atlas_glyphs(
5054            &annotated,
5055            rect,
5056            &style,
5057            Color::WHITE,
5058            18.0,
5059            1.0,
5060            &font_set,
5061            &mut cache,
5062            &mut glyphs,
5063        )
5064        .expect("solid styled text is atlas-eligible");
5065
5066        assert!(!glyphs.is_empty());
5067        assert!(glyphs.iter().all(|glyph| glyph.mask.width > 0));
5068        assert!(glyphs.iter().all(|glyph| glyph.mask.height > 0));
5069        assert!(glyphs
5070            .iter()
5071            .any(|glyph| glyph.color == Color(0.4, 0.7, 1.0, 1.0)));
5072        assert!(cache.stats().entries > 0);
5073    }
5074
5075    #[test]
5076    fn cached_atlas_placements_reuse_existing_glyph_masks_without_payloads() {
5077        let font = default_software_text_font().expect("bundled default font");
5078        let font_set = SoftwareTextFontSet::from_font(font);
5079        let style = TextStyle::default();
5080        let rect = Rect {
5081            x: 12.0,
5082            y: 4.0,
5083            width: 260.0,
5084            height: 48.0,
5085        };
5086        let annotated = AnnotatedString {
5087            text: "markdown link".to_string(),
5088            span_styles: vec![RangeStyle {
5089                item: SpanStyle {
5090                    color: Some(Color(0.4, 0.7, 1.0, 1.0)),
5091                    ..Default::default()
5092                },
5093                range: 9..13,
5094            }],
5095            ..Default::default()
5096        };
5097        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(64);
5098        let mut placements = Vec::new();
5099
5100        assert!(
5101            collect_cached_solid_text_atlas_placements(
5102                &annotated,
5103                rect,
5104                &style,
5105                Color::WHITE,
5106                18.0,
5107                1.0,
5108                &font_set,
5109                &mut cache,
5110                &mut placements,
5111            )
5112            .is_none(),
5113            "placement-only collection requires retained glyph masks"
5114        );
5115        assert!(placements.is_empty());
5116
5117        let mut glyphs = Vec::new();
5118        collect_solid_text_atlas_glyphs(
5119            &annotated,
5120            rect,
5121            &style,
5122            Color::WHITE,
5123            18.0,
5124            1.0,
5125            &font_set,
5126            &mut cache,
5127            &mut glyphs,
5128        )
5129        .expect("solid styled text is atlas-eligible");
5130
5131        collect_cached_solid_text_atlas_placements(
5132            &annotated,
5133            rect,
5134            &style,
5135            Color::WHITE,
5136            18.0,
5137            1.0,
5138            &font_set,
5139            &mut cache,
5140            &mut placements,
5141        )
5142        .expect("cached masks provide placement-only atlas glyphs");
5143
5144        assert_eq!(placements.len(), glyphs.len());
5145        assert!(placements
5146            .iter()
5147            .zip(glyphs.iter())
5148            .all(|(placement, glyph)| {
5149                placement.key == glyph.key
5150                    && placement.x == glyph.x
5151                    && placement.y == glyph.y
5152                    && placement.width == glyph.mask.width
5153                    && placement.height == glyph.mask.height
5154                    && placement.color == glyph.color
5155            }));
5156        let recovered = cache
5157            .atlas_glyph_for_placement(&placements[0])
5158            .expect("placement should recover retained mask payload");
5159        assert_eq!(recovered.key, glyphs[0].key);
5160        assert_eq!(recovered.x, glyphs[0].x);
5161        assert_eq!(recovered.y, glyphs[0].y);
5162        assert_eq!(recovered.mask.width, glyphs[0].mask.width);
5163        assert_eq!(recovered.mask.height, glyphs[0].mask.height);
5164        assert_eq!(recovered.mask.alpha, glyphs[0].mask.alpha);
5165        assert_eq!(recovered.color, glyphs[0].color);
5166    }
5167
5168    #[test]
5169    fn atlas_glyph_collection_rejects_shadow_and_gradient_without_partial_output() {
5170        let font = default_software_text_font().expect("bundled default font");
5171        let font_set = SoftwareTextFontSet::from_font(font);
5172        let rect = Rect {
5173            x: 0.0,
5174            y: 0.0,
5175            width: 240.0,
5176            height: 40.0,
5177        };
5178        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(64);
5179        let mut glyphs = Vec::new();
5180        glyphs.push(SoftwareGlyphAtlasGlyph {
5181            key: SoftwareGlyphAtlasKey {
5182                font_hash: 1,
5183                glyph_id: 1,
5184                scale_x_bits: 1,
5185                scale_y_bits: 1,
5186                embolden_px_bits: 0,
5187                slant_bits: 0,
5188            },
5189            mask: SoftwareGlyphAtlasMask {
5190                alpha: Arc::from([1.0f32]),
5191                width: 1,
5192                height: 1,
5193            },
5194            x: 0,
5195            y: 0,
5196            color: Color::WHITE,
5197        });
5198        let initial_len = glyphs.len();
5199
5200        let shadow_style = TextStyle::from_span_style(SpanStyle {
5201            shadow: Some(Shadow {
5202                color: Color(0.0, 0.0, 0.0, 0.5),
5203                offset: Point::new(1.0, 1.0),
5204                blur_radius: 0.0,
5205            }),
5206            ..Default::default()
5207        });
5208        assert!(collect_solid_text_atlas_glyphs(
5209            &AnnotatedString::new("shadow".to_string()),
5210            rect,
5211            &shadow_style,
5212            Color::WHITE,
5213            18.0,
5214            1.0,
5215            &font_set,
5216            &mut cache,
5217            &mut glyphs,
5218        )
5219        .is_none());
5220        assert_eq!(glyphs.len(), initial_len);
5221
5222        let gradient_style = TextStyle::from_span_style(SpanStyle {
5223            brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
5224            ..Default::default()
5225        });
5226        assert!(collect_solid_text_atlas_glyphs(
5227            &AnnotatedString::new("gradient".to_string()),
5228            rect,
5229            &gradient_style,
5230            Color::WHITE,
5231            18.0,
5232            1.0,
5233            &font_set,
5234            &mut cache,
5235            &mut glyphs,
5236        )
5237        .is_none());
5238        assert_eq!(glyphs.len(), initial_len);
5239    }
5240
5241    fn average_ink_rgb(
5242        image: &ImageBitmap,
5243        x_start: u32,
5244        x_end: u32,
5245        y_start: u32,
5246        y_end: u32,
5247    ) -> Option<[f32; 3]> {
5248        let width = image.width();
5249        let height = image.height();
5250        let mut sums = [0.0f32; 3];
5251        let mut count = 0usize;
5252        let pixels = image.pixels();
5253
5254        let x_end = x_end.min(width);
5255        let y_end = y_end.min(height);
5256        for y in y_start.min(height)..y_end {
5257            for x in x_start.min(width)..x_end {
5258                let idx = ((y * width + x) * 4) as usize;
5259                let alpha = pixels[idx + 3];
5260                if alpha == 0 {
5261                    continue;
5262                }
5263                sums[0] += pixels[idx] as f32 / 255.0;
5264                sums[1] += pixels[idx + 1] as f32 / 255.0;
5265                sums[2] += pixels[idx + 2] as f32 / 255.0;
5266                count += 1;
5267            }
5268        }
5269
5270        if count == 0 {
5271            return None;
5272        }
5273        Some([
5274            sums[0] / count as f32,
5275            sums[1] / count as f32,
5276            sums[2] / count as f32,
5277        ])
5278    }
5279
5280    fn ink_x_range(image: &ImageBitmap) -> Option<(u32, u32)> {
5281        let width = image.width();
5282        let height = image.height();
5283        let pixels = image.pixels();
5284        let mut min_x = u32::MAX;
5285        let mut max_x = 0u32;
5286        let mut found = false;
5287        for y in 0..height {
5288            for x in 0..width {
5289                let idx = ((y * width + x) * 4) as usize;
5290                if pixels[idx + 3] > 0 {
5291                    min_x = min_x.min(x);
5292                    max_x = max_x.max(x + 1);
5293                    found = true;
5294                }
5295            }
5296        }
5297        found.then_some((min_x, max_x))
5298    }
5299
5300    fn ink_y_range(image: &ImageBitmap) -> Option<(u32, u32)> {
5301        let width = image.width();
5302        let height = image.height();
5303        let pixels = image.pixels();
5304        let mut min_y = u32::MAX;
5305        let mut max_y = 0u32;
5306        let mut found = false;
5307        for y in 0..height {
5308            for x in 0..width {
5309                let idx = ((y * width + x) * 4) as usize;
5310                if pixels[idx + 3] > 0 {
5311                    min_y = min_y.min(y);
5312                    max_y = max_y.max(y + 1);
5313                    found = true;
5314                }
5315            }
5316        }
5317        found.then_some((min_y, max_y))
5318    }
5319
5320    fn ink_centroid_x(image: &ImageBitmap, y_start: u32, y_end: u32) -> Option<f32> {
5321        let width = image.width();
5322        let height = image.height();
5323        let pixels = image.pixels();
5324        let mut weighted_x = 0.0f32;
5325        let mut total_alpha = 0.0f32;
5326
5327        for y in y_start.min(height)..y_end.min(height) {
5328            for x in 0..width {
5329                let idx = ((y * width + x) * 4) as usize;
5330                let alpha = pixels[idx + 3] as f32 / 255.0;
5331                if alpha <= 0.0 {
5332                    continue;
5333                }
5334                weighted_x += x as f32 * alpha;
5335                total_alpha += alpha;
5336            }
5337        }
5338
5339        (total_alpha > 0.0).then_some(weighted_x / total_alpha)
5340    }
5341
5342    fn vertical_slant_delta(image: &ImageBitmap) -> f32 {
5343        let (top, bottom) = ink_y_range(image).expect("image should contain ink");
5344        let mid = top + (bottom - top).max(1) / 2;
5345        let top_x = ink_centroid_x(image, top, mid).expect("top ink centroid");
5346        let bottom_x = ink_centroid_x(image, mid, bottom).expect("bottom ink centroid");
5347        top_x - bottom_x
5348    }
5349
5350    fn top_ink_row(image: &ImageBitmap) -> Option<u32> {
5351        let width = image.width();
5352        let height = image.height();
5353        let pixels = image.pixels();
5354        for y in 0..height {
5355            for x in 0..width {
5356                let idx = ((y * width + x) * 4) as usize;
5357                if pixels[idx + 3] > 0 {
5358                    return Some(y);
5359                }
5360            }
5361        }
5362        None
5363    }
5364
5365    fn reference_dilation_offsets(radius: i32) -> Vec<(i32, i32)> {
5366        let mut offsets = Vec::new();
5367        let squared_radius = radius * radius;
5368        for dy in -radius..=radius {
5369            for dx in -radius..=radius {
5370                if dx * dx + dy * dy <= squared_radius {
5371                    offsets.push((dx, dy));
5372                }
5373            }
5374        }
5375        if offsets.is_empty() {
5376            offsets.push((0, 0));
5377        }
5378        offsets
5379    }
5380
5381    fn reference_dilation_stroke_mask(fill: &GlyphMask, stroke_width: f32) -> GlyphMask {
5382        let radius = (stroke_width * 0.5).ceil() as i32;
5383        let offsets = reference_dilation_offsets(radius);
5384        let out_width = fill.width as i32 + radius * 2;
5385        let out_height = fill.height as i32 + radius * 2;
5386        let fill_width_i32 = fill.width as i32;
5387        let fill_height_i32 = fill.height as i32;
5388        let mut alpha = vec![0.0f32; (out_width * out_height) as usize];
5389
5390        for out_y in 0..out_height {
5391            let oy = out_y - radius;
5392            for out_x in 0..out_width {
5393                let ox = out_x - radius;
5394                let base_alpha =
5395                    if ox >= 0 && oy >= 0 && ox < fill_width_i32 && oy < fill_height_i32 {
5396                        fill.alpha[oy as usize * fill.width + ox as usize]
5397                    } else {
5398                        0.0
5399                    };
5400
5401                let mut dilated_alpha = 0.0f32;
5402                for (dx, dy) in &offsets {
5403                    let sx = ox + dx;
5404                    let sy = oy + dy;
5405                    if sx < 0 || sy < 0 || sx >= fill_width_i32 || sy >= fill_height_i32 {
5406                        continue;
5407                    }
5408                    let sample = fill.alpha[sy as usize * fill.width + sx as usize];
5409                    if sample > dilated_alpha {
5410                        dilated_alpha = sample;
5411                        if dilated_alpha >= 0.999 {
5412                            break;
5413                        }
5414                    }
5415                }
5416                alpha[out_y as usize * out_width as usize + out_x as usize] =
5417                    (dilated_alpha - base_alpha).max(0.0);
5418            }
5419        }
5420
5421        GlyphMask {
5422            alpha: Arc::from(alpha),
5423            width: out_width as usize,
5424            height: out_height as usize,
5425            origin_x: fill.origin_x - radius,
5426            origin_y: fill.origin_y - radius,
5427        }
5428    }
5429
5430    fn rasterize_reference_dilation_stroke(
5431        text: &str,
5432        rect: Rect,
5433        font_size: f32,
5434        stroke_width: f32,
5435        font: &impl Font,
5436    ) -> ImageBitmap {
5437        let width = rect.width.ceil().max(1.0) as u32;
5438        let height = rect.height.ceil().max(1.0) as u32;
5439        let mut canvas = vec![[0.0f32; 4]; (width * height) as usize];
5440
5441        let metrics = vertical_metrics(font, font_size);
5442        let baseline = line_box_for(&TextStyle::default(), metrics, font_size * 1.4, 1.0).baseline;
5443        for glyph in layout_line_glyphs(font, text, font_size, point(0.0, baseline)) {
5444            let Some((outlined, bounds)) = outline_glyph_with_bounds(font, &glyph) else {
5445                continue;
5446            };
5447            let Some(fill) = build_fill_mask(&outlined, bounds) else {
5448                continue;
5449            };
5450            let reference = reference_dilation_stroke_mask(&fill, stroke_width);
5451            draw_mask_glyph(
5452                &mut canvas,
5453                width,
5454                height,
5455                &reference,
5456                &Brush::solid(Color::WHITE),
5457                1.0,
5458                rect,
5459            );
5460        }
5461
5462        let mut rgba = vec![0u8; canvas.len() * 4];
5463        for (index, pixel) in canvas.iter().enumerate() {
5464            let base = index * 4;
5465            rgba[base] = (pixel[0].clamp(0.0, 1.0) * 255.0).round() as u8;
5466            rgba[base + 1] = (pixel[1].clamp(0.0, 1.0) * 255.0).round() as u8;
5467            rgba[base + 2] = (pixel[2].clamp(0.0, 1.0) * 255.0).round() as u8;
5468            rgba[base + 3] = (pixel[3].clamp(0.0, 1.0) * 255.0).round() as u8;
5469        }
5470        ImageBitmap::from_rgba8(width, height, rgba).expect("reference dilation image")
5471    }
5472
5473    fn test_font() -> ab_glyph::FontRef<'static> {
5474        ab_glyph::FontRef::try_from_slice(include_bytes!("../assets/NotoSansMerged.ttf"))
5475            .expect("font")
5476    }
5477
5478    fn test_software_font() -> SoftwareTextFont {
5479        SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansMerged.ttf").to_vec())
5480            .expect("font")
5481    }
5482
5483    #[test]
5484    fn software_text_font_rejects_invalid_bytes() {
5485        assert!(SoftwareTextFont::from_bytes(vec![0, 1, 2, 3]).is_err());
5486    }
5487
5488    #[test]
5489    fn default_software_text_font_has_no_process_global_cache() {
5490        let source = include_str!("software_text_raster.rs");
5491        let once_lock = ["Once", "Lock"].concat();
5492        let cached_default = ["static ", "FONT"].concat();
5493        let default_font_fn = ["fn ", "default_font()"].concat();
5494
5495        assert!(
5496            !source.contains(&cached_default)
5497                && !source.contains(&default_font_fn)
5498                && !source.contains(&once_lock),
5499            "default software text font construction must be explicit renderer/app-owned state, not a process-global cache"
5500        );
5501    }
5502
5503    #[test]
5504    fn software_text_measurer_empty_font_set_uses_deterministic_fallback_without_panicking() {
5505        let measurer = SoftwareTextMeasurer::from_font_set(SoftwareTextFontSet::empty(), 4);
5506        let style = TextStyle {
5507            span_style: SpanStyle {
5508                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5509                ..Default::default()
5510            },
5511            ..Default::default()
5512        };
5513        let text = AnnotatedString::from("ab\nc");
5514
5515        let metrics = measurer.measure(&text, &style);
5516        assert_eq!(metrics.line_count, 2);
5517        assert!(metrics.width > 0.0);
5518        assert!(metrics.height >= metrics.line_height * 2.0);
5519
5520        let cursor_x = measurer.get_cursor_x_for_offset(&text, &style, 2);
5521        assert!(cursor_x > 0.0);
5522        let second_line_offset =
5523            measurer.get_offset_for_position(&text, &style, 0.0, metrics.line_height);
5524        assert!(
5525            second_line_offset >= "ab\n".len(),
5526            "fallback hit testing should resolve into the second line: {second_line_offset}"
5527        );
5528
5529        let layout = measurer.layout(&text, &style);
5530        assert_eq!(layout.lines.len(), 2);
5531        assert_eq!(layout.glyph_layouts().len(), 3);
5532    }
5533
5534    #[test]
5535    fn software_text_metrics_layout_and_cursor_share_font_backend() {
5536        let font = test_software_font();
5537        let style = TextStyle {
5538            span_style: SpanStyle {
5539                font_size: cranpose_ui::text::TextUnit::Sp(18.0),
5540                ..Default::default()
5541            },
5542            ..Default::default()
5543        };
5544        let text = "Text\nBackend";
5545
5546        let metrics = measure_text_with_font(text, &style, 18.0, &font);
5547        let layout = layout_text_with_font(text, &style, &font);
5548
5549        assert!(metrics.width > 0.0);
5550        assert_eq!(metrics.line_count, 2);
5551        assert_eq!(layout.lines.len(), 2);
5552        assert_eq!(layout.height, metrics.height);
5553        assert!(layout.glyph_layouts().len() >= "TextBackend".len());
5554
5555        let offset =
5556            text_offset_for_position_with_font(text, &style, 0.0, metrics.line_height, &font);
5557        assert!(
5558            offset >= "Text\n".len(),
5559            "second-line hit testing should return a byte offset on the second line: {offset}"
5560        );
5561        let cursor_x = cursor_x_for_offset_with_font(text, &style, "Text".len(), &font);
5562        assert!(cursor_x > 0.0);
5563    }
5564
5565    #[test]
5566    fn software_text_metrics_keep_requested_font_size_for_default_font() {
5567        let font = default_software_text_font().expect("bundled default test font");
5568        let style = TextStyle {
5569            span_style: SpanStyle {
5570                font_size: cranpose_ui::text::TextUnit::Sp(14.0),
5571                ..Default::default()
5572            },
5573            ..Default::default()
5574        };
5575
5576        let metrics = measure_text_with_font("Counter App", &style, 14.0, &font);
5577        assert!(
5578            (metrics.width - 83.16).abs() < 0.05 && (metrics.height - 19.6).abs() < 0.05,
5579            "14sp demo text must use font em metrics, not ab_glyph height units: {metrics:?}"
5580        );
5581    }
5582
5583    #[test]
5584    fn software_text_synthesizes_missing_bold_weight() {
5585        let font = test_software_font();
5586        let normal_style = TextStyle {
5587            span_style: SpanStyle {
5588                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5589                ..Default::default()
5590            },
5591            ..Default::default()
5592        };
5593        let bold_style = TextStyle {
5594            span_style: SpanStyle {
5595                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5596                font_weight: Some(FontWeight::BOLD),
5597                ..Default::default()
5598            },
5599            ..Default::default()
5600        };
5601        let no_synthesis_style = TextStyle {
5602            span_style: SpanStyle {
5603                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5604                font_weight: Some(FontWeight::BOLD),
5605                font_synthesis: Some(FontSynthesis::None),
5606                ..Default::default()
5607            },
5608            ..Default::default()
5609        };
5610
5611        let normal = measure_text_with_font("Save Raster WebP", &normal_style, 20.0, &font);
5612        let synthesized = measure_text_with_font("Save Raster WebP", &bold_style, 20.0, &font);
5613        let disabled = measure_text_with_font("Save Raster WebP", &no_synthesis_style, 20.0, &font);
5614
5615        assert!(
5616            synthesized.width > normal.width * 1.04,
5617            "bold fallback should synthesize heavier advances: normal={normal:?} synthesized={synthesized:?}"
5618        );
5619        assert!(
5620            (disabled.width - normal.width).abs() < 0.01,
5621            "explicit FontSynthesis::None should preserve regular metrics: normal={normal:?} disabled={disabled:?}"
5622        );
5623    }
5624
5625    #[test]
5626    fn rasterized_synthetic_bold_adds_ink_without_changing_line_box() {
5627        let font = test_software_font();
5628        let normal_style = TextStyle {
5629            span_style: SpanStyle {
5630                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5631                ..Default::default()
5632            },
5633            ..Default::default()
5634        };
5635        let bold_style = TextStyle {
5636            span_style: SpanStyle {
5637                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5638                font_weight: Some(FontWeight::BOLD),
5639                ..Default::default()
5640            },
5641            ..Default::default()
5642        };
5643        let normal_metrics = measure_text_with_font("Composer", &normal_style, 20.0, &font);
5644        let bold_metrics = measure_text_with_font("Composer", &bold_style, 20.0, &font);
5645
5646        let normal = rasterize_text_to_image(
5647            "Composer",
5648            Rect {
5649                x: 0.0,
5650                y: 0.0,
5651                width: normal_metrics.width.ceil(),
5652                height: normal_metrics.height.ceil(),
5653            },
5654            &normal_style,
5655            Color::WHITE,
5656            20.0,
5657            1.0,
5658            &font,
5659        )
5660        .expect("normal text image");
5661        let bold = rasterize_text_to_image(
5662            "Composer",
5663            Rect {
5664                x: 0.0,
5665                y: 0.0,
5666                width: bold_metrics.width.ceil(),
5667                height: bold_metrics.height.ceil(),
5668            },
5669            &bold_style,
5670            Color::WHITE,
5671            20.0,
5672            1.0,
5673            &font,
5674        )
5675        .expect("bold text image");
5676
5677        assert_eq!(bold.height(), normal.height());
5678        assert!(
5679            count_ink_pixels(&bold) > count_ink_pixels(&normal),
5680            "synthetic bold should increase rasterized ink coverage"
5681        );
5682    }
5683
5684    #[test]
5685    fn software_text_synthesizes_missing_italic_style() {
5686        let font = test_software_font();
5687        let normal_style = TextStyle {
5688            span_style: SpanStyle {
5689                font_size: cranpose_ui::text::TextUnit::Sp(36.0),
5690                ..Default::default()
5691            },
5692            ..Default::default()
5693        };
5694        let italic_style = TextStyle {
5695            span_style: SpanStyle {
5696                font_size: cranpose_ui::text::TextUnit::Sp(36.0),
5697                font_style: Some(FontStyle::Italic),
5698                ..Default::default()
5699            },
5700            ..Default::default()
5701        };
5702        let no_synthesis_style = TextStyle {
5703            span_style: SpanStyle {
5704                font_size: cranpose_ui::text::TextUnit::Sp(36.0),
5705                font_style: Some(FontStyle::Italic),
5706                font_synthesis: Some(FontSynthesis::None),
5707                ..Default::default()
5708            },
5709            ..Default::default()
5710        };
5711
5712        let normal_metrics = measure_text_with_font("Italic", &normal_style, 36.0, &font);
5713        let italic_metrics = measure_text_with_font("Italic", &italic_style, 36.0, &font);
5714        let disabled_metrics = measure_text_with_font("Italic", &no_synthesis_style, 36.0, &font);
5715
5716        assert!(
5717            italic_metrics.width > normal_metrics.width + 6.0,
5718            "italic fallback should reserve slanted visual overhang: normal={normal_metrics:?} italic={italic_metrics:?}"
5719        );
5720        assert!(
5721            (disabled_metrics.width - normal_metrics.width).abs() < 0.01,
5722            "explicit FontSynthesis::None should preserve regular metrics: normal={normal_metrics:?} disabled={disabled_metrics:?}"
5723        );
5724
5725        let normal = rasterize_text_to_image(
5726            "Italic",
5727            Rect {
5728                x: 0.0,
5729                y: 0.0,
5730                width: normal_metrics.width.ceil(),
5731                height: normal_metrics.height.ceil(),
5732            },
5733            &normal_style,
5734            Color::WHITE,
5735            36.0,
5736            1.0,
5737            &font,
5738        )
5739        .expect("normal text image");
5740        let italic = rasterize_text_to_image(
5741            "Italic",
5742            Rect {
5743                x: 0.0,
5744                y: 0.0,
5745                width: italic_metrics.width.ceil(),
5746                height: italic_metrics.height.ceil(),
5747            },
5748            &italic_style,
5749            Color::WHITE,
5750            36.0,
5751            1.0,
5752            &font,
5753        )
5754        .expect("italic text image");
5755        let disabled = rasterize_text_to_image(
5756            "Italic",
5757            Rect {
5758                x: 0.0,
5759                y: 0.0,
5760                width: disabled_metrics.width.ceil(),
5761                height: disabled_metrics.height.ceil(),
5762            },
5763            &no_synthesis_style,
5764            Color::WHITE,
5765            36.0,
5766            1.0,
5767            &font,
5768        )
5769        .expect("disabled italic text image");
5770
5771        assert_eq!(
5772            normal.pixels(),
5773            disabled.pixels(),
5774            "FontSynthesis::None must not synthesize oblique glyphs"
5775        );
5776        assert!(
5777            vertical_slant_delta(&italic) > vertical_slant_delta(&normal) + 2.0,
5778            "synthetic italic should visibly lean top ink to the right"
5779        );
5780    }
5781
5782    #[test]
5783    fn rasterized_default_text_fills_expected_visual_height() {
5784        let font = default_software_text_font().expect("bundled default test font");
5785        let style = TextStyle {
5786            span_style: SpanStyle {
5787                font_size: cranpose_ui::text::TextUnit::Sp(14.0),
5788                ..Default::default()
5789            },
5790            ..Default::default()
5791        };
5792        let metrics = measure_text_with_font("Counter App", &style, 14.0, &font);
5793        let image = rasterize_text_to_image(
5794            "Counter App",
5795            Rect {
5796                x: 0.0,
5797                y: 0.0,
5798                width: metrics.width.ceil(),
5799                height: metrics.height.ceil(),
5800            },
5801            &style,
5802            Color::WHITE,
5803            14.0,
5804            1.0,
5805            &font,
5806        )
5807        .expect("text image");
5808        let (top, bottom) = ink_y_range(&image).expect("text should contain ink");
5809        let ink_height = bottom - top;
5810
5811        assert!(
5812            ink_height >= 13,
5813            "14sp default text ink should keep visual height parity with the WGPU baseline: top={top} bottom={bottom} image={}x{}",
5814            image.width(),
5815            image.height()
5816        );
5817    }
5818
5819    #[test]
5820    fn software_text_font_selection_preserves_first_complete_default_face() {
5821        let regular =
5822            SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansMerged.ttf").to_vec())
5823                .expect("regular test font should load");
5824        let font = software_text_font_from_fonts_or_default(&[
5825            include_bytes!("../assets/NotoSansMerged.ttf"),
5826            include_bytes!("../assets/NotoSansBold.ttf"),
5827            include_bytes!("../assets/TwemojiMozilla.ttf"),
5828        ])
5829        .expect("font selection should resolve a test font");
5830        let style = TextStyle {
5831            span_style: SpanStyle {
5832                font_size: cranpose_ui::text::TextUnit::Sp(18.0),
5833                ..Default::default()
5834            },
5835            ..Default::default()
5836        };
5837
5838        let regular_metrics = measure_text_with_font("UNDER", &style, 18.0, &regular);
5839        let metrics = measure_text_with_font("UNDER", &style, 18.0, &font);
5840        assert!(
5841            (metrics.width - regular_metrics.width).abs() < 0.01,
5842            "font selection should keep the declared regular face for default text: selected={metrics:?}, regular={regular_metrics:?}"
5843        );
5844    }
5845
5846    #[test]
5847    fn software_text_font_resolution_reuses_cached_font_score() {
5848        let font = test_software_font();
5849        assert!(
5850            font.score.is_complete_default_face(),
5851            "test font should cache complete Latin coverage at load time: supported={} width={}",
5852            font.score.supported_latin_chars,
5853            font.score.latin_sample_width
5854        );
5855
5856        let fonts = SoftwareTextFontSet::from_font(font.clone());
5857        let resolved = fonts
5858            .resolve(&TextStyle {
5859                span_style: SpanStyle {
5860                    font_weight: Some(FontWeight::BOLD),
5861                    ..Default::default()
5862                },
5863                ..Default::default()
5864            })
5865            .expect("font set should resolve a test font");
5866
5867        assert_eq!(
5868            resolved.score.supported_latin_chars,
5869            font.score.supported_latin_chars
5870        );
5871        assert_eq!(
5872            resolved.score.latin_sample_width,
5873            font.score.latin_sample_width
5874        );
5875    }
5876
5877    #[test]
5878    fn software_text_font_set_resolves_requested_weight() {
5879        let fonts = software_text_font_set_from_fonts_or_default(&[
5880            include_bytes!("../assets/NotoSansMerged.ttf"),
5881            include_bytes!("../assets/NotoSansBold.ttf"),
5882            include_bytes!("../assets/TwemojiMozilla.ttf"),
5883        ]);
5884        let regular = fonts
5885            .resolve(&TextStyle::default())
5886            .expect("font set should resolve regular test font");
5887        let bold_style = TextStyle {
5888            span_style: SpanStyle {
5889                font_weight: Some(FontWeight::BOLD),
5890                ..Default::default()
5891            },
5892            ..Default::default()
5893        };
5894        let bold = fonts
5895            .resolve(&bold_style)
5896            .expect("font set should resolve bold test font");
5897
5898        assert_eq!(regular.weight(), FontWeight::NORMAL);
5899        assert_eq!(bold.weight(), FontWeight::BOLD);
5900
5901        let regular_metrics =
5902            measure_text_with_font("Counter App", &TextStyle::default(), 18.0, regular);
5903        let bold_metrics = measure_text_with_font("Counter App", &bold_style, 18.0, bold);
5904        assert!(
5905            bold_metrics.width > regular_metrics.width,
5906            "bold face resolution should affect real text metrics: regular={regular_metrics:?} bold={bold_metrics:?}"
5907        );
5908    }
5909
5910    fn registered_face(family: &FontFamily, weight: FontWeight) -> SoftwareTextFont {
5911        SoftwareTextFont::from_registered_bytes(
5912            family,
5913            weight,
5914            FontStyle::Normal,
5915            include_bytes!("../assets/NotoSansMerged.ttf").to_vec(),
5916        )
5917        .expect("registered test face")
5918    }
5919
5920    fn style_naming(family: &FontFamily) -> TextStyle {
5921        TextStyle {
5922            span_style: SpanStyle {
5923                font_family: Some(family.clone()),
5924                ..Default::default()
5925            },
5926            ..Default::default()
5927        }
5928    }
5929
5930    fn unregistered_face() -> SoftwareTextFont {
5931        SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansBold.ttf").to_vec())
5932            .expect("unregistered test face")
5933    }
5934
5935    #[test]
5936    fn a_named_family_resolves_the_face_registered_under_it() {
5937        // The file's own `name` table says Noto Sans; the app filed it as
5938        // "Game UI", and asking for that has to find it.
5939        let family = FontFamily::named("Game UI");
5940        let fonts = SoftwareTextFontSet::from_faces(vec![
5941            unregistered_face(),
5942            registered_face(&family, FontWeight::NORMAL),
5943        ]);
5944
5945        let resolved = fonts
5946            .resolve(&style_naming(&family))
5947            .expect("registered face");
5948        assert_eq!(
5949            resolved.registered_family(),
5950            Some(FontFamilyKey::of(&family))
5951        );
5952    }
5953
5954    #[test]
5955    fn a_file_backed_family_never_resolves_a_face_filed_under_another_one() {
5956        let mine = FontFamily::loaded_typeface_path("/fonts/Mine.ttf");
5957        let theirs = FontFamily::loaded_typeface_path("/fonts/Theirs.ttf");
5958        let fallback =
5959            SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansMerged.ttf").to_vec())
5960                .expect("fallback test face");
5961        let theirs_face = SoftwareTextFont::from_registered_bytes(
5962            &theirs,
5963            FontWeight::BOLD,
5964            FontStyle::Normal,
5965            include_bytes!("../assets/NotoSansBold.ttf").to_vec(),
5966        )
5967        .expect("registered test face");
5968        let fonts = SoftwareTextFontSet::from_faces(vec![fallback.clone(), theirs_face]);
5969
5970        assert_eq!(
5971            fonts
5972                .resolve(&style_naming(&mine))
5973                .expect("fallback face")
5974                .content_hash(),
5975            fallback.content_hash(),
5976            "an unregistered family must fall back rather than borrow someone else's face"
5977        );
5978        assert_eq!(
5979            fonts
5980                .resolve(&style_naming(&theirs))
5981                .expect("registered face")
5982                .registered_family(),
5983            Some(FontFamilyKey::of(&theirs)),
5984            "the family that was registered still resolves to its own face"
5985        );
5986    }
5987
5988    #[test]
5989    fn a_generic_family_only_constrains_the_set_once_a_face_is_registered_for_it() {
5990        let bold_sans_serif = TextStyle {
5991            span_style: SpanStyle {
5992                font_family: Some(FontFamily::SansSerif),
5993                font_weight: Some(FontWeight::BOLD),
5994                ..Default::default()
5995            },
5996            ..Default::default()
5997        };
5998
5999        // Nothing claims `sans-serif`, so weight matching still runs over the
6000        // whole set the way it did before app-supplied families existed.
6001        let unclaimed = software_text_font_set_from_fonts_or_default(&[
6002            include_bytes!("../assets/NotoSansMerged.ttf"),
6003            include_bytes!("../assets/NotoSansBold.ttf"),
6004        ]);
6005        assert_eq!(
6006            unclaimed
6007                .resolve(&bold_sans_serif)
6008                .expect("bold face")
6009                .weight(),
6010            FontWeight::BOLD
6011        );
6012
6013        // Once a face is filed under `sans-serif` it wins, because that is what
6014        // the app said the alias means.
6015        let claimed = SoftwareTextFontSet::from_faces(vec![
6016            SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansBold.ttf").to_vec())
6017                .expect("bold test face"),
6018            registered_face(&FontFamily::SansSerif, FontWeight::NORMAL),
6019        ]);
6020        let resolved = claimed.resolve(&bold_sans_serif).expect("system face");
6021        assert_eq!(
6022            resolved.registered_family(),
6023            Some(FontFamilyKey::of(&FontFamily::SansSerif))
6024        );
6025    }
6026
6027    #[test]
6028    fn an_app_supplied_family_measures_once_and_is_served_from_the_metrics_cache() {
6029        let family = FontFamily::named("Game UI");
6030        let measurer = SoftwareTextMeasurer::from_font_set(
6031            SoftwareTextFontSet::from_faces(vec![registered_face(&family, FontWeight::NORMAL)]),
6032            64,
6033        );
6034        let style = style_naming(&family);
6035        let text = AnnotatedString::from("SCORE 1234");
6036
6037        let first = measurer.measure(&text, &style);
6038        let stats_after_first = measurer.lock_cache().glyph_metrics.stats();
6039        for _ in 0..60 {
6040            assert_eq!(measurer.measure(&text, &style), first);
6041        }
6042
6043        assert_eq!(
6044            measurer.lock_cache().glyph_metrics.stats(),
6045            stats_after_first,
6046            "repeat frames of an unchanged string must not re-shape against the app face"
6047        );
6048    }
6049
6050    #[test]
6051    fn a_font_size_animation_measures_each_glyph_once_rather_than_once_per_size() {
6052        let font = default_software_text_font().expect("bundled default test font");
6053        let measurer = SoftwareTextMeasurer::new(font, 64);
6054        let text = AnnotatedString::from("Scaling list row");
6055
6056        let sized = |size: f32| TextStyle {
6057            span_style: SpanStyle {
6058                font_size: cranpose_ui::text::TextUnit::Sp(size),
6059                ..Default::default()
6060            },
6061            ..Default::default()
6062        };
6063
6064        // One size to fill the cache with this string's glyphs and pairs.
6065        let first = measurer.measure(&text, &sized(14.0));
6066        let after_first = measurer.lock_cache().glyph_metrics.stats();
6067
6068        // A scaling list re-draws the same rows at a new size every frame. Font
6069        // metrics are linear in the size, so nothing here is new work: what the
6070        // cache holds is font units, and only the multiply changes.
6071        for step in 0..120 {
6072            let size = 14.0 + step as f32 * 0.137;
6073            let measured = measurer.measure(&text, &sized(size));
6074            assert!(
6075                measured.width > 0.0,
6076                "a scaled measurement must still produce a width"
6077            );
6078        }
6079
6080        // Hits climb, which is the point. What must not move is the misses:
6081        // every one of those is a font-table read, and keying by size made them
6082        // grow with every frame a scaling list drew.
6083        let after_scaling = measurer.lock_cache().glyph_metrics.stats();
6084        assert_eq!(
6085            (after_scaling.glyph_misses, after_scaling.kern_misses),
6086            (after_first.glyph_misses, after_first.kern_misses),
6087            "measuring the same glyphs at a new size must not re-read the font: {after_scaling:?}"
6088        );
6089        assert!(
6090            after_scaling.glyph_hits > after_first.glyph_hits,
6091            "the scaled measurements must have come from the cache"
6092        );
6093
6094        // And the scaling stays honest: twice the size is twice the advance.
6095        let single = measurer.measure(&AnnotatedString::from("W"), &sized(20.0));
6096        let double = measurer.measure(&AnnotatedString::from("W"), &sized(40.0));
6097        let ratio = double.width / single.width.max(f32::EPSILON);
6098        assert!(
6099            (ratio - 2.0).abs() < 0.01,
6100            "advances must scale with the font size: {single:?} -> {double:?} (ratio {ratio})"
6101        );
6102        let _ = first;
6103    }
6104
6105    #[test]
6106    fn software_text_metrics_use_largest_annotated_span_font_size() {
6107        let font = default_software_text_font().expect("bundled default test font");
6108        let text = AnnotatedString::builder()
6109            .push_style(SpanStyle {
6110                font_size: cranpose_ui::text::TextUnit::Sp(30.0),
6111                ..Default::default()
6112            })
6113            .append("BIG ")
6114            .pop()
6115            .push_style(SpanStyle {
6116                font_size: cranpose_ui::text::TextUnit::Sp(10.0),
6117                ..Default::default()
6118            })
6119            .append("small")
6120            .pop()
6121            .to_annotated_string();
6122
6123        let metrics = measure_annotated_text_with_font(&text, &TextStyle::default(), 14.0, &font);
6124
6125        assert!(
6126            metrics.height >= 30.0,
6127            "rich text metrics must include the largest span height: {metrics:?}"
6128        );
6129        assert!(
6130            metrics.width > 48.0,
6131            "rich text metrics should measure run widths at their span sizes: {metrics:?}"
6132        );
6133    }
6134
6135    #[test]
6136    fn software_text_line_height_matches_full_measurement_without_width_layout() {
6137        let measurer = SoftwareTextMeasurer::new(
6138            default_software_text_font().expect("bundled default test font"),
6139            8,
6140        );
6141        let text = AnnotatedString::builder()
6142            .append("normal ")
6143            .push_style(SpanStyle {
6144                font_size: cranpose_ui::text::TextUnit::Sp(32.0),
6145                ..Default::default()
6146            })
6147            .append("large")
6148            .pop()
6149            .append("\nsecond line")
6150            .to_annotated_string();
6151        let style = TextStyle::default();
6152
6153        let measured = measurer.measure(&text, &style);
6154        let line_height = measurer.line_height(&text, &style);
6155
6156        assert_eq!(line_height, measured.line_height);
6157        assert!(
6158            line_height > measurer.line_height(&AnnotatedString::from("normal"), &style),
6159            "span font size should affect fast line-height lookup"
6160        );
6161    }
6162
6163    #[test]
6164    fn solid_text_atlas_line_advance_matches_measured_line_height() {
6165        let font = default_software_text_font().expect("bundled default test font");
6166        let fonts = SoftwareTextFontSet::from_font(font);
6167        let style = TextStyle::default();
6168        let text = AnnotatedString::from("A\nA\nA\nA");
6169        let font_size = style.resolve_font_size(14.0);
6170        let metrics = measure_annotated_text_with_font_set(&text, &style, font_size, &fonts);
6171        let rect = Rect {
6172            x: 0.0,
6173            y: 0.0,
6174            width: 120.0,
6175            height: metrics.height,
6176        };
6177        let mut glyph_cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(16);
6178        let mut run = Vec::new();
6179
6180        collect_solid_text_atlas_run(
6181            &text,
6182            rect,
6183            &style,
6184            Color(1.0, 1.0, 1.0, 1.0),
6185            font_size,
6186            1.0,
6187            &fonts,
6188            &mut glyph_cache,
6189            &mut run,
6190        )
6191        .expect("atlas-compatible text");
6192
6193        let mut glyph_y: Vec<i32> = run.iter().map(|glyph| glyph.placement().y).collect();
6194        glyph_y.sort_unstable();
6195        glyph_y.dedup();
6196        assert_eq!(glyph_y.len(), 4);
6197        for window in glyph_y.windows(2) {
6198            let advance = (window[1] - window[0]) as f32;
6199            assert!(
6200                (advance - metrics.line_height).abs() <= 1.0,
6201                "glyph advance {advance} should match measured line height {}",
6202                metrics.line_height
6203            );
6204        }
6205    }
6206
6207    #[test]
6208    fn software_text_metrics_cache_keys_include_span_styles() {
6209        let measurer = SoftwareTextMeasurer::new(
6210            default_software_text_font().expect("bundled default test font"),
6211            8,
6212        );
6213        let plain = AnnotatedString::from("BIG small");
6214        let rich = AnnotatedString::builder()
6215            .push_style(SpanStyle {
6216                font_size: cranpose_ui::text::TextUnit::Sp(30.0),
6217                ..Default::default()
6218            })
6219            .append("BIG ")
6220            .pop()
6221            .append("small")
6222            .to_annotated_string();
6223
6224        let plain_metrics = measurer.measure(&plain, &TextStyle::default());
6225        let rich_metrics = measurer.measure(&rich, &TextStyle::default());
6226
6227        assert!(
6228            rich_metrics.height > plain_metrics.height,
6229            "cached plain text metrics must not be reused for styled text: plain={plain_metrics:?} rich={rich_metrics:?}"
6230        );
6231    }
6232
6233    #[test]
6234    fn software_text_metrics_cache_recovers_after_poison() {
6235        let measurer = SoftwareTextMeasurer::new(
6236            default_software_text_font().expect("bundled default test font"),
6237            8,
6238        );
6239        let text = AnnotatedString::from("Recovered text metrics");
6240
6241        let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6242            let _guard = measurer
6243                .cache
6244                .lock()
6245                .unwrap_or_else(|poisoned| poisoned.into_inner());
6246            panic!("poison software text metrics cache for recovery test");
6247        }));
6248
6249        assert!(poison_result.is_err());
6250
6251        let metrics = measurer.measure(&text, &TextStyle::default());
6252        assert!(metrics.width > 0.0);
6253        assert!(metrics.height > 0.0);
6254
6255        let subset =
6256            measurer.measure_subsequence(&text, 0.."Recovered".len(), &TextStyle::default());
6257        assert!(subset.width > 0.0);
6258        assert!(subset.width < metrics.width);
6259    }
6260
6261    #[test]
6262    fn software_text_prefix_widths_match_subsequence_measurement() {
6263        let measurer = SoftwareTextMeasurer::new(
6264            default_software_text_font().expect("bundled default test font"),
6265            8,
6266        );
6267        let style = TextStyle {
6268            span_style: SpanStyle {
6269                font_size: cranpose_ui::text::TextUnit::Sp(18.0),
6270                ..Default::default()
6271            },
6272            ..Default::default()
6273        };
6274        let text = AnnotatedString::from("Hello Prefix Widths");
6275        let widths = measurer
6276            .measure_line_prefix_widths(&text, 0..text.text.len(), &style)
6277            .expect("uniform line should expose prefix widths");
6278
6279        let start = "Hello ".len();
6280        let end = "Hello Prefix".len();
6281        let expected = measurer
6282            .measure_subsequence(&text, start..end, &style)
6283            .width;
6284        let actual = widths
6285            .width_for_char_range(6, 12)
6286            .expect("valid char range");
6287
6288        assert!(
6289            (actual - expected).abs() < 0.01,
6290            "prefix width should match exact subsequence width: actual={actual}, expected={expected}"
6291        );
6292    }
6293
6294    #[test]
6295    fn software_text_line_width_and_prefix_width_share_cached_plan() {
6296        let measurer = SoftwareTextMeasurer::new(
6297            default_software_text_font().expect("bundled default test font"),
6298            8,
6299        );
6300        let style = TextStyle::default();
6301        let text = AnnotatedString::from("shared prefix plan ".repeat(32).as_str());
6302        let line_range = 0..text.text.len();
6303
6304        let width = measurer
6305            .measure_line_width(&text, line_range.clone(), &style)
6306            .expect("software text should expose a line width");
6307        let stats_after_width = {
6308            let cache = measurer.lock_cache();
6309            assert_eq!(cache.line_prefix_widths.len(), 1);
6310            cache.glyph_metrics.stats()
6311        };
6312
6313        let widths = measurer
6314            .measure_line_prefix_widths(&text, line_range, &style)
6315            .expect("line width probe should cache the prefix plan");
6316        let stats_after_prefix = measurer.lock_cache().glyph_metrics.stats();
6317
6318        assert_eq!(stats_after_prefix, stats_after_width);
6319        assert!(
6320            (width - widths.width_for_char_range(0, widths.char_count()).unwrap()).abs() < 0.01,
6321            "cached line-width probe and prefix plan must agree"
6322        );
6323    }
6324
6325    #[test]
6326    fn software_text_glyph_metrics_cache_reuses_common_glyphs_across_unique_lines() {
6327        let measurer = SoftwareTextMeasurer::new(
6328            default_software_text_font().expect("bundled default test font"),
6329            8,
6330        );
6331        let style = TextStyle::default();
6332        let first = AnnotatedString::from("algorithm data structure ".repeat(24).as_str());
6333        let second =
6334            AnnotatedString::from("algorithmic structures repeat data ".repeat(24).as_str());
6335
6336        measurer
6337            .measure_line_prefix_widths(&first, 0..first.text.len(), &style)
6338            .expect("first unique line should measure");
6339        let stats_after_first = measurer.lock_cache().glyph_metrics.stats();
6340
6341        measurer
6342            .measure_line_prefix_widths(&second, 0..second.text.len(), &style)
6343            .expect("second unique line should measure");
6344        let stats_after_second = measurer.lock_cache().glyph_metrics.stats();
6345
6346        assert!(
6347            stats_after_second.glyph_hits > stats_after_first.glyph_hits,
6348            "unique markdown rows should reuse retained glyph metrics: first={stats_after_first:?} second={stats_after_second:?}"
6349        );
6350        assert!(
6351            stats_after_second.kern_hits > stats_after_first.kern_hits,
6352            "unique markdown rows should reuse retained kerning metrics: first={stats_after_first:?} second={stats_after_second:?}"
6353        );
6354    }
6355
6356    #[test]
6357    fn rasterized_gradient_text_shows_color_transition() {
6358        let font = test_font();
6359        // Use a gradient sized to the rendered text width so left=red, right=blue.
6360        // We first do a plain measurement pass to know the text width.
6361        let plain_style = TextStyle::default();
6362        let probe = rasterize_text_to_image_with_font(
6363            "MMMMMMMM",
6364            Rect {
6365                x: 0.0,
6366                y: 0.0,
6367                width: 320.0,
6368                height: 96.0,
6369            },
6370            &plain_style,
6371            Color::WHITE,
6372            48.0,
6373            1.0,
6374            &font,
6375        )
6376        .expect("probe image");
6377        let (ink_x_min, ink_x_max) = ink_x_range(&probe).expect("probe must contain ink");
6378        let gradient_end = ink_x_max as f32;
6379
6380        let style = TextStyle {
6381            span_style: SpanStyle {
6382                brush: Some(Brush::linear_gradient_range(
6383                    vec![Color::RED, Color::BLUE],
6384                    Point::new(0.0, 0.0),
6385                    Point::new(gradient_end, 0.0),
6386                )),
6387                ..Default::default()
6388            },
6389            ..Default::default()
6390        };
6391
6392        let image = rasterize_text_to_image_with_font(
6393            "MMMMMMMM",
6394            Rect {
6395                x: 0.0,
6396                y: 0.0,
6397                width: 320.0,
6398                height: 96.0,
6399            },
6400            &style,
6401            Color::WHITE,
6402            48.0,
6403            1.0,
6404            &font,
6405        )
6406        .expect("rasterized image");
6407
6408        let ink_span = ink_x_max.saturating_sub(ink_x_min).max(1);
6409        let left_end = ink_x_min + ink_span * 3 / 10;
6410        let right_start = ink_x_max.saturating_sub(ink_span * 3 / 10);
6411        let left = average_ink_rgb(&image, ink_x_min, left_end, 8, 90).expect("left ink");
6412        let right = average_ink_rgb(&image, right_start, ink_x_max, 8, 90).expect("right ink");
6413        assert!(
6414            left[0] > left[2] * 1.1,
6415            "left region should be red dominant, got {left:?}"
6416        );
6417        assert!(
6418            right[2] > right[0] * 1.1,
6419            "right region should be blue dominant, got {right:?}"
6420        );
6421    }
6422
6423    #[test]
6424    fn rasterized_stroke_and_fill_ink_coverage_differs() {
6425        let font = test_font();
6426        let fill_style = TextStyle::default();
6427        let stroke_style = TextStyle {
6428            span_style: SpanStyle {
6429                draw_style: Some(TextDrawStyle::Stroke { width: 6.0 }),
6430                ..Default::default()
6431            },
6432            ..Default::default()
6433        };
6434        let rect = Rect {
6435            x: 0.0,
6436            y: 0.0,
6437            width: 320.0,
6438            height: 96.0,
6439        };
6440
6441        let fill = rasterize_text_to_image_with_font(
6442            "MMMMMMMM",
6443            rect,
6444            &fill_style,
6445            Color::WHITE,
6446            48.0,
6447            1.0,
6448            &font,
6449        )
6450        .expect("fill image");
6451        let stroke = rasterize_text_to_image_with_font(
6452            "MMMMMMMM",
6453            rect,
6454            &stroke_style,
6455            Color::WHITE,
6456            48.0,
6457            1.0,
6458            &font,
6459        )
6460        .expect("stroke image");
6461
6462        let fill_ink = count_ink_pixels(&fill);
6463        let stroke_ink = count_ink_pixels(&stroke);
6464        assert_ne!(fill.pixels(), stroke.pixels());
6465        assert!(
6466            fill_ink.abs_diff(stroke_ink) > 300,
6467            "fill/stroke ink coverage should differ; fill={fill_ink}, stroke={stroke_ink}"
6468        );
6469    }
6470
6471    #[test]
6472    fn stroke_path_uses_miter_join_for_acute_apexes() {
6473        let font = test_font();
6474        let fill_style = TextStyle::default();
6475        let stroke_width = 12.0;
6476        let stroke_style = TextStyle {
6477            span_style: SpanStyle {
6478                draw_style: Some(TextDrawStyle::Stroke {
6479                    width: stroke_width,
6480                }),
6481                ..Default::default()
6482            },
6483            ..Default::default()
6484        };
6485        let rect = Rect {
6486            x: 0.0,
6487            y: 0.0,
6488            width: 180.0,
6489            height: 140.0,
6490        };
6491
6492        let fill = rasterize_text_to_image_with_font(
6493            "A",
6494            rect,
6495            &fill_style,
6496            Color::WHITE,
6497            110.0,
6498            1.0,
6499            &font,
6500        )
6501        .expect("fill image");
6502        let stroke = rasterize_text_to_image_with_font(
6503            "A",
6504            rect,
6505            &stroke_style,
6506            Color::WHITE,
6507            110.0,
6508            1.0,
6509            &font,
6510        )
6511        .expect("stroke image");
6512
6513        let fill_top = top_ink_row(&fill).expect("fill top row");
6514        let stroke_top = top_ink_row(&stroke).expect("stroke top row");
6515        let reference_dilation =
6516            rasterize_reference_dilation_stroke("A", rect, 110.0, stroke_width, &font);
6517        let reference_top = top_ink_row(&reference_dilation).expect("reference top row");
6518        let extra_extension = fill_top.saturating_sub(stroke_top) as f32;
6519        let half_stroke = stroke_width * 0.5;
6520        assert!(
6521            extra_extension >= half_stroke - 0.25,
6522            "stroke apex should extend by roughly at least half stroke width; fill_top={fill_top}, stroke_top={stroke_top}, half_stroke={half_stroke:.2}"
6523        );
6524        assert!(
6525            stroke.pixels() != reference_dilation.pixels(),
6526            "path stroke should diverge from mask-dilation reference output"
6527        );
6528        assert!(
6529            stroke_top <= reference_top,
6530            "miter stroke should keep acute apex at least as extended as mask-dilation reference; stroke_top={stroke_top}, reference_top={reference_top}"
6531        );
6532    }
6533
6534    #[test]
6535    fn shadow_blur_radius_changes_spread_for_shared_raster_path() {
6536        let font = test_font();
6537        let base_shadow = Shadow {
6538            color: Color(0.0, 0.0, 0.0, 0.9),
6539            offset: Point::new(5.5, 4.25),
6540            blur_radius: 0.0,
6541        };
6542        let hard_shadow_style = TextStyle {
6543            span_style: SpanStyle {
6544                shadow: Some(base_shadow),
6545                ..Default::default()
6546            },
6547            ..Default::default()
6548        };
6549        let blurred_shadow_style = TextStyle {
6550            span_style: SpanStyle {
6551                shadow: Some(Shadow {
6552                    blur_radius: 9.0,
6553                    ..base_shadow
6554                }),
6555                ..Default::default()
6556            },
6557            ..Default::default()
6558        };
6559        let rect = Rect {
6560            x: 0.0,
6561            y: 0.0,
6562            width: 320.0,
6563            height: 120.0,
6564        };
6565
6566        let hard_shadow = rasterize_text_to_image_with_font(
6567            "Shared shadow",
6568            rect,
6569            &hard_shadow_style,
6570            Color::TRANSPARENT,
6571            48.0,
6572            1.0,
6573            &font,
6574        )
6575        .expect("hard shadow image");
6576        let blurred_shadow = rasterize_text_to_image_with_font(
6577            "Shared shadow",
6578            rect,
6579            &blurred_shadow_style,
6580            Color::TRANSPARENT,
6581            48.0,
6582            1.0,
6583            &font,
6584        )
6585        .expect("blurred shadow image");
6586
6587        let hard_ink = count_ink_pixels(&hard_shadow);
6588        let blurred_ink = count_ink_pixels(&blurred_shadow);
6589        assert_ne!(
6590            hard_shadow.pixels(),
6591            blurred_shadow.pixels(),
6592            "blur radius should change rasterized shadow output"
6593        );
6594        assert!(
6595            blurred_ink > hard_ink,
6596            "blurred shadow should spread to more pixels; hard={hard_ink}, blurred={blurred_ink}"
6597        );
6598    }
6599
6600    #[test]
6601    fn text_motion_changes_fractional_shadow_sampling() {
6602        let font = test_font();
6603        let base_shadow = Shadow {
6604            color: Color(0.0, 0.0, 0.0, 0.9),
6605            offset: Point::new(3.35, 2.65),
6606            blur_radius: 6.0,
6607        };
6608        let static_style = TextStyle {
6609            span_style: SpanStyle {
6610                shadow: Some(base_shadow),
6611                ..Default::default()
6612            },
6613            paragraph_style: cranpose_ui::text::ParagraphStyle {
6614                text_motion: Some(TextMotion::Static),
6615                ..Default::default()
6616            },
6617        };
6618        let animated_style = TextStyle {
6619            span_style: SpanStyle {
6620                shadow: Some(base_shadow),
6621                ..Default::default()
6622            },
6623            paragraph_style: cranpose_ui::text::ParagraphStyle {
6624                text_motion: Some(TextMotion::Animated),
6625                ..Default::default()
6626            },
6627        };
6628        let rect = Rect {
6629            x: 11.35,
6630            y: 7.65,
6631            width: 280.0,
6632            height: 120.0,
6633        };
6634
6635        let static_image = rasterize_text_to_image_with_font(
6636            "Motion shadow",
6637            rect,
6638            &static_style,
6639            Color::TRANSPARENT,
6640            42.0,
6641            1.0,
6642            &font,
6643        )
6644        .expect("static image");
6645        let animated_image = rasterize_text_to_image_with_font(
6646            "Motion shadow",
6647            rect,
6648            &animated_style,
6649            Color::TRANSPARENT,
6650            42.0,
6651            1.0,
6652            &font,
6653        )
6654        .expect("animated image");
6655
6656        assert_ne!(
6657            static_image.pixels(),
6658            animated_image.pixels(),
6659            "TextMotion::Static should quantize shadow placement while Animated keeps fractional sampling"
6660        );
6661    }
6662
6663    #[test]
6664    fn static_text_motion_aligns_glyph_positions_to_pixel_grid() {
6665        let font = test_font();
6666        let base_glyph = layout_line_glyphs(&font, "A", 17.0, point(0.0, 13.37))
6667            .into_iter()
6668            .next()
6669            .expect("glyph");
6670        let static_aligned = align_glyph_for_text_motion(base_glyph, true);
6671        let static_position = static_aligned.position;
6672        assert!(
6673            (static_position.x - static_position.x.round()).abs() < f32::EPSILON,
6674            "static text should snap glyph x to pixel grid"
6675        );
6676        assert!(
6677            (static_position.y - static_position.y.round()).abs() < f32::EPSILON,
6678            "static text should snap glyph y to pixel grid"
6679        );
6680
6681        let animated_source = layout_line_glyphs(&font, "A", 17.0, point(0.0, 13.37))
6682            .into_iter()
6683            .next()
6684            .expect("glyph");
6685        let animated_aligned = align_glyph_for_text_motion(animated_source, false);
6686        let animated_position = animated_aligned.position;
6687        assert!(
6688            (animated_position.y - 13.37).abs() < 1e-3,
6689            "animated text should preserve fractional glyph position"
6690        );
6691    }
6692}
6693
6694#[cfg(test)]
6695mod line_alignment_tests {
6696    use super::*;
6697    use cranpose_ui::text::{ParagraphStyle, TextAlign};
6698
6699    /// The x range of every non-transparent pixel on the rows a line occupies.
6700    fn ink_columns(image: &ImageBitmap, rows: std::ops::Range<u32>) -> Option<(u32, u32)> {
6701        let width = image.width();
6702        let pixels = image.pixels();
6703        let mut min = u32::MAX;
6704        let mut max = 0u32;
6705        for y in rows {
6706            for x in 0..width {
6707                let index = ((y * width + x) * 4 + 3) as usize;
6708                if pixels.get(index).copied().unwrap_or(0) > 0 {
6709                    min = min.min(x);
6710                    max = max.max(x);
6711                }
6712            }
6713        }
6714        (min != u32::MAX).then_some((min, max))
6715    }
6716
6717    fn centred_style(align: TextAlign) -> TextStyle {
6718        TextStyle {
6719            paragraph_style: ParagraphStyle {
6720                text_align: align,
6721                ..ParagraphStyle::default()
6722            },
6723            ..TextStyle::default()
6724        }
6725    }
6726
6727    #[test]
6728    fn a_wrapped_line_is_centred_under_the_one_above_it_not_left_under_it() {
6729        // `TextAlign` is a paragraph property and Compose applies it to every
6730        // line. The scene builder centres the block; without a per-line offset
6731        // as well, the short second line sits hard against the left of the
6732        // block -- which is exactly what the Credits screen's
6733        // "Designed and built for Wear / OS." showed.
6734        let font = default_software_text_font().expect("bundled default font");
6735        let rect = Rect {
6736            x: 0.0,
6737            y: 0.0,
6738            width: 400.0,
6739            height: 80.0,
6740        };
6741        let text = "wwwwwwwwwwww\nww";
6742        let image = rasterize_text_to_image(
6743            text,
6744            rect,
6745            &centred_style(TextAlign::Center),
6746            Color(1.0, 1.0, 1.0, 1.0),
6747            20.0,
6748            1.0,
6749            &font,
6750        )
6751        .expect("centred image");
6752        let long = ink_columns(&image, 0..(image.height() / 2)).expect("first line ink");
6753        let short =
6754            ink_columns(&image, (image.height() / 2)..image.height()).expect("second line ink");
6755        let long_centre = (long.0 + long.1) as f32 * 0.5;
6756        let short_centre = (short.0 + short.1) as f32 * 0.5;
6757        assert!(
6758            (long_centre - short_centre).abs() <= 2.0,
6759            "the two lines should share a centre: {long:?} vs {short:?}"
6760        );
6761        assert!(
6762            short.0 > long.0 + 4,
6763            "the short line must not start where the long one does: {long:?} vs {short:?}"
6764        );
6765    }
6766
6767    #[test]
6768    fn the_atlas_run_centres_each_line_of_a_wrapped_block() {
6769        // The path an app on wgpu actually takes. It cuts the block into
6770        // segments at span boundaries AND at newlines and drives its own
6771        // cursor, so the per-line offset has to be applied there and not
6772        // inside the glyph loop -- a segment handed to the glyph loop never
6773        // contains a newline, so a rule written there never fires. This test
6774        // is the difference between the two: it was green against the glyph
6775        // loop and the watch still drew "OS." hard left.
6776        let font = default_software_text_font().expect("bundled default font");
6777        let fonts = SoftwareTextFontSet::from_font(font);
6778        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(256);
6779        let rect = Rect {
6780            x: 0.0,
6781            y: 0.0,
6782            width: 400.0,
6783            height: 80.0,
6784        };
6785        let text = AnnotatedString::from("wwwwwwwwwwww\nww".to_string());
6786
6787        let mut centred = Vec::new();
6788        collect_solid_text_atlas_run(
6789            &text,
6790            rect,
6791            &centred_style(TextAlign::Center),
6792            Color(1.0, 1.0, 1.0, 1.0),
6793            20.0,
6794            1.0,
6795            &fonts,
6796            &mut cache,
6797            &mut centred,
6798        )
6799        .expect("centred run");
6800        let mut flush = Vec::new();
6801        collect_solid_text_atlas_run(
6802            &text,
6803            rect,
6804            &centred_style(TextAlign::Start),
6805            Color(1.0, 1.0, 1.0, 1.0),
6806            20.0,
6807            1.0,
6808            &fonts,
6809            &mut cache,
6810            &mut flush,
6811        )
6812        .expect("start aligned run");
6813
6814        let second_line_start = |glyphs: &[SoftwareGlyphAtlasRunGlyph]| {
6815            let placements: Vec<_> = glyphs.iter().map(|glyph| glyph.placement()).collect();
6816            let baseline = placements.iter().map(|p| p.y).max().expect("glyphs");
6817            placements
6818                .iter()
6819                .filter(|p| p.y == baseline)
6820                .map(|p| p.x)
6821                .min()
6822                .expect("second line")
6823        };
6824        assert_eq!(
6825            second_line_start(&flush),
6826            0,
6827            "a start-aligned second line begins at the block's left edge"
6828        );
6829        assert!(
6830            second_line_start(&centred) > 40,
6831            "a centred second line is indented by half the slack, was {}",
6832            second_line_start(&centred)
6833        );
6834    }
6835
6836    #[test]
6837    fn a_start_aligned_paragraph_still_stacks_its_lines_flush_left() {
6838        let font = default_software_text_font().expect("bundled default font");
6839        let rect = Rect {
6840            x: 0.0,
6841            y: 0.0,
6842            width: 400.0,
6843            height: 80.0,
6844        };
6845        let image = rasterize_text_to_image(
6846            "wwwwwwwwwwww\nww",
6847            rect,
6848            &centred_style(TextAlign::Start),
6849            Color(1.0, 1.0, 1.0, 1.0),
6850            20.0,
6851            1.0,
6852            &font,
6853        )
6854        .expect("start aligned image");
6855        let long = ink_columns(&image, 0..(image.height() / 2)).expect("first line ink");
6856        let short =
6857            ink_columns(&image, (image.height() / 2)..image.height()).expect("second line ink");
6858        assert!(
6859            short.0.abs_diff(long.0) <= 1,
6860            "start-aligned lines share a left edge: {long:?} vs {short:?}"
6861        );
6862    }
6863}