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