Skip to main content

cranpose_render_common/
software_text_raster.rs

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