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