Skip to main content

cranpose_render_common/
software_text_raster.rs

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