Skip to main content

cranpose_render_common/
software_text_raster.rs

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