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