Skip to main content

cranpose_render_common/
software_text_raster.rs

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