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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
684struct FontScaleMetricsKey {
685    font_hash: u64,
686    glyph_font_size_bits: u32,
687}
688
689#[derive(Clone, Copy, Debug)]
690struct CachedGlyphMetrics {
691    glyph_id: GlyphId,
692    advance: f32,
693}
694
695#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
696struct GlyphMetricsKey {
697    font: FontScaleMetricsKey,
698    ch: char,
699}
700
701#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
702struct KernMetricsKey {
703    font: FontScaleMetricsKey,
704    previous_id: u32,
705    glyph_id: u32,
706}
707
708#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
709struct SoftwareTextGlyphMetricsStats {
710    glyph_hits: u64,
711    glyph_misses: u64,
712    kern_hits: u64,
713    kern_misses: u64,
714}
715
716struct SoftwareTextGlyphMetricsCache {
717    glyphs: BoundedLruCache<GlyphMetricsKey, CachedGlyphMetrics>,
718    kerns: BoundedLruCache<KernMetricsKey, f32>,
719    stats: SoftwareTextGlyphMetricsStats,
720}
721
722impl SoftwareTextGlyphMetricsCache {
723    fn new() -> Self {
724        Self {
725            glyphs: BoundedLruCache::with_capacity_at_least_one(
726                SOFTWARE_TEXT_GLYPH_METRICS_CACHE_CAPACITY,
727            ),
728            kerns: BoundedLruCache::with_capacity_at_least_one(
729                SOFTWARE_TEXT_KERN_METRICS_CACHE_CAPACITY,
730            ),
731            stats: SoftwareTextGlyphMetricsStats::default(),
732        }
733    }
734
735    #[cfg(test)]
736    fn stats(&self) -> SoftwareTextGlyphMetricsStats {
737        self.stats
738    }
739
740    fn glyph_metrics<F, S>(
741        &mut self,
742        font: &SoftwareTextFont,
743        glyph_font_size: f32,
744        scaled_font: &S,
745        ch: char,
746    ) -> CachedGlyphMetrics
747    where
748        F: Font,
749        S: ScaleFont<F>,
750    {
751        let font_key = FontScaleMetricsKey {
752            font_hash: font.content_hash(),
753            glyph_font_size_bits: glyph_font_size.to_bits(),
754        };
755        let key = GlyphMetricsKey { font: font_key, ch };
756        if let Some(metrics) = self.glyphs.get(&key).copied() {
757            self.stats.glyph_hits = self.stats.glyph_hits.saturating_add(1);
758            return metrics;
759        }
760
761        let glyph_id = scaled_font.glyph_id(ch);
762        let metrics = CachedGlyphMetrics {
763            glyph_id,
764            advance: scaled_font.h_advance(glyph_id).max(0.0),
765        };
766        self.glyphs.put(key, metrics);
767        self.stats.glyph_misses = self.stats.glyph_misses.saturating_add(1);
768        metrics
769    }
770
771    fn kern<F, S>(
772        &mut self,
773        font: &SoftwareTextFont,
774        glyph_font_size: f32,
775        scaled_font: &S,
776        previous_id: GlyphId,
777        glyph_id: GlyphId,
778    ) -> f32
779    where
780        F: Font,
781        S: ScaleFont<F>,
782    {
783        let font_key = FontScaleMetricsKey {
784            font_hash: font.content_hash(),
785            glyph_font_size_bits: glyph_font_size.to_bits(),
786        };
787        let key = KernMetricsKey {
788            font: font_key,
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.kern(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    let mut width = 0.0f32;
2958    let mut previous = None;
2959
2960    for ch in text.chars() {
2961        let metrics = glyph_metrics.glyph_metrics(font, glyph_font_size, &scaled_font, ch);
2962        if let Some(previous_id) = previous {
2963            width += glyph_metrics.kern(
2964                font,
2965                glyph_font_size,
2966                &scaled_font,
2967                previous_id,
2968                metrics.glyph_id,
2969            );
2970        }
2971        width += metrics.advance;
2972        previous = Some(metrics.glyph_id);
2973    }
2974
2975    width.max(0.0)
2976}
2977
2978fn annotated_line_prefix_widths_with_font_set_cached(
2979    text: &AnnotatedString,
2980    line_range: std::ops::Range<usize>,
2981    style: &TextStyle,
2982    fonts: &SoftwareTextFontSet,
2983    cache: &mut SoftwareTextMetricsCache,
2984) -> Option<TextLinePrefixWidths> {
2985    let mut boundaries = text.span_boundaries();
2986    boundaries.push(line_range.start);
2987    boundaries.push(line_range.end);
2988    boundaries.sort_unstable();
2989    boundaries.dedup();
2990    boundaries.retain(|offset| {
2991        *offset >= line_range.start
2992            && *offset <= line_range.end
2993            && text.text.is_char_boundary(*offset)
2994    });
2995
2996    let char_count = text.text[line_range.clone()].chars().count();
2997    let mut prefix_widths = Vec::with_capacity(char_count + 1);
2998    let mut separator_before = Vec::with_capacity(char_count);
2999    let non_empty_overhang = {
3000        let mut sink = PrefixWidthSegmentSink {
3001            prefix_widths: &mut prefix_widths,
3002            separator_before: &mut separator_before,
3003            width: 0.0,
3004            non_empty_overhang: 0.0,
3005        };
3006        sink.prefix_widths.push(sink.width);
3007
3008        for range in boundaries.windows(2) {
3009            let start = range[0];
3010            let end = range[1];
3011            if start >= end {
3012                continue;
3013            }
3014            let segment = &text.text[start..end];
3015            let segment_style = effective_style_for_range(&text.span_styles, style, start, end);
3016            append_prefix_width_segment_cached(segment, &segment_style, fonts, cache, &mut sink);
3017        }
3018
3019        sink.non_empty_overhang
3020    };
3021
3022    TextLinePrefixWidths::from_parts(prefix_widths, separator_before, non_empty_overhang)
3023}
3024
3025struct PrefixWidthSegmentSink<'a> {
3026    prefix_widths: &'a mut Vec<f32>,
3027    separator_before: &'a mut Vec<f32>,
3028    width: f32,
3029    non_empty_overhang: f32,
3030}
3031
3032fn append_prefix_width_segment_cached(
3033    segment: &str,
3034    style: &TextStyle,
3035    fonts: &SoftwareTextFontSet,
3036    cache: &mut SoftwareTextMetricsCache,
3037    sink: &mut PrefixWidthSegmentSink<'_>,
3038) {
3039    if segment.is_empty() {
3040        return;
3041    }
3042
3043    let font_size = resolve_font_size(style);
3044    if let Some(font) = fonts.resolve(style) {
3045        append_font_prefix_width_segment_cached(segment, style, font_size, font, cache, sink);
3046    } else {
3047        append_fallback_prefix_width_segment(segment, style, font_size, sink);
3048    }
3049}
3050
3051fn append_font_prefix_width_segment_cached(
3052    segment: &str,
3053    style: &TextStyle,
3054    font_size: f32,
3055    font: &SoftwareTextFont,
3056    cache: &mut SoftwareTextMetricsCache,
3057    sink: &mut PrefixWidthSegmentSink<'_>,
3058) {
3059    let glyph_font_size = font.ab_glyph_px_size(font_size);
3060    let scaled_font = font.font.as_scaled(PxScale::from(glyph_font_size));
3061    let letter_spacing = resolve_letter_spacing(style, font_size);
3062    let weight_synthesis = TextWeightSynthesis::for_style(style, font.weight(), font_size, 1.0);
3063    let style_synthesis = TextStyleSynthesis::for_style(style, font.style(), font_size, 1.0);
3064    sink.non_empty_overhang = sink
3065        .non_empty_overhang
3066        .max(style_synthesis.visual_overhang_px());
3067
3068    let mut previous = None;
3069
3070    for (index, ch) in segment.chars().enumerate() {
3071        let metrics = cache
3072            .glyph_metrics
3073            .glyph_metrics(font, glyph_font_size, &scaled_font, ch);
3074        let separator = if index == 0 {
3075            0.0
3076        } else {
3077            previous
3078                .map(|previous_id| {
3079                    weight_synthesis.apply_width(cache.glyph_metrics.kern(
3080                        font,
3081                        glyph_font_size,
3082                        &scaled_font,
3083                        previous_id,
3084                        metrics.glyph_id,
3085                    ))
3086                })
3087                .unwrap_or(0.0)
3088                + letter_spacing
3089        };
3090        sink.separator_before.push(separator);
3091        sink.width += separator + weight_synthesis.apply_width(metrics.advance);
3092        sink.prefix_widths.push(sink.width.max(0.0));
3093        previous = Some(metrics.glyph_id);
3094    }
3095}
3096
3097fn append_fallback_prefix_width_segment(
3098    segment: &str,
3099    style: &TextStyle,
3100    font_size: f32,
3101    sink: &mut PrefixWidthSegmentSink<'_>,
3102) {
3103    let char_width = fallback_char_width(font_size);
3104    let letter_spacing = resolve_letter_spacing(style, font_size);
3105    for (index, _) in segment.chars().enumerate() {
3106        let separator = if index == 0 { 0.0 } else { letter_spacing };
3107        sink.separator_before.push(separator);
3108        sink.width += separator + char_width;
3109        sink.prefix_widths.push(sink.width.max(0.0));
3110    }
3111}
3112
3113fn byte_offset_for_char_index(text: &str, char_index: usize) -> usize {
3114    text.char_indices()
3115        .map(|(index, _)| index)
3116        .nth(char_index)
3117        .unwrap_or(text.len())
3118}
3119
3120fn measure_text_impl(
3121    text: &str,
3122    style: &TextStyle,
3123    font_size: f32,
3124    glyph_font_size: f32,
3125    font: &impl Font,
3126    resolved_style: FontStyle,
3127    resolved_weight: FontWeight,
3128) -> TextMetrics {
3129    let line_height = resolve_line_height(style, font_size * 1.4);
3130    let letter_spacing = resolve_letter_spacing(style, font_size);
3131    let weight_synthesis = TextWeightSynthesis::for_style(style, resolved_weight, font_size, 1.0);
3132    let style_synthesis = TextStyleSynthesis::for_style(style, resolved_style, font_size, 1.0);
3133
3134    let lines: Vec<&str> = text.split('\n').collect();
3135    let line_count = lines.len().max(1);
3136
3137    let mut max_width: f32 = 0.0;
3138    for line in &lines {
3139        let line_width = line_advance_width(font, line, glyph_font_size);
3140        let char_spacing = (line.chars().count().saturating_sub(1) as f32) * letter_spacing;
3141        let line_width = (weight_synthesis.apply_width(line_width) + char_spacing).max(0.0);
3142        let line_width = if line.is_empty() {
3143            line_width
3144        } else {
3145            line_width + style_synthesis.visual_overhang_px()
3146        };
3147        max_width = max_width.max(line_width);
3148    }
3149
3150    TextMetrics {
3151        width: max_width,
3152        height: line_count as f32 * line_height,
3153        line_height,
3154        line_count,
3155    }
3156}
3157
3158fn measure_text_impl_cached(
3159    text: &str,
3160    style: &TextStyle,
3161    font_size: f32,
3162    font: &SoftwareTextFont,
3163    cache: &mut SoftwareTextMetricsCache,
3164) -> TextMetrics {
3165    let line_height = resolve_line_height(style, font_size * 1.4);
3166    let letter_spacing = resolve_letter_spacing(style, font_size);
3167    let weight_synthesis = TextWeightSynthesis::for_style(style, font.weight(), font_size, 1.0);
3168    let style_synthesis = TextStyleSynthesis::for_style(style, font.style(), font_size, 1.0);
3169    let glyph_font_size = font.ab_glyph_px_size(font_size);
3170
3171    let lines: Vec<&str> = text.split('\n').collect();
3172    let line_count = lines.len().max(1);
3173
3174    let mut max_width: f32 = 0.0;
3175    for line in &lines {
3176        let line_width =
3177            cached_line_advance_width(font, line, glyph_font_size, &mut cache.glyph_metrics);
3178        let char_spacing = (line.chars().count().saturating_sub(1) as f32) * letter_spacing;
3179        let line_width = (weight_synthesis.apply_width(line_width) + char_spacing).max(0.0);
3180        let line_width = if line.is_empty() {
3181            line_width
3182        } else {
3183            line_width + style_synthesis.visual_overhang_px()
3184        };
3185        max_width = max_width.max(line_width);
3186    }
3187
3188    TextMetrics {
3189        width: max_width,
3190        height: line_count as f32 * line_height,
3191        line_height,
3192        line_count,
3193    }
3194}
3195
3196fn measure_annotated_text_with_resolver(
3197    text: &AnnotatedString,
3198    style: &TextStyle,
3199    font_size: f32,
3200    fonts: &SoftwareTextFontSet,
3201    mut cache: Option<&mut SoftwareTextMetricsCache>,
3202) -> TextMetrics {
3203    let Some(base_font) = fonts.resolve(style) else {
3204        return fallback_text_metrics(text.text.as_str(), style, font_size);
3205    };
3206    let base_line_height = line_height_for_style(style, font_size, &base_font.font);
3207    let mut boundaries = text.span_boundaries();
3208    for (offset, ch) in text.text.char_indices() {
3209        if ch == '\n' {
3210            boundaries.push(offset);
3211            boundaries.push(offset + ch.len_utf8());
3212        }
3213    }
3214    boundaries.sort_unstable();
3215    boundaries.dedup();
3216    boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
3217
3218    let mut line_count = 1usize;
3219    let mut max_width = 0.0f32;
3220    let mut current_line_width = 0.0f32;
3221
3222    for range in boundaries.windows(2) {
3223        let start = range[0];
3224        let end = range[1];
3225        if start == end {
3226            continue;
3227        }
3228        let segment = &text.text[start..end];
3229        let segment_style = effective_style_for_range(&text.span_styles, style, start, end);
3230        let segment_font_size = resolve_font_size(&segment_style);
3231        let Some(segment_font) = fonts.resolve(&segment_style) else {
3232            let mut remaining = segment;
3233            loop {
3234                if let Some(newline_offset) = remaining.find('\n') {
3235                    let before_newline = &remaining[..newline_offset];
3236                    if !before_newline.is_empty() {
3237                        current_line_width += fallback_text_metrics(
3238                            before_newline,
3239                            &segment_style,
3240                            segment_font_size,
3241                        )
3242                        .width;
3243                    }
3244                    max_width = max_width.max(current_line_width);
3245                    current_line_width = 0.0;
3246                    line_count += 1;
3247                    remaining = &remaining[newline_offset + 1..];
3248                    if remaining.is_empty() {
3249                        break;
3250                    }
3251                } else {
3252                    if !remaining.is_empty() {
3253                        current_line_width +=
3254                            fallback_text_metrics(remaining, &segment_style, segment_font_size)
3255                                .width;
3256                    }
3257                    break;
3258                }
3259            }
3260            continue;
3261        };
3262
3263        let mut remaining = segment;
3264        loop {
3265            if let Some(newline_offset) = remaining.find('\n') {
3266                let before_newline = &remaining[..newline_offset];
3267                if !before_newline.is_empty() {
3268                    let metrics = if let Some(cache) = cache.as_deref_mut() {
3269                        measure_text_with_font_cached(
3270                            before_newline,
3271                            &segment_style,
3272                            segment_font_size,
3273                            segment_font,
3274                            cache,
3275                        )
3276                    } else {
3277                        measure_text_with_font(
3278                            before_newline,
3279                            &segment_style,
3280                            segment_font_size,
3281                            segment_font,
3282                        )
3283                    };
3284                    current_line_width += metrics.width;
3285                }
3286                max_width = max_width.max(current_line_width);
3287                current_line_width = 0.0;
3288                line_count += 1;
3289                remaining = &remaining[newline_offset + 1..];
3290                if remaining.is_empty() {
3291                    break;
3292                }
3293            } else {
3294                if !remaining.is_empty() {
3295                    let metrics = if let Some(cache) = cache.as_deref_mut() {
3296                        measure_text_with_font_cached(
3297                            remaining,
3298                            &segment_style,
3299                            segment_font_size,
3300                            segment_font,
3301                            cache,
3302                        )
3303                    } else {
3304                        measure_text_with_font(
3305                            remaining,
3306                            &segment_style,
3307                            segment_font_size,
3308                            segment_font,
3309                        )
3310                    };
3311                    current_line_width += metrics.width;
3312                }
3313                break;
3314            }
3315        }
3316    }
3317
3318    max_width = max_width.max(current_line_width);
3319
3320    let line_heights = annotated_line_heights_with_resolver(text, style, font_size, fonts);
3321    let total_height = line_heights.iter().sum();
3322    let max_line_height = line_heights.into_iter().fold(base_line_height, f32::max);
3323
3324    TextMetrics {
3325        width: max_width,
3326        height: total_height,
3327        line_height: max_line_height,
3328        line_count,
3329    }
3330}
3331
3332fn annotated_line_heights_with_resolver(
3333    text: &AnnotatedString,
3334    style: &TextStyle,
3335    font_size: f32,
3336    fonts: &SoftwareTextFontSet,
3337) -> Vec<f32> {
3338    let Some(base_font) = fonts.resolve(style) else {
3339        return fallback_line_heights(text.text.as_str(), style, font_size);
3340    };
3341    let base_line_height = line_height_for_style(style, font_size, &base_font.font);
3342    let mut line_heights = vec![base_line_height];
3343    let mut boundaries = text.span_boundaries();
3344    for (offset, ch) in text.text.char_indices() {
3345        if ch == '\n' {
3346            boundaries.push(offset);
3347            boundaries.push(offset + ch.len_utf8());
3348        }
3349    }
3350    boundaries.sort_unstable();
3351    boundaries.dedup();
3352    boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
3353
3354    let mut line_index = 0usize;
3355    for range in boundaries.windows(2) {
3356        let start = range[0];
3357        let end = range[1];
3358        if start == end {
3359            continue;
3360        }
3361        let segment = &text.text[start..end];
3362        let segment_style = effective_style_for_range(&text.span_styles, style, start, end);
3363        let segment_font_size = resolve_font_size(&segment_style);
3364        let segment_line_height = if let Some(segment_font) = fonts.resolve(&segment_style) {
3365            line_height_for_style(&segment_style, segment_font_size, &segment_font.font)
3366        } else {
3367            fallback_line_height(&segment_style, segment_font_size)
3368        };
3369        for ch in segment.chars() {
3370            line_heights[line_index] = line_heights[line_index].max(segment_line_height);
3371            if ch == '\n' {
3372                line_index += 1;
3373                if line_heights.len() <= line_index {
3374                    line_heights.push(base_line_height);
3375                }
3376            }
3377        }
3378    }
3379
3380    line_heights
3381}
3382
3383fn max_line_height_for_annotated_text_with_resolver(
3384    text: &AnnotatedString,
3385    style: &TextStyle,
3386    font_size: f32,
3387    fonts: &SoftwareTextFontSet,
3388) -> f32 {
3389    let base_line_height = fonts
3390        .resolve(style)
3391        .map(|font| line_height_for_style(style, font_size, &font.font))
3392        .unwrap_or_else(|| fallback_line_height(style, font_size));
3393    if text.span_styles.is_empty() {
3394        return base_line_height;
3395    }
3396
3397    let mut max_line_height = base_line_height;
3398    for range in text.span_boundaries().windows(2) {
3399        let start = range[0];
3400        let end = range[1];
3401        if start == end {
3402            continue;
3403        }
3404        let segment_style = effective_style_for_range(&text.span_styles, style, start, end);
3405        let segment_font_size = resolve_font_size(&segment_style);
3406        let segment_line_height = fonts
3407            .resolve(&segment_style)
3408            .map(|font| line_height_for_style(&segment_style, segment_font_size, &font.font))
3409            .unwrap_or_else(|| fallback_line_height(&segment_style, segment_font_size));
3410        max_line_height = max_line_height.max(segment_line_height);
3411    }
3412    max_line_height
3413}
3414
3415fn effective_style_for_range(
3416    span_styles: &[RangeStyle<SpanStyle>],
3417    style: &TextStyle,
3418    start: usize,
3419    end: usize,
3420) -> TextStyle {
3421    let mut effective = style.clone();
3422    for span in span_styles {
3423        if span.range.start < end && span.range.end > start {
3424            effective.span_style = effective.span_style.merge(&span.item);
3425        }
3426    }
3427    effective
3428}
3429
3430fn line_height_for_style(style: &TextStyle, font_size: f32, font: &impl Font) -> f32 {
3431    let _ = font;
3432    resolve_line_height(style, font_size * 1.4)
3433}
3434
3435fn clamp_to_char_boundary(text: &str, mut offset: usize) -> usize {
3436    offset = offset.min(text.len());
3437    while offset > 0 && !text.is_char_boundary(offset) {
3438        offset -= 1;
3439    }
3440    offset
3441}
3442
3443fn align_glyph_for_text_motion(glyph: Glyph, static_text_motion: bool) -> Glyph {
3444    align_glyph_to_pixel_grid(glyph, static_text_motion)
3445}
3446
3447fn static_glyph_pixel_origin(glyph: &Glyph) -> (i32, i32) {
3448    (
3449        glyph.position.x.round() as i32,
3450        glyph.position.y.round() as i32,
3451    )
3452}
3453
3454fn glyph_mask_cache_key(
3455    font_hash: u64,
3456    glyph: &Glyph,
3457    raster_style: GlyphRasterStyle,
3458    weight_synthesis: TextWeightSynthesis,
3459    style_synthesis: TextStyleSynthesis,
3460) -> GlyphMaskCacheKey {
3461    GlyphMaskCacheKey {
3462        font_hash,
3463        glyph_id: u32::from(glyph.id.0),
3464        scale_x_bits: glyph.scale.x.to_bits(),
3465        scale_y_bits: glyph.scale.y.to_bits(),
3466        raster_style: GlyphRasterStyleKey::from_style(raster_style),
3467        embolden_px_bits: weight_synthesis.embolden_px.to_bits(),
3468        slant_bits: style_synthesis.slant.to_bits(),
3469    }
3470}
3471
3472fn glyph_atlas_key_from_mask_key(key: GlyphMaskCacheKey) -> Option<SoftwareGlyphAtlasKey> {
3473    if !matches!(key.raster_style, GlyphRasterStyleKey::Fill) {
3474        return None;
3475    }
3476    Some(SoftwareGlyphAtlasKey {
3477        font_hash: key.font_hash,
3478        glyph_id: key.glyph_id,
3479        scale_x_bits: key.scale_x_bits,
3480        scale_y_bits: key.scale_y_bits,
3481        embolden_px_bits: key.embolden_px_bits,
3482        slant_bits: key.slant_bits,
3483    })
3484}
3485
3486fn build_complete_glyph_mask(
3487    font: &impl Font,
3488    glyph: &Glyph,
3489    raster_style: GlyphRasterStyle,
3490    weight_synthesis: TextWeightSynthesis,
3491    style_synthesis: TextStyleSynthesis,
3492) -> Option<GlyphMask> {
3493    let (outlined, bounds) = outline_glyph_with_bounds(font, glyph)?;
3494    let mask = build_glyph_mask(font, glyph, &outlined, bounds, raster_style)?;
3495    let mask = synthesize_glyph_weight(mask, weight_synthesis);
3496    Some(synthesize_glyph_style(mask, style_synthesis))
3497}
3498
3499fn cached_static_glyph_mask_with_key(
3500    cache: &mut SoftwareGlyphRasterCache,
3501    font_hash: u64,
3502    font: &impl Font,
3503    glyph: &Glyph,
3504    raster_style: GlyphRasterStyle,
3505    weight_synthesis: TextWeightSynthesis,
3506    style_synthesis: TextStyleSynthesis,
3507) -> Option<(GlyphMaskCacheKey, GlyphMask)> {
3508    let key = glyph_mask_cache_key(
3509        font_hash,
3510        glyph,
3511        raster_style,
3512        weight_synthesis,
3513        style_synthesis,
3514    );
3515    if let Some(mask) = cache.get(&key, glyph) {
3516        return Some((key, mask));
3517    }
3518    let mask =
3519        build_complete_glyph_mask(font, glyph, raster_style, weight_synthesis, style_synthesis)?;
3520    Some((key, cache.put(key, glyph, mask)))
3521}
3522
3523fn cached_static_glyph_mask(
3524    cache: &mut SoftwareGlyphRasterCache,
3525    font_hash: u64,
3526    font: &impl Font,
3527    glyph: &Glyph,
3528    raster_style: GlyphRasterStyle,
3529    weight_synthesis: TextWeightSynthesis,
3530    style_synthesis: TextStyleSynthesis,
3531) -> Option<GlyphMask> {
3532    cached_static_glyph_mask_with_key(
3533        cache,
3534        font_hash,
3535        font,
3536        glyph,
3537        raster_style,
3538        weight_synthesis,
3539        style_synthesis,
3540    )
3541    .map(|(_, mask)| mask)
3542}
3543
3544#[allow(clippy::too_many_arguments)]
3545fn visit_text_glyph_masks(
3546    text: &str,
3547    font: &impl Font,
3548    font_hash: u64,
3549    font_px_size: f32,
3550    line_height: f32,
3551    first_baseline_y: f32,
3552    origin_x: f32,
3553    origin_y: f32,
3554    letter_spacing: f32,
3555    static_text_motion: bool,
3556    raster_style: GlyphRasterStyle,
3557    weight_synthesis: TextWeightSynthesis,
3558    style_synthesis: TextStyleSynthesis,
3559    mut glyph_cache: Option<&mut SoftwareGlyphRasterCache>,
3560    mut visit: impl FnMut(&GlyphMask),
3561) -> f32 {
3562    let scale = PxScale::from(font_px_size);
3563    let scaled_font = font.as_scaled(scale);
3564    let mut max_advance = 0.0f32;
3565    for (line_idx, line) in text.split('\n').enumerate() {
3566        let baseline_y = first_baseline_y + line_idx as f32 * line_height + origin_y;
3567        let mut caret_x = origin_x;
3568        let mut previous = None;
3569        for ch in line.chars() {
3570            let glyph_id = scaled_font.glyph_id(ch);
3571            if let Some(previous_id) = previous {
3572                caret_x += scaled_font.kern(previous_id, glyph_id) + letter_spacing;
3573            }
3574            let glyph = glyph_id.with_scale_and_position(scale, point(caret_x, baseline_y));
3575            caret_x += scaled_font.h_advance(glyph_id);
3576            previous = Some(glyph_id);
3577            let glyph = align_glyph_for_text_motion(glyph, static_text_motion);
3578            let Some(mask) = (if static_text_motion {
3579                glyph_cache.as_deref_mut().and_then(|cache| {
3580                    cached_static_glyph_mask(
3581                        cache,
3582                        font_hash,
3583                        font,
3584                        &glyph,
3585                        raster_style,
3586                        weight_synthesis,
3587                        style_synthesis,
3588                    )
3589                })
3590            } else {
3591                None
3592            })
3593            .or_else(|| {
3594                build_complete_glyph_mask(
3595                    font,
3596                    &glyph,
3597                    raster_style,
3598                    weight_synthesis,
3599                    style_synthesis,
3600                )
3601            }) else {
3602                continue;
3603            };
3604            visit(&mask);
3605        }
3606        max_advance = max_advance.max((caret_x - origin_x).max(0.0));
3607    }
3608    max_advance
3609}
3610
3611#[allow(clippy::too_many_arguments)]
3612fn visit_text_glyph_masks_with_key(
3613    text: &str,
3614    font: &impl Font,
3615    font_hash: u64,
3616    font_px_size: f32,
3617    line_height: f32,
3618    first_baseline_y: f32,
3619    origin_x: f32,
3620    origin_y: f32,
3621    letter_spacing: f32,
3622    static_text_motion: bool,
3623    raster_style: GlyphRasterStyle,
3624    weight_synthesis: TextWeightSynthesis,
3625    style_synthesis: TextStyleSynthesis,
3626    mut glyph_cache: Option<&mut SoftwareGlyphRasterCache>,
3627    mut visit: impl FnMut(SoftwareGlyphAtlasKey, &GlyphMask),
3628) -> f32 {
3629    if !static_text_motion {
3630        return 0.0;
3631    }
3632
3633    let scale = PxScale::from(font_px_size);
3634    let scaled_font = font.as_scaled(scale);
3635    let mut max_advance = 0.0f32;
3636    for (line_idx, line) in text.split('\n').enumerate() {
3637        let baseline_y = first_baseline_y + line_idx as f32 * line_height + origin_y;
3638        let mut caret_x = origin_x;
3639        let mut previous = None;
3640        for ch in line.chars() {
3641            let glyph_id = scaled_font.glyph_id(ch);
3642            if let Some(previous_id) = previous {
3643                caret_x += scaled_font.kern(previous_id, glyph_id) + letter_spacing;
3644            }
3645            let glyph = glyph_id.with_scale_and_position(scale, point(caret_x, baseline_y));
3646            caret_x += scaled_font.h_advance(glyph_id);
3647            previous = Some(glyph_id);
3648            let glyph = align_glyph_for_text_motion(glyph, true);
3649            let Some((cache_key, mask)) = glyph_cache.as_deref_mut().and_then(|cache| {
3650                cached_static_glyph_mask_with_key(
3651                    cache,
3652                    font_hash,
3653                    font,
3654                    &glyph,
3655                    raster_style,
3656                    weight_synthesis,
3657                    style_synthesis,
3658                )
3659            }) else {
3660                continue;
3661            };
3662            let Some(atlas_key) = glyph_atlas_key_from_mask_key(cache_key) else {
3663                continue;
3664            };
3665            visit(atlas_key, &mask);
3666        }
3667        max_advance = max_advance.max((caret_x - origin_x).max(0.0));
3668    }
3669    max_advance
3670}
3671
3672#[allow(clippy::too_many_arguments)]
3673fn visit_cached_text_glyph_atlas_placements(
3674    text: &str,
3675    font: &impl Font,
3676    font_hash: u64,
3677    font_px_size: f32,
3678    line_height: f32,
3679    first_baseline_y: f32,
3680    origin_x: f32,
3681    origin_y: f32,
3682    letter_spacing: f32,
3683    raster_style: GlyphRasterStyle,
3684    weight_synthesis: TextWeightSynthesis,
3685    style_synthesis: TextStyleSynthesis,
3686    glyph_cache: &mut SoftwareGlyphRasterCache,
3687    mut visit: impl FnMut(SoftwareGlyphAtlasPlacement),
3688) -> f32 {
3689    let scale = PxScale::from(font_px_size);
3690    let scaled_font = font.as_scaled(scale);
3691    let mut max_advance = 0.0f32;
3692    for (line_idx, line) in text.split('\n').enumerate() {
3693        let baseline_y = first_baseline_y + line_idx as f32 * line_height + origin_y;
3694        let mut caret_x = origin_x;
3695        let mut previous = None;
3696        for ch in line.chars() {
3697            let glyph_id = scaled_font.glyph_id(ch);
3698            if let Some(previous_id) = previous {
3699                caret_x += scaled_font.kern(previous_id, glyph_id) + letter_spacing;
3700            }
3701            let glyph = glyph_id.with_scale_and_position(scale, point(caret_x, baseline_y));
3702            caret_x += scaled_font.h_advance(glyph_id);
3703            previous = Some(glyph_id);
3704            let glyph = align_glyph_for_text_motion(glyph, true);
3705            let cache_key = glyph_mask_cache_key(
3706                font_hash,
3707                &glyph,
3708                raster_style,
3709                weight_synthesis,
3710                style_synthesis,
3711            );
3712            let Some((key, x, y, width, height)) =
3713                glyph_cache.get_atlas_placement(&cache_key, &glyph)
3714            else {
3715                if font.outline(glyph.id).is_none() {
3716                    continue;
3717                }
3718                return f32::NAN;
3719            };
3720            visit(SoftwareGlyphAtlasPlacement {
3721                key,
3722                x,
3723                y,
3724                width,
3725                height,
3726                color: Color::WHITE,
3727            });
3728        }
3729        max_advance = max_advance.max((caret_x - origin_x).max(0.0));
3730    }
3731    max_advance
3732}
3733
3734#[allow(clippy::too_many_arguments)]
3735fn visit_text_glyph_atlas_run(
3736    text: &str,
3737    font: &impl Font,
3738    font_hash: u64,
3739    font_px_size: f32,
3740    line_height: f32,
3741    first_baseline_y: f32,
3742    origin_x: f32,
3743    origin_y: f32,
3744    letter_spacing: f32,
3745    raster_style: GlyphRasterStyle,
3746    weight_synthesis: TextWeightSynthesis,
3747    style_synthesis: TextStyleSynthesis,
3748    glyph_cache: &mut SoftwareGlyphRasterCache,
3749    mut visit: impl FnMut(SoftwareGlyphAtlasRunGlyph),
3750) -> f32 {
3751    let scale = PxScale::from(font_px_size);
3752    let scaled_font = font.as_scaled(scale);
3753    let mut max_advance = 0.0f32;
3754    let mut run_metrics_cache: Vec<(GlyphMaskCacheKey, CachedAtlasGlyphMetrics)> = Vec::new();
3755    for (line_idx, line) in text.split('\n').enumerate() {
3756        let baseline_y = first_baseline_y + line_idx as f32 * line_height + origin_y;
3757        let mut caret_x = origin_x;
3758        let mut previous = None;
3759        for ch in line.chars() {
3760            let glyph_id = scaled_font.glyph_id(ch);
3761            if let Some(previous_id) = previous {
3762                caret_x += scaled_font.kern(previous_id, glyph_id) + letter_spacing;
3763            }
3764            let glyph = glyph_id.with_scale_and_position(scale, point(caret_x, baseline_y));
3765            caret_x += scaled_font.h_advance(glyph_id);
3766            previous = Some(glyph_id);
3767            let glyph = align_glyph_for_text_motion(glyph, true);
3768            let cache_key = glyph_mask_cache_key(
3769                font_hash,
3770                &glyph,
3771                raster_style,
3772                weight_synthesis,
3773                style_synthesis,
3774            );
3775            if let Some((_, metrics)) = run_metrics_cache
3776                .iter()
3777                .find(|(cached_key, _)| *cached_key == cache_key)
3778            {
3779                visit(SoftwareGlyphAtlasRunGlyph::Cached(
3780                    metrics.placement(&glyph, Color::WHITE),
3781                ));
3782                continue;
3783            }
3784            if let Some(metrics) = glyph_cache.get_atlas_metrics(&cache_key) {
3785                if run_metrics_cache.len() < RUN_GLYPH_METRICS_CACHE_LIMIT {
3786                    run_metrics_cache.push((cache_key, metrics));
3787                }
3788                visit(SoftwareGlyphAtlasRunGlyph::Cached(
3789                    metrics.placement(&glyph, Color::WHITE),
3790                ));
3791                continue;
3792            }
3793
3794            if font.outline(glyph.id).is_none() {
3795                continue;
3796            }
3797            let Some(mask) = build_complete_glyph_mask(
3798                font,
3799                &glyph,
3800                raster_style,
3801                weight_synthesis,
3802                style_synthesis,
3803            ) else {
3804                continue;
3805            };
3806            let mask = glyph_cache.put(cache_key, &glyph, mask);
3807            let Some(key) = glyph_atlas_key_from_mask_key(cache_key) else {
3808                continue;
3809            };
3810            let (glyph_x, glyph_y) = static_glyph_pixel_origin(&glyph);
3811            if run_metrics_cache.len() < RUN_GLYPH_METRICS_CACHE_LIMIT {
3812                run_metrics_cache.push((
3813                    cache_key,
3814                    CachedAtlasGlyphMetrics {
3815                        key,
3816                        width: mask.width,
3817                        height: mask.height,
3818                        origin_offset_x: mask.origin_x - glyph_x,
3819                        origin_offset_y: mask.origin_y - glyph_y,
3820                    },
3821                ));
3822            }
3823            visit(SoftwareGlyphAtlasRunGlyph::New(SoftwareGlyphAtlasGlyph {
3824                key,
3825                mask: SoftwareGlyphAtlasMask {
3826                    alpha: Arc::clone(&mask.alpha),
3827                    width: mask.width,
3828                    height: mask.height,
3829                },
3830                x: mask.origin_x,
3831                y: mask.origin_y,
3832                color: Color::WHITE,
3833            }));
3834        }
3835        max_advance = max_advance.max((caret_x - origin_x).max(0.0));
3836    }
3837    max_advance
3838}
3839
3840fn blend_src_over(dst: &mut [f32; 4], src: [f32; 4]) {
3841    let src_alpha = src[3].clamp(0.0, 1.0);
3842    if src_alpha <= 0.0 {
3843        return;
3844    }
3845
3846    let dst_alpha = dst[3].clamp(0.0, 1.0);
3847    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
3848
3849    if out_alpha <= f32::EPSILON {
3850        *dst = [0.0, 0.0, 0.0, 0.0];
3851        return;
3852    }
3853
3854    for channel in 0..3 {
3855        let src_premult = src[channel].clamp(0.0, 1.0) * src_alpha;
3856        let dst_premult = dst[channel].clamp(0.0, 1.0) * dst_alpha;
3857        dst[channel] =
3858            ((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0);
3859    }
3860    dst[3] = out_alpha;
3861}
3862
3863fn draw_mask_glyph(
3864    canvas: &mut [[f32; 4]],
3865    width: u32,
3866    height: u32,
3867    mask: &GlyphMask,
3868    brush: &Brush,
3869    brush_alpha_multiplier: f32,
3870    brush_rect: Rect,
3871) {
3872    for y in 0..mask.height {
3873        let py = mask.origin_y + y as i32;
3874        if py < 0 || py >= height as i32 {
3875            continue;
3876        }
3877
3878        for x in 0..mask.width {
3879            let px = mask.origin_x + x as i32;
3880            if px < 0 || px >= width as i32 {
3881                continue;
3882            }
3883
3884            let coverage = mask.alpha[y * mask.width + x];
3885            if coverage <= 0.0 {
3886                continue;
3887            }
3888
3889            let sample = sample_brush_rgba(
3890                brush,
3891                brush_rect,
3892                brush_rect.x + px as f32 + 0.5,
3893                brush_rect.y + py as f32 + 0.5,
3894            );
3895            let alpha = coverage * sample[3] * brush_alpha_multiplier;
3896            if alpha <= 0.0 {
3897                continue;
3898            }
3899            let idx = (py as u32 * width + px as u32) as usize;
3900            blend_src_over(
3901                &mut canvas[idx],
3902                [sample[0], sample[1], sample[2], alpha.clamp(0.0, 1.0)],
3903            );
3904        }
3905    }
3906}
3907
3908fn blend_src_over_u8(dst: &mut [u8], src: [f32; 4]) {
3909    let src_alpha = src[3].clamp(0.0, 1.0);
3910    if src_alpha <= 0.0 {
3911        return;
3912    }
3913
3914    let dst_alpha = dst[3] as f32 / 255.0;
3915    if dst_alpha <= 0.0 {
3916        dst[0] = (src[0].clamp(0.0, 1.0) * 255.0).round() as u8;
3917        dst[1] = (src[1].clamp(0.0, 1.0) * 255.0).round() as u8;
3918        dst[2] = (src[2].clamp(0.0, 1.0) * 255.0).round() as u8;
3919        dst[3] = (src_alpha * 255.0).round() as u8;
3920        return;
3921    }
3922
3923    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
3924    if out_alpha <= f32::EPSILON {
3925        dst.fill(0);
3926        return;
3927    }
3928
3929    for channel in 0..3 {
3930        let src_premult = src[channel].clamp(0.0, 1.0) * src_alpha;
3931        let dst_premult = (dst[channel] as f32 / 255.0) * dst_alpha;
3932        dst[channel] =
3933            ((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha * 255.0).round() as u8;
3934    }
3935    dst[3] = (out_alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
3936}
3937
3938fn draw_mask_glyph_solid_u8(
3939    canvas: &mut [u8],
3940    width: u32,
3941    height: u32,
3942    mask: &GlyphMask,
3943    color: [f32; 4],
3944    alpha_multiplier: f32,
3945) {
3946    let red = (color[0].clamp(0.0, 1.0) * 255.0).round() as u8;
3947    let green = (color[1].clamp(0.0, 1.0) * 255.0).round() as u8;
3948    let blue = (color[2].clamp(0.0, 1.0) * 255.0).round() as u8;
3949    let alpha_scale = color[3].clamp(0.0, 1.0) * alpha_multiplier.clamp(0.0, 1.0);
3950    if alpha_scale <= 0.0 {
3951        return;
3952    }
3953
3954    for y in 0..mask.height {
3955        let py = mask.origin_y + y as i32;
3956        if py < 0 || py >= height as i32 {
3957            continue;
3958        }
3959
3960        for x in 0..mask.width {
3961            let px = mask.origin_x + x as i32;
3962            if px < 0 || px >= width as i32 {
3963                continue;
3964            }
3965
3966            let coverage = mask.alpha[y * mask.width + x];
3967            if coverage <= 0.0 {
3968                continue;
3969            }
3970
3971            let alpha = (coverage * alpha_scale).clamp(0.0, 1.0);
3972            let alpha_u8 = (alpha * 255.0).round() as u8;
3973            if alpha_u8 == 0 {
3974                continue;
3975            }
3976            let idx = ((py as u32 * width + px as u32) * 4) as usize;
3977            let dst = &mut canvas[idx..idx + 4];
3978            if dst[3] == 0 {
3979                dst[0] = red;
3980                dst[1] = green;
3981                dst[2] = blue;
3982                dst[3] = alpha_u8;
3983            } else {
3984                blend_src_over_u8(dst, [color[0], color[1], color[2], alpha]);
3985            }
3986        }
3987    }
3988}
3989
3990fn draw_shadow_mask(
3991    canvas: &mut [[f32; 4]],
3992    width: u32,
3993    height: u32,
3994    mask: &GlyphMask,
3995    shadow: Shadow,
3996    text_scale: f32,
3997    static_text_motion: bool,
3998) {
3999    if mask.width == 0 || mask.height == 0 {
4000        return;
4001    }
4002
4003    let shadow_dx = shadow.offset.x * text_scale;
4004    let shadow_dy = shadow.offset.y * text_scale;
4005    let blur_radius = (shadow.blur_radius * text_scale).max(0.0);
4006    let sigma = shadow_blur_sigma(blur_radius);
4007    let blur_margin = if sigma > 0.0 {
4008        (sigma * 3.0).ceil() as i32
4009    } else {
4010        0
4011    };
4012
4013    let padded_width = mask.width + (blur_margin as usize) * 2;
4014    let padded_height = mask.height + (blur_margin as usize) * 2;
4015    let mut padded_mask = vec![0.0f32; padded_width * padded_height];
4016
4017    for y in 0..mask.height {
4018        let src_offset = y * mask.width;
4019        let dst_offset = (y + blur_margin as usize) * padded_width + blur_margin as usize;
4020        padded_mask[dst_offset..dst_offset + mask.width]
4021            .copy_from_slice(&mask.alpha[src_offset..src_offset + mask.width]);
4022    }
4023
4024    let blurred = if sigma > 0.0 {
4025        gaussian_blur_alpha(&padded_mask, padded_width, padded_height, sigma)
4026    } else {
4027        padded_mask
4028    };
4029
4030    let shadow_rgba = color_to_rgba(shadow.color);
4031    let shadow_origin_x = mask.origin_x - blur_margin;
4032    let shadow_origin_y = mask.origin_y - blur_margin;
4033
4034    for y in 0..padded_height {
4035        for x in 0..padded_width {
4036            let alpha = blurred[y * padded_width + x] * shadow_rgba[3];
4037            if alpha <= 0.0 {
4038                continue;
4039            }
4040
4041            let target_x = shadow_origin_x as f32 + x as f32 + shadow_dx;
4042            let target_y = shadow_origin_y as f32 + y as f32 + shadow_dy;
4043            if static_text_motion {
4044                blend_shadow_pixel(
4045                    canvas,
4046                    width,
4047                    height,
4048                    target_x.round() as i32,
4049                    target_y.round() as i32,
4050                    shadow_rgba,
4051                    alpha.clamp(0.0, 1.0),
4052                );
4053            } else {
4054                blend_shadow_pixel_subpixel(
4055                    canvas,
4056                    width,
4057                    height,
4058                    target_x,
4059                    target_y,
4060                    shadow_rgba,
4061                    alpha.clamp(0.0, 1.0),
4062                );
4063            }
4064        }
4065    }
4066}
4067
4068fn blend_shadow_pixel(
4069    canvas: &mut [[f32; 4]],
4070    width: u32,
4071    height: u32,
4072    px: i32,
4073    py: i32,
4074    color: [f32; 4],
4075    alpha: f32,
4076) {
4077    if px < 0 || py < 0 || px >= width as i32 || py >= height as i32 || alpha <= 0.0 {
4078        return;
4079    }
4080    let idx = (py as u32 * width + px as u32) as usize;
4081    blend_src_over(
4082        &mut canvas[idx],
4083        [color[0], color[1], color[2], alpha.clamp(0.0, 1.0)],
4084    );
4085}
4086
4087fn blend_shadow_pixel_subpixel(
4088    canvas: &mut [[f32; 4]],
4089    width: u32,
4090    height: u32,
4091    x: f32,
4092    y: f32,
4093    color: [f32; 4],
4094    alpha: f32,
4095) {
4096    if alpha <= 0.0 {
4097        return;
4098    }
4099
4100    let base_x = x.floor();
4101    let base_y = y.floor();
4102    let frac_x = x - base_x;
4103    let frac_y = y - base_y;
4104    let base_x_i32 = base_x as i32;
4105    let base_y_i32 = base_y as i32;
4106    let weights = [
4107        ((1.0 - frac_x) * (1.0 - frac_y), 0i32, 0i32),
4108        (frac_x * (1.0 - frac_y), 1, 0),
4109        ((1.0 - frac_x) * frac_y, 0, 1),
4110        (frac_x * frac_y, 1, 1),
4111    ];
4112
4113    for (weight, dx, dy) in weights {
4114        if weight <= 0.0 {
4115            continue;
4116        }
4117        blend_shadow_pixel(
4118            canvas,
4119            width,
4120            height,
4121            base_x_i32 + dx,
4122            base_y_i32 + dy,
4123            color,
4124            alpha * weight,
4125        );
4126    }
4127}
4128
4129fn shadow_blur_sigma(blur_radius: f32) -> f32 {
4130    if blur_radius <= 0.0 {
4131        0.0
4132    } else {
4133        (blur_radius * SHADOW_SIGMA_SCALE + SHADOW_SIGMA_BIAS).max(0.5)
4134    }
4135}
4136
4137fn gaussian_blur_alpha(src: &[f32], width: usize, height: usize, sigma: f32) -> Vec<f32> {
4138    let kernel = gaussian_kernel_1d(sigma);
4139    if kernel.len() == 1 {
4140        return src.to_vec();
4141    }
4142    let half = (kernel.len() / 2) as i32;
4143
4144    let mut horizontal = vec![0.0f32; src.len()];
4145    for y in 0..height {
4146        for x in 0..width {
4147            let mut sum = 0.0f32;
4148            for (index, weight) in kernel.iter().enumerate() {
4149                let offset = index as i32 - half;
4150                let sample_x = (x as i32 + offset).clamp(0, width as i32 - 1) as usize;
4151                sum += src[y * width + sample_x] * *weight;
4152            }
4153            horizontal[y * width + x] = sum;
4154        }
4155    }
4156
4157    let mut output = vec![0.0f32; src.len()];
4158    for y in 0..height {
4159        for x in 0..width {
4160            let mut sum = 0.0f32;
4161            for (index, weight) in kernel.iter().enumerate() {
4162                let offset = index as i32 - half;
4163                let sample_y = (y as i32 + offset).clamp(0, height as i32 - 1) as usize;
4164                sum += horizontal[sample_y * width + x] * *weight;
4165            }
4166            output[y * width + x] = sum;
4167        }
4168    }
4169
4170    output
4171}
4172
4173fn gaussian_kernel_1d(sigma: f32) -> Vec<f32> {
4174    let half = ((sigma * 3.0).ceil() as i32).clamp(1, MAX_GAUSSIAN_KERNEL_HALF);
4175    if half <= 0 {
4176        return vec![1.0];
4177    }
4178
4179    let mut kernel = Vec::with_capacity((half * 2 + 1) as usize);
4180    let mut sum = 0.0f32;
4181    for offset in -half..=half {
4182        let distance = offset as f32;
4183        let weight = (-0.5 * (distance / sigma).powi(2)).exp();
4184        kernel.push(weight);
4185        sum += weight;
4186    }
4187
4188    if sum > f32::EPSILON {
4189        for weight in &mut kernel {
4190            *weight /= sum;
4191        }
4192    }
4193
4194    kernel
4195}
4196
4197fn outline_glyph_with_bounds(
4198    font: &impl Font,
4199    glyph: &Glyph,
4200) -> Option<(OutlinedGlyph, GlyphPixelBounds)> {
4201    let outlined = font.outline_glyph(glyph.clone())?;
4202    let bounds = pixel_bounds_from_outlined(&outlined);
4203    Some((outlined, bounds))
4204}
4205
4206fn build_glyph_mask(
4207    font: &impl Font,
4208    glyph: &Glyph,
4209    outlined: &OutlinedGlyph,
4210    bounds: GlyphPixelBounds,
4211    style: GlyphRasterStyle,
4212) -> Option<GlyphMask> {
4213    match style {
4214        GlyphRasterStyle::Fill => build_fill_mask(outlined, bounds),
4215        GlyphRasterStyle::Stroke { width_px } => {
4216            build_stroke_mask(font, glyph, outlined, bounds, width_px)
4217        }
4218    }
4219}
4220
4221fn build_fill_mask(outlined: &OutlinedGlyph, bounds: GlyphPixelBounds) -> Option<GlyphMask> {
4222    let mask_width = bounds.width();
4223    let mask_height = bounds.height();
4224    if mask_width == 0 || mask_height == 0 {
4225        return None;
4226    }
4227
4228    let mut alpha = vec![0.0f32; mask_width * mask_height];
4229    outlined.draw(|gx, gy, value| {
4230        let idx = gy as usize * mask_width + gx as usize;
4231        alpha[idx] = value;
4232    });
4233
4234    Some(GlyphMask {
4235        alpha: Arc::from(alpha),
4236        width: mask_width,
4237        height: mask_height,
4238        origin_x: bounds.min_x,
4239        origin_y: bounds.min_y,
4240    })
4241}
4242
4243fn build_stroke_mask(
4244    font: &impl Font,
4245    glyph: &Glyph,
4246    outlined: &OutlinedGlyph,
4247    bounds: GlyphPixelBounds,
4248    stroke_width_px: f32,
4249) -> Option<GlyphMask> {
4250    if !stroke_width_px.is_finite() || stroke_width_px <= 0.0 {
4251        return build_fill_mask(outlined, bounds);
4252    }
4253
4254    let mask_width = bounds.max_x - bounds.min_x;
4255    let mask_height = bounds.max_y - bounds.min_y;
4256    if mask_width <= 0 || mask_height <= 0 {
4257        return None;
4258    }
4259
4260    let half_width = stroke_width_px * 0.5;
4261    let miter_pad = (half_width * COMPOSE_STROKE_MITER_LIMIT).ceil();
4262    let pad = miter_pad.max(1.0) as i32 + 1;
4263    let path = build_outline_path(font, glyph, bounds, pad)?;
4264    let raster_width = mask_width + pad * 2;
4265    let raster_height = mask_height + pad * 2;
4266    if raster_width <= 0 || raster_height <= 0 {
4267        return None;
4268    }
4269
4270    let mut pixmap = Pixmap::new(raster_width as u32, raster_height as u32)?;
4271    let mut paint = Paint::default();
4272    paint.set_color_rgba8(255, 255, 255, 255);
4273    paint.anti_alias = true;
4274
4275    let stroke = Stroke {
4276        width: stroke_width_px,
4277        line_cap: LineCap::Butt,
4278        line_join: LineJoin::Miter,
4279        miter_limit: COMPOSE_STROKE_MITER_LIMIT,
4280        ..Stroke::default()
4281    };
4282
4283    pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
4284
4285    let alpha: Vec<f32> = pixmap
4286        .data()
4287        .chunks_exact(4)
4288        .map(|pixel| pixel[3] as f32 / 255.0)
4289        .collect();
4290
4291    Some(GlyphMask {
4292        alpha: Arc::from(alpha),
4293        width: raster_width as usize,
4294        height: raster_height as usize,
4295        origin_x: bounds.min_x - pad,
4296        origin_y: bounds.min_y - pad,
4297    })
4298}
4299
4300fn synthesize_glyph_weight(mask: GlyphMask, synthesis: TextWeightSynthesis) -> GlyphMask {
4301    let horizontal_shift = synthetic_weight_shift_px(synthesis.embolden_px);
4302    if horizontal_shift == 0 || mask.width == 0 || mask.height == 0 {
4303        return mask;
4304    }
4305
4306    let vertical_shift = (horizontal_shift / 2).min(1);
4307    let output_width = mask.width + horizontal_shift;
4308    let output_height = mask.height + vertical_shift * 2;
4309    let mut alpha = vec![0.0f32; output_width * output_height];
4310    for y in 0..mask.height {
4311        for x in 0..mask.width {
4312            let coverage = mask.alpha[y * mask.width + x];
4313            if coverage <= 0.0 {
4314                continue;
4315            }
4316            for dy in 0..=(vertical_shift * 2) {
4317                let output_y = y + dy;
4318                for dx in 0..=horizontal_shift {
4319                    let output_x = x + dx;
4320                    let output_index = output_y * output_width + output_x;
4321                    if coverage > alpha[output_index] {
4322                        alpha[output_index] = coverage;
4323                    }
4324                }
4325            }
4326        }
4327    }
4328
4329    GlyphMask {
4330        alpha: Arc::from(alpha),
4331        width: output_width,
4332        height: output_height,
4333        origin_x: mask.origin_x,
4334        origin_y: mask.origin_y - vertical_shift as i32,
4335    }
4336}
4337
4338fn synthesize_glyph_style(mask: GlyphMask, synthesis: TextStyleSynthesis) -> GlyphMask {
4339    if synthesis.slant <= 0.0 || mask.width == 0 || mask.height == 0 {
4340        return mask;
4341    }
4342
4343    let max_shift = ((mask.height.saturating_sub(1)) as f32 * synthesis.slant).ceil() as usize;
4344    if max_shift == 0 {
4345        return mask;
4346    }
4347
4348    let output_width = mask.width + max_shift + 1;
4349    let mut alpha = vec![0.0f32; output_width * mask.height];
4350    for y in 0..mask.height {
4351        let shift = (mask.height.saturating_sub(1) - y) as f32 * synthesis.slant;
4352        let shift_floor = shift.floor() as usize;
4353        let shift_fraction = shift - shift.floor();
4354        for x in 0..mask.width {
4355            let coverage = mask.alpha[y * mask.width + x];
4356            if coverage <= 0.0 {
4357                continue;
4358            }
4359
4360            let output_x = x + shift_floor;
4361            let left_index = y * output_width + output_x;
4362            let left_coverage = coverage * (1.0 - shift_fraction);
4363            if left_coverage > alpha[left_index] {
4364                alpha[left_index] = left_coverage;
4365            }
4366
4367            if shift_fraction > 0.0 {
4368                let right_index = left_index + 1;
4369                let right_coverage = coverage * shift_fraction;
4370                if right_coverage > alpha[right_index] {
4371                    alpha[right_index] = right_coverage;
4372                }
4373            }
4374        }
4375    }
4376
4377    GlyphMask {
4378        alpha: Arc::from(alpha),
4379        width: output_width,
4380        height: mask.height,
4381        origin_x: mask.origin_x,
4382        origin_y: mask.origin_y,
4383    }
4384}
4385
4386fn synthetic_weight_shift_px(embolden_px: f32) -> usize {
4387    if !embolden_px.is_finite() || embolden_px < 0.35 {
4388        return 0;
4389    }
4390    embolden_px.ceil().max(1.0) as usize
4391}
4392
4393fn build_outline_path(
4394    font: &impl Font,
4395    glyph: &Glyph,
4396    bounds: GlyphPixelBounds,
4397    pad: i32,
4398) -> Option<Path> {
4399    let outline = font.outline(glyph.id)?;
4400    let scale_factor = font.as_scaled(glyph.scale).scale_factor();
4401    let mut builder = PathBuilder::new();
4402    let mut has_segments = false;
4403    let mut current_end = None;
4404    let mut subpath_start = None;
4405
4406    for curve in outline.curves {
4407        match curve {
4408            ab_glyph::OutlineCurve::Line(p0, p1) => {
4409                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
4410                let end = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
4411                if current_end != Some(start) {
4412                    if current_end.is_some() {
4413                        builder.close();
4414                    }
4415                    builder.move_to(start.0, start.1);
4416                    subpath_start = Some(start);
4417                }
4418                builder.line_to(end.0, end.1);
4419                if subpath_start == Some(end) {
4420                    builder.close();
4421                    current_end = None;
4422                    subpath_start = None;
4423                } else {
4424                    current_end = Some(end);
4425                }
4426            }
4427            ab_glyph::OutlineCurve::Quad(p0, p1, p2) => {
4428                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
4429                let control = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
4430                let end = transform_outline_point(p2, scale_factor, glyph, bounds, pad);
4431                if current_end != Some(start) {
4432                    if current_end.is_some() {
4433                        builder.close();
4434                    }
4435                    builder.move_to(start.0, start.1);
4436                    subpath_start = Some(start);
4437                }
4438                builder.quad_to(control.0, control.1, end.0, end.1);
4439                if subpath_start == Some(end) {
4440                    builder.close();
4441                    current_end = None;
4442                    subpath_start = None;
4443                } else {
4444                    current_end = Some(end);
4445                }
4446            }
4447            ab_glyph::OutlineCurve::Cubic(p0, p1, p2, p3) => {
4448                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
4449                let control1 = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
4450                let control2 = transform_outline_point(p2, scale_factor, glyph, bounds, pad);
4451                let end = transform_outline_point(p3, scale_factor, glyph, bounds, pad);
4452                if current_end != Some(start) {
4453                    if current_end.is_some() {
4454                        builder.close();
4455                    }
4456                    builder.move_to(start.0, start.1);
4457                    subpath_start = Some(start);
4458                }
4459                builder.cubic_to(control1.0, control1.1, control2.0, control2.1, end.0, end.1);
4460                if subpath_start == Some(end) {
4461                    builder.close();
4462                    current_end = None;
4463                    subpath_start = None;
4464                } else {
4465                    current_end = Some(end);
4466                }
4467            }
4468        }
4469        has_segments = true;
4470    }
4471
4472    if !has_segments {
4473        return None;
4474    }
4475
4476    if current_end.is_some() {
4477        builder.close();
4478    }
4479
4480    builder.finish()
4481}
4482
4483fn transform_outline_point(
4484    point: ab_glyph::Point,
4485    scale_factor: ab_glyph::PxScaleFactor,
4486    glyph: &Glyph,
4487    bounds: GlyphPixelBounds,
4488    pad: i32,
4489) -> (f32, f32) {
4490    (
4491        point.x * scale_factor.horizontal + glyph.position.x - bounds.min_x as f32 + pad as f32,
4492        point.y * -scale_factor.vertical + glyph.position.y - bounds.min_y as f32 + pad as f32,
4493    )
4494}
4495
4496#[cfg(test)]
4497mod tests {
4498    use super::*;
4499    use cranpose_ui::text::{RangeStyle, SpanStyle};
4500    use cranpose_ui_graphics::Point;
4501
4502    fn count_ink_pixels(image: &ImageBitmap) -> usize {
4503        image
4504            .pixels()
4505            .chunks_exact(4)
4506            .filter(|px| px[3] > 0)
4507            .count()
4508    }
4509
4510    #[test]
4511    fn software_glyph_raster_cache_reuses_static_masks_across_positions() {
4512        let font = default_software_text_font().expect("bundled default font");
4513        let style = TextStyle::default();
4514        let rect = Rect {
4515            x: 0.0,
4516            y: 0.0,
4517            width: 160.0,
4518            height: 32.0,
4519        };
4520        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(64);
4521
4522        let uncached = rasterize_text_to_image(
4523            "aaaa",
4524            rect,
4525            &style,
4526            Color(1.0, 1.0, 1.0, 1.0),
4527            18.0,
4528            1.0,
4529            &font,
4530        )
4531        .expect("uncached image");
4532        let cached = rasterize_text_to_image_with_glyph_cache(
4533            "aaaa",
4534            rect,
4535            &style,
4536            Color(1.0, 1.0, 1.0, 1.0),
4537            18.0,
4538            1.0,
4539            &font,
4540            &mut cache,
4541        )
4542        .expect("cached image");
4543
4544        assert_eq!(cached.pixels(), uncached.pixels());
4545        let stats = cache.stats();
4546        assert_eq!(stats.entries, 1);
4547        assert_eq!(stats.misses, 1);
4548        assert_eq!(stats.hits, 3);
4549
4550        let shifted_rect = Rect {
4551            x: 24.0,
4552            y: 17.0,
4553            ..rect
4554        };
4555        let _ = rasterize_text_to_image_with_glyph_cache(
4556            "aaaa",
4557            shifted_rect,
4558            &style,
4559            Color(1.0, 1.0, 1.0, 1.0),
4560            18.0,
4561            1.0,
4562            &font,
4563            &mut cache,
4564        )
4565        .expect("cached shifted image");
4566
4567        let shifted_stats = cache.stats();
4568        assert_eq!(shifted_stats.entries, 1);
4569        assert_eq!(shifted_stats.misses, 1);
4570        assert_eq!(shifted_stats.hits, 7);
4571    }
4572
4573    #[test]
4574    fn annotated_solid_text_direct_raster_matches_plain_text_pixels() {
4575        let font = default_software_text_font().expect("bundled default font");
4576        let font_set = SoftwareTextFontSet::from_font(font.clone());
4577        let style = TextStyle::default();
4578        let rect = Rect {
4579            x: 0.0,
4580            y: 0.0,
4581            width: 240.0,
4582            height: 40.0,
4583        };
4584        let color = Color(1.0, 1.0, 1.0, 1.0);
4585        let annotated = AnnotatedString {
4586            text: "plain link".to_string(),
4587            span_styles: vec![RangeStyle {
4588                item: SpanStyle {
4589                    color: Some(color),
4590                    ..Default::default()
4591                },
4592                range: 0..10,
4593            }],
4594            ..Default::default()
4595        };
4596        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(64);
4597
4598        let plain = rasterize_text_to_image(
4599            annotated.text.as_str(),
4600            rect,
4601            &style,
4602            color,
4603            18.0,
4604            1.0,
4605            &font,
4606        )
4607        .expect("plain text image");
4608        let direct = rasterize_annotated_text_to_image_with_glyph_cache(
4609            &annotated, rect, &style, color, 18.0, 1.0, &font_set, &mut cache,
4610        )
4611        .expect("annotated text image");
4612
4613        assert_eq!(direct.pixels(), plain.pixels());
4614    }
4615
4616    #[test]
4617    fn solid_annotated_text_collects_atlas_glyphs_with_stable_keys() {
4618        let font = default_software_text_font().expect("bundled default font");
4619        let font_set = SoftwareTextFontSet::from_font(font);
4620        let style = TextStyle::default();
4621        let rect = Rect {
4622            x: 12.0,
4623            y: 4.0,
4624            width: 260.0,
4625            height: 48.0,
4626        };
4627        let annotated = AnnotatedString {
4628            text: "markdown link".to_string(),
4629            span_styles: vec![RangeStyle {
4630                item: SpanStyle {
4631                    color: Some(Color(0.4, 0.7, 1.0, 1.0)),
4632                    ..Default::default()
4633                },
4634                range: 9..13,
4635            }],
4636            ..Default::default()
4637        };
4638        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(64);
4639        let mut glyphs = Vec::new();
4640
4641        collect_solid_text_atlas_glyphs(
4642            &annotated,
4643            rect,
4644            &style,
4645            Color::WHITE,
4646            18.0,
4647            1.0,
4648            &font_set,
4649            &mut cache,
4650            &mut glyphs,
4651        )
4652        .expect("solid styled text is atlas-eligible");
4653
4654        assert!(!glyphs.is_empty());
4655        assert!(glyphs.iter().all(|glyph| glyph.mask.width > 0));
4656        assert!(glyphs.iter().all(|glyph| glyph.mask.height > 0));
4657        assert!(glyphs
4658            .iter()
4659            .any(|glyph| glyph.color == Color(0.4, 0.7, 1.0, 1.0)));
4660        assert!(cache.stats().entries > 0);
4661    }
4662
4663    #[test]
4664    fn cached_atlas_placements_reuse_existing_glyph_masks_without_payloads() {
4665        let font = default_software_text_font().expect("bundled default font");
4666        let font_set = SoftwareTextFontSet::from_font(font);
4667        let style = TextStyle::default();
4668        let rect = Rect {
4669            x: 12.0,
4670            y: 4.0,
4671            width: 260.0,
4672            height: 48.0,
4673        };
4674        let annotated = AnnotatedString {
4675            text: "markdown link".to_string(),
4676            span_styles: vec![RangeStyle {
4677                item: SpanStyle {
4678                    color: Some(Color(0.4, 0.7, 1.0, 1.0)),
4679                    ..Default::default()
4680                },
4681                range: 9..13,
4682            }],
4683            ..Default::default()
4684        };
4685        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(64);
4686        let mut placements = Vec::new();
4687
4688        assert!(
4689            collect_cached_solid_text_atlas_placements(
4690                &annotated,
4691                rect,
4692                &style,
4693                Color::WHITE,
4694                18.0,
4695                1.0,
4696                &font_set,
4697                &mut cache,
4698                &mut placements,
4699            )
4700            .is_none(),
4701            "placement-only collection requires retained glyph masks"
4702        );
4703        assert!(placements.is_empty());
4704
4705        let mut glyphs = Vec::new();
4706        collect_solid_text_atlas_glyphs(
4707            &annotated,
4708            rect,
4709            &style,
4710            Color::WHITE,
4711            18.0,
4712            1.0,
4713            &font_set,
4714            &mut cache,
4715            &mut glyphs,
4716        )
4717        .expect("solid styled text is atlas-eligible");
4718
4719        collect_cached_solid_text_atlas_placements(
4720            &annotated,
4721            rect,
4722            &style,
4723            Color::WHITE,
4724            18.0,
4725            1.0,
4726            &font_set,
4727            &mut cache,
4728            &mut placements,
4729        )
4730        .expect("cached masks provide placement-only atlas glyphs");
4731
4732        assert_eq!(placements.len(), glyphs.len());
4733        assert!(placements
4734            .iter()
4735            .zip(glyphs.iter())
4736            .all(|(placement, glyph)| {
4737                placement.key == glyph.key
4738                    && placement.x == glyph.x
4739                    && placement.y == glyph.y
4740                    && placement.width == glyph.mask.width
4741                    && placement.height == glyph.mask.height
4742                    && placement.color == glyph.color
4743            }));
4744        let recovered = cache
4745            .atlas_glyph_for_placement(&placements[0])
4746            .expect("placement should recover retained mask payload");
4747        assert_eq!(recovered.key, glyphs[0].key);
4748        assert_eq!(recovered.x, glyphs[0].x);
4749        assert_eq!(recovered.y, glyphs[0].y);
4750        assert_eq!(recovered.mask.width, glyphs[0].mask.width);
4751        assert_eq!(recovered.mask.height, glyphs[0].mask.height);
4752        assert_eq!(recovered.mask.alpha, glyphs[0].mask.alpha);
4753        assert_eq!(recovered.color, glyphs[0].color);
4754    }
4755
4756    #[test]
4757    fn atlas_glyph_collection_rejects_shadow_and_gradient_without_partial_output() {
4758        let font = default_software_text_font().expect("bundled default font");
4759        let font_set = SoftwareTextFontSet::from_font(font);
4760        let rect = Rect {
4761            x: 0.0,
4762            y: 0.0,
4763            width: 240.0,
4764            height: 40.0,
4765        };
4766        let mut cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(64);
4767        let mut glyphs = Vec::new();
4768        glyphs.push(SoftwareGlyphAtlasGlyph {
4769            key: SoftwareGlyphAtlasKey {
4770                font_hash: 1,
4771                glyph_id: 1,
4772                scale_x_bits: 1,
4773                scale_y_bits: 1,
4774                embolden_px_bits: 0,
4775                slant_bits: 0,
4776            },
4777            mask: SoftwareGlyphAtlasMask {
4778                alpha: Arc::from([1.0f32]),
4779                width: 1,
4780                height: 1,
4781            },
4782            x: 0,
4783            y: 0,
4784            color: Color::WHITE,
4785        });
4786        let initial_len = glyphs.len();
4787
4788        let shadow_style = TextStyle::from_span_style(SpanStyle {
4789            shadow: Some(Shadow {
4790                color: Color(0.0, 0.0, 0.0, 0.5),
4791                offset: Point::new(1.0, 1.0),
4792                blur_radius: 0.0,
4793            }),
4794            ..Default::default()
4795        });
4796        assert!(collect_solid_text_atlas_glyphs(
4797            &AnnotatedString::new("shadow".to_string()),
4798            rect,
4799            &shadow_style,
4800            Color::WHITE,
4801            18.0,
4802            1.0,
4803            &font_set,
4804            &mut cache,
4805            &mut glyphs,
4806        )
4807        .is_none());
4808        assert_eq!(glyphs.len(), initial_len);
4809
4810        let gradient_style = TextStyle::from_span_style(SpanStyle {
4811            brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
4812            ..Default::default()
4813        });
4814        assert!(collect_solid_text_atlas_glyphs(
4815            &AnnotatedString::new("gradient".to_string()),
4816            rect,
4817            &gradient_style,
4818            Color::WHITE,
4819            18.0,
4820            1.0,
4821            &font_set,
4822            &mut cache,
4823            &mut glyphs,
4824        )
4825        .is_none());
4826        assert_eq!(glyphs.len(), initial_len);
4827    }
4828
4829    fn average_ink_rgb(
4830        image: &ImageBitmap,
4831        x_start: u32,
4832        x_end: u32,
4833        y_start: u32,
4834        y_end: u32,
4835    ) -> Option<[f32; 3]> {
4836        let width = image.width();
4837        let height = image.height();
4838        let mut sums = [0.0f32; 3];
4839        let mut count = 0usize;
4840        let pixels = image.pixels();
4841
4842        let x_end = x_end.min(width);
4843        let y_end = y_end.min(height);
4844        for y in y_start.min(height)..y_end {
4845            for x in x_start.min(width)..x_end {
4846                let idx = ((y * width + x) * 4) as usize;
4847                let alpha = pixels[idx + 3];
4848                if alpha == 0 {
4849                    continue;
4850                }
4851                sums[0] += pixels[idx] as f32 / 255.0;
4852                sums[1] += pixels[idx + 1] as f32 / 255.0;
4853                sums[2] += pixels[idx + 2] as f32 / 255.0;
4854                count += 1;
4855            }
4856        }
4857
4858        if count == 0 {
4859            return None;
4860        }
4861        Some([
4862            sums[0] / count as f32,
4863            sums[1] / count as f32,
4864            sums[2] / count as f32,
4865        ])
4866    }
4867
4868    fn ink_x_range(image: &ImageBitmap) -> Option<(u32, u32)> {
4869        let width = image.width();
4870        let height = image.height();
4871        let pixels = image.pixels();
4872        let mut min_x = u32::MAX;
4873        let mut max_x = 0u32;
4874        let mut found = false;
4875        for y in 0..height {
4876            for x in 0..width {
4877                let idx = ((y * width + x) * 4) as usize;
4878                if pixels[idx + 3] > 0 {
4879                    min_x = min_x.min(x);
4880                    max_x = max_x.max(x + 1);
4881                    found = true;
4882                }
4883            }
4884        }
4885        found.then_some((min_x, max_x))
4886    }
4887
4888    fn ink_y_range(image: &ImageBitmap) -> Option<(u32, u32)> {
4889        let width = image.width();
4890        let height = image.height();
4891        let pixels = image.pixels();
4892        let mut min_y = u32::MAX;
4893        let mut max_y = 0u32;
4894        let mut found = false;
4895        for y in 0..height {
4896            for x in 0..width {
4897                let idx = ((y * width + x) * 4) as usize;
4898                if pixels[idx + 3] > 0 {
4899                    min_y = min_y.min(y);
4900                    max_y = max_y.max(y + 1);
4901                    found = true;
4902                }
4903            }
4904        }
4905        found.then_some((min_y, max_y))
4906    }
4907
4908    fn ink_centroid_x(image: &ImageBitmap, y_start: u32, y_end: u32) -> Option<f32> {
4909        let width = image.width();
4910        let height = image.height();
4911        let pixels = image.pixels();
4912        let mut weighted_x = 0.0f32;
4913        let mut total_alpha = 0.0f32;
4914
4915        for y in y_start.min(height)..y_end.min(height) {
4916            for x in 0..width {
4917                let idx = ((y * width + x) * 4) as usize;
4918                let alpha = pixels[idx + 3] as f32 / 255.0;
4919                if alpha <= 0.0 {
4920                    continue;
4921                }
4922                weighted_x += x as f32 * alpha;
4923                total_alpha += alpha;
4924            }
4925        }
4926
4927        (total_alpha > 0.0).then_some(weighted_x / total_alpha)
4928    }
4929
4930    fn vertical_slant_delta(image: &ImageBitmap) -> f32 {
4931        let (top, bottom) = ink_y_range(image).expect("image should contain ink");
4932        let mid = top + (bottom - top).max(1) / 2;
4933        let top_x = ink_centroid_x(image, top, mid).expect("top ink centroid");
4934        let bottom_x = ink_centroid_x(image, mid, bottom).expect("bottom ink centroid");
4935        top_x - bottom_x
4936    }
4937
4938    fn top_ink_row(image: &ImageBitmap) -> Option<u32> {
4939        let width = image.width();
4940        let height = image.height();
4941        let pixels = image.pixels();
4942        for y in 0..height {
4943            for x in 0..width {
4944                let idx = ((y * width + x) * 4) as usize;
4945                if pixels[idx + 3] > 0 {
4946                    return Some(y);
4947                }
4948            }
4949        }
4950        None
4951    }
4952
4953    fn reference_dilation_offsets(radius: i32) -> Vec<(i32, i32)> {
4954        let mut offsets = Vec::new();
4955        let squared_radius = radius * radius;
4956        for dy in -radius..=radius {
4957            for dx in -radius..=radius {
4958                if dx * dx + dy * dy <= squared_radius {
4959                    offsets.push((dx, dy));
4960                }
4961            }
4962        }
4963        if offsets.is_empty() {
4964            offsets.push((0, 0));
4965        }
4966        offsets
4967    }
4968
4969    fn reference_dilation_stroke_mask(fill: &GlyphMask, stroke_width: f32) -> GlyphMask {
4970        let radius = (stroke_width * 0.5).ceil() as i32;
4971        let offsets = reference_dilation_offsets(radius);
4972        let out_width = fill.width as i32 + radius * 2;
4973        let out_height = fill.height as i32 + radius * 2;
4974        let fill_width_i32 = fill.width as i32;
4975        let fill_height_i32 = fill.height as i32;
4976        let mut alpha = vec![0.0f32; (out_width * out_height) as usize];
4977
4978        for out_y in 0..out_height {
4979            let oy = out_y - radius;
4980            for out_x in 0..out_width {
4981                let ox = out_x - radius;
4982                let base_alpha =
4983                    if ox >= 0 && oy >= 0 && ox < fill_width_i32 && oy < fill_height_i32 {
4984                        fill.alpha[oy as usize * fill.width + ox as usize]
4985                    } else {
4986                        0.0
4987                    };
4988
4989                let mut dilated_alpha = 0.0f32;
4990                for (dx, dy) in &offsets {
4991                    let sx = ox + dx;
4992                    let sy = oy + dy;
4993                    if sx < 0 || sy < 0 || sx >= fill_width_i32 || sy >= fill_height_i32 {
4994                        continue;
4995                    }
4996                    let sample = fill.alpha[sy as usize * fill.width + sx as usize];
4997                    if sample > dilated_alpha {
4998                        dilated_alpha = sample;
4999                        if dilated_alpha >= 0.999 {
5000                            break;
5001                        }
5002                    }
5003                }
5004                alpha[out_y as usize * out_width as usize + out_x as usize] =
5005                    (dilated_alpha - base_alpha).max(0.0);
5006            }
5007        }
5008
5009        GlyphMask {
5010            alpha: Arc::from(alpha),
5011            width: out_width as usize,
5012            height: out_height as usize,
5013            origin_x: fill.origin_x - radius,
5014            origin_y: fill.origin_y - radius,
5015        }
5016    }
5017
5018    fn rasterize_reference_dilation_stroke(
5019        text: &str,
5020        rect: Rect,
5021        font_size: f32,
5022        stroke_width: f32,
5023        font: &impl Font,
5024    ) -> ImageBitmap {
5025        let width = rect.width.ceil().max(1.0) as u32;
5026        let height = rect.height.ceil().max(1.0) as u32;
5027        let mut canvas = vec![[0.0f32; 4]; (width * height) as usize];
5028
5029        let metrics = vertical_metrics(font, font_size);
5030        let baseline = baseline_y_for_line_box(metrics, font_size * 1.4);
5031        for glyph in layout_line_glyphs(font, text, font_size, point(0.0, baseline)) {
5032            let Some((outlined, bounds)) = outline_glyph_with_bounds(font, &glyph) else {
5033                continue;
5034            };
5035            let Some(fill) = build_fill_mask(&outlined, bounds) else {
5036                continue;
5037            };
5038            let reference = reference_dilation_stroke_mask(&fill, stroke_width);
5039            draw_mask_glyph(
5040                &mut canvas,
5041                width,
5042                height,
5043                &reference,
5044                &Brush::solid(Color::WHITE),
5045                1.0,
5046                rect,
5047            );
5048        }
5049
5050        let mut rgba = vec![0u8; canvas.len() * 4];
5051        for (index, pixel) in canvas.iter().enumerate() {
5052            let base = index * 4;
5053            rgba[base] = (pixel[0].clamp(0.0, 1.0) * 255.0).round() as u8;
5054            rgba[base + 1] = (pixel[1].clamp(0.0, 1.0) * 255.0).round() as u8;
5055            rgba[base + 2] = (pixel[2].clamp(0.0, 1.0) * 255.0).round() as u8;
5056            rgba[base + 3] = (pixel[3].clamp(0.0, 1.0) * 255.0).round() as u8;
5057        }
5058        ImageBitmap::from_rgba8(width, height, rgba).expect("reference dilation image")
5059    }
5060
5061    fn test_font() -> ab_glyph::FontRef<'static> {
5062        ab_glyph::FontRef::try_from_slice(include_bytes!("../assets/NotoSansMerged.ttf"))
5063            .expect("font")
5064    }
5065
5066    fn test_software_font() -> SoftwareTextFont {
5067        SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansMerged.ttf").to_vec())
5068            .expect("font")
5069    }
5070
5071    #[test]
5072    fn software_text_font_rejects_invalid_bytes() {
5073        assert!(SoftwareTextFont::from_bytes(vec![0, 1, 2, 3]).is_err());
5074    }
5075
5076    #[test]
5077    fn default_software_text_font_has_no_process_global_cache() {
5078        let source = include_str!("software_text_raster.rs");
5079        let once_lock = ["Once", "Lock"].concat();
5080        let cached_default = ["static ", "FONT"].concat();
5081        let default_font_fn = ["fn ", "default_font()"].concat();
5082
5083        assert!(
5084            !source.contains(&cached_default)
5085                && !source.contains(&default_font_fn)
5086                && !source.contains(&once_lock),
5087            "default software text font construction must be explicit renderer/app-owned state, not a process-global cache"
5088        );
5089    }
5090
5091    #[test]
5092    fn software_text_measurer_empty_font_set_uses_deterministic_fallback_without_panicking() {
5093        let measurer = SoftwareTextMeasurer::from_font_set(SoftwareTextFontSet::empty(), 4);
5094        let style = TextStyle {
5095            span_style: SpanStyle {
5096                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5097                ..Default::default()
5098            },
5099            ..Default::default()
5100        };
5101        let text = AnnotatedString::from("ab\nc");
5102
5103        let metrics = measurer.measure(&text, &style);
5104        assert_eq!(metrics.line_count, 2);
5105        assert!(metrics.width > 0.0);
5106        assert!(metrics.height >= metrics.line_height * 2.0);
5107
5108        let cursor_x = measurer.get_cursor_x_for_offset(&text, &style, 2);
5109        assert!(cursor_x > 0.0);
5110        let second_line_offset =
5111            measurer.get_offset_for_position(&text, &style, 0.0, metrics.line_height);
5112        assert!(
5113            second_line_offset >= "ab\n".len(),
5114            "fallback hit testing should resolve into the second line: {second_line_offset}"
5115        );
5116
5117        let layout = measurer.layout(&text, &style);
5118        assert_eq!(layout.lines.len(), 2);
5119        assert_eq!(layout.glyph_layouts().len(), 3);
5120    }
5121
5122    #[test]
5123    fn software_text_metrics_layout_and_cursor_share_font_backend() {
5124        let font = test_software_font();
5125        let style = TextStyle {
5126            span_style: SpanStyle {
5127                font_size: cranpose_ui::text::TextUnit::Sp(18.0),
5128                ..Default::default()
5129            },
5130            ..Default::default()
5131        };
5132        let text = "Text\nBackend";
5133
5134        let metrics = measure_text_with_font(text, &style, 18.0, &font);
5135        let layout = layout_text_with_font(text, &style, &font);
5136
5137        assert!(metrics.width > 0.0);
5138        assert_eq!(metrics.line_count, 2);
5139        assert_eq!(layout.lines.len(), 2);
5140        assert_eq!(layout.height, metrics.height);
5141        assert!(layout.glyph_layouts().len() >= "TextBackend".len());
5142
5143        let offset =
5144            text_offset_for_position_with_font(text, &style, 0.0, metrics.line_height, &font);
5145        assert!(
5146            offset >= "Text\n".len(),
5147            "second-line hit testing should return a byte offset on the second line: {offset}"
5148        );
5149        let cursor_x = cursor_x_for_offset_with_font(text, &style, "Text".len(), &font);
5150        assert!(cursor_x > 0.0);
5151    }
5152
5153    #[test]
5154    fn software_text_metrics_keep_requested_font_size_for_default_font() {
5155        let font = default_software_text_font().expect("bundled default test font");
5156        let style = TextStyle {
5157            span_style: SpanStyle {
5158                font_size: cranpose_ui::text::TextUnit::Sp(14.0),
5159                ..Default::default()
5160            },
5161            ..Default::default()
5162        };
5163
5164        let metrics = measure_text_with_font("Counter App", &style, 14.0, &font);
5165        assert!(
5166            (metrics.width - 83.16).abs() < 0.05 && (metrics.height - 19.6).abs() < 0.05,
5167            "14sp demo text must use font em metrics, not ab_glyph height units: {metrics:?}"
5168        );
5169    }
5170
5171    #[test]
5172    fn software_text_synthesizes_missing_bold_weight() {
5173        let font = test_software_font();
5174        let normal_style = TextStyle {
5175            span_style: SpanStyle {
5176                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5177                ..Default::default()
5178            },
5179            ..Default::default()
5180        };
5181        let bold_style = TextStyle {
5182            span_style: SpanStyle {
5183                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5184                font_weight: Some(FontWeight::BOLD),
5185                ..Default::default()
5186            },
5187            ..Default::default()
5188        };
5189        let no_synthesis_style = TextStyle {
5190            span_style: SpanStyle {
5191                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5192                font_weight: Some(FontWeight::BOLD),
5193                font_synthesis: Some(FontSynthesis::None),
5194                ..Default::default()
5195            },
5196            ..Default::default()
5197        };
5198
5199        let normal = measure_text_with_font("Save Raster WebP", &normal_style, 20.0, &font);
5200        let synthesized = measure_text_with_font("Save Raster WebP", &bold_style, 20.0, &font);
5201        let disabled = measure_text_with_font("Save Raster WebP", &no_synthesis_style, 20.0, &font);
5202
5203        assert!(
5204            synthesized.width > normal.width * 1.04,
5205            "bold fallback should synthesize heavier advances: normal={normal:?} synthesized={synthesized:?}"
5206        );
5207        assert!(
5208            (disabled.width - normal.width).abs() < 0.01,
5209            "explicit FontSynthesis::None should preserve regular metrics: normal={normal:?} disabled={disabled:?}"
5210        );
5211    }
5212
5213    #[test]
5214    fn rasterized_synthetic_bold_adds_ink_without_changing_line_box() {
5215        let font = test_software_font();
5216        let normal_style = TextStyle {
5217            span_style: SpanStyle {
5218                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5219                ..Default::default()
5220            },
5221            ..Default::default()
5222        };
5223        let bold_style = TextStyle {
5224            span_style: SpanStyle {
5225                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
5226                font_weight: Some(FontWeight::BOLD),
5227                ..Default::default()
5228            },
5229            ..Default::default()
5230        };
5231        let normal_metrics = measure_text_with_font("Composer", &normal_style, 20.0, &font);
5232        let bold_metrics = measure_text_with_font("Composer", &bold_style, 20.0, &font);
5233
5234        let normal = rasterize_text_to_image(
5235            "Composer",
5236            Rect {
5237                x: 0.0,
5238                y: 0.0,
5239                width: normal_metrics.width.ceil(),
5240                height: normal_metrics.height.ceil(),
5241            },
5242            &normal_style,
5243            Color::WHITE,
5244            20.0,
5245            1.0,
5246            &font,
5247        )
5248        .expect("normal text image");
5249        let bold = rasterize_text_to_image(
5250            "Composer",
5251            Rect {
5252                x: 0.0,
5253                y: 0.0,
5254                width: bold_metrics.width.ceil(),
5255                height: bold_metrics.height.ceil(),
5256            },
5257            &bold_style,
5258            Color::WHITE,
5259            20.0,
5260            1.0,
5261            &font,
5262        )
5263        .expect("bold text image");
5264
5265        assert_eq!(bold.height(), normal.height());
5266        assert!(
5267            count_ink_pixels(&bold) > count_ink_pixels(&normal),
5268            "synthetic bold should increase rasterized ink coverage"
5269        );
5270    }
5271
5272    #[test]
5273    fn software_text_synthesizes_missing_italic_style() {
5274        let font = test_software_font();
5275        let normal_style = TextStyle {
5276            span_style: SpanStyle {
5277                font_size: cranpose_ui::text::TextUnit::Sp(36.0),
5278                ..Default::default()
5279            },
5280            ..Default::default()
5281        };
5282        let italic_style = TextStyle {
5283            span_style: SpanStyle {
5284                font_size: cranpose_ui::text::TextUnit::Sp(36.0),
5285                font_style: Some(FontStyle::Italic),
5286                ..Default::default()
5287            },
5288            ..Default::default()
5289        };
5290        let no_synthesis_style = TextStyle {
5291            span_style: SpanStyle {
5292                font_size: cranpose_ui::text::TextUnit::Sp(36.0),
5293                font_style: Some(FontStyle::Italic),
5294                font_synthesis: Some(FontSynthesis::None),
5295                ..Default::default()
5296            },
5297            ..Default::default()
5298        };
5299
5300        let normal_metrics = measure_text_with_font("Italic", &normal_style, 36.0, &font);
5301        let italic_metrics = measure_text_with_font("Italic", &italic_style, 36.0, &font);
5302        let disabled_metrics = measure_text_with_font("Italic", &no_synthesis_style, 36.0, &font);
5303
5304        assert!(
5305            italic_metrics.width > normal_metrics.width + 6.0,
5306            "italic fallback should reserve slanted visual overhang: normal={normal_metrics:?} italic={italic_metrics:?}"
5307        );
5308        assert!(
5309            (disabled_metrics.width - normal_metrics.width).abs() < 0.01,
5310            "explicit FontSynthesis::None should preserve regular metrics: normal={normal_metrics:?} disabled={disabled_metrics:?}"
5311        );
5312
5313        let normal = rasterize_text_to_image(
5314            "Italic",
5315            Rect {
5316                x: 0.0,
5317                y: 0.0,
5318                width: normal_metrics.width.ceil(),
5319                height: normal_metrics.height.ceil(),
5320            },
5321            &normal_style,
5322            Color::WHITE,
5323            36.0,
5324            1.0,
5325            &font,
5326        )
5327        .expect("normal text image");
5328        let italic = rasterize_text_to_image(
5329            "Italic",
5330            Rect {
5331                x: 0.0,
5332                y: 0.0,
5333                width: italic_metrics.width.ceil(),
5334                height: italic_metrics.height.ceil(),
5335            },
5336            &italic_style,
5337            Color::WHITE,
5338            36.0,
5339            1.0,
5340            &font,
5341        )
5342        .expect("italic text image");
5343        let disabled = rasterize_text_to_image(
5344            "Italic",
5345            Rect {
5346                x: 0.0,
5347                y: 0.0,
5348                width: disabled_metrics.width.ceil(),
5349                height: disabled_metrics.height.ceil(),
5350            },
5351            &no_synthesis_style,
5352            Color::WHITE,
5353            36.0,
5354            1.0,
5355            &font,
5356        )
5357        .expect("disabled italic text image");
5358
5359        assert_eq!(
5360            normal.pixels(),
5361            disabled.pixels(),
5362            "FontSynthesis::None must not synthesize oblique glyphs"
5363        );
5364        assert!(
5365            vertical_slant_delta(&italic) > vertical_slant_delta(&normal) + 2.0,
5366            "synthetic italic should visibly lean top ink to the right"
5367        );
5368    }
5369
5370    #[test]
5371    fn rasterized_default_text_fills_expected_visual_height() {
5372        let font = default_software_text_font().expect("bundled default test font");
5373        let style = TextStyle {
5374            span_style: SpanStyle {
5375                font_size: cranpose_ui::text::TextUnit::Sp(14.0),
5376                ..Default::default()
5377            },
5378            ..Default::default()
5379        };
5380        let metrics = measure_text_with_font("Counter App", &style, 14.0, &font);
5381        let image = rasterize_text_to_image(
5382            "Counter App",
5383            Rect {
5384                x: 0.0,
5385                y: 0.0,
5386                width: metrics.width.ceil(),
5387                height: metrics.height.ceil(),
5388            },
5389            &style,
5390            Color::WHITE,
5391            14.0,
5392            1.0,
5393            &font,
5394        )
5395        .expect("text image");
5396        let (top, bottom) = ink_y_range(&image).expect("text should contain ink");
5397        let ink_height = bottom - top;
5398
5399        assert!(
5400            ink_height >= 13,
5401            "14sp default text ink should keep visual height parity with the WGPU baseline: top={top} bottom={bottom} image={}x{}",
5402            image.width(),
5403            image.height()
5404        );
5405    }
5406
5407    #[test]
5408    fn software_text_font_selection_preserves_first_complete_default_face() {
5409        let regular =
5410            SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansMerged.ttf").to_vec())
5411                .expect("regular test font should load");
5412        let font = software_text_font_from_fonts_or_default(&[
5413            include_bytes!("../assets/NotoSansMerged.ttf"),
5414            include_bytes!("../assets/NotoSansBold.ttf"),
5415            include_bytes!("../assets/TwemojiMozilla.ttf"),
5416        ])
5417        .expect("font selection should resolve a test font");
5418        let style = TextStyle {
5419            span_style: SpanStyle {
5420                font_size: cranpose_ui::text::TextUnit::Sp(18.0),
5421                ..Default::default()
5422            },
5423            ..Default::default()
5424        };
5425
5426        let regular_metrics = measure_text_with_font("UNDER", &style, 18.0, &regular);
5427        let metrics = measure_text_with_font("UNDER", &style, 18.0, &font);
5428        assert!(
5429            (metrics.width - regular_metrics.width).abs() < 0.01,
5430            "font selection should keep the declared regular face for default text: selected={metrics:?}, regular={regular_metrics:?}"
5431        );
5432    }
5433
5434    #[test]
5435    fn software_text_font_resolution_reuses_cached_font_score() {
5436        let font = test_software_font();
5437        assert!(
5438            font.score.is_complete_default_face(),
5439            "test font should cache complete Latin coverage at load time: supported={} width={}",
5440            font.score.supported_latin_chars,
5441            font.score.latin_sample_width
5442        );
5443
5444        let fonts = SoftwareTextFontSet::from_font(font.clone());
5445        let resolved = fonts
5446            .resolve(&TextStyle {
5447                span_style: SpanStyle {
5448                    font_weight: Some(FontWeight::BOLD),
5449                    ..Default::default()
5450                },
5451                ..Default::default()
5452            })
5453            .expect("font set should resolve a test font");
5454
5455        assert_eq!(
5456            resolved.score.supported_latin_chars,
5457            font.score.supported_latin_chars
5458        );
5459        assert_eq!(
5460            resolved.score.latin_sample_width,
5461            font.score.latin_sample_width
5462        );
5463    }
5464
5465    #[test]
5466    fn software_text_font_set_resolves_requested_weight() {
5467        let fonts = software_text_font_set_from_fonts_or_default(&[
5468            include_bytes!("../assets/NotoSansMerged.ttf"),
5469            include_bytes!("../assets/NotoSansBold.ttf"),
5470            include_bytes!("../assets/TwemojiMozilla.ttf"),
5471        ]);
5472        let regular = fonts
5473            .resolve(&TextStyle::default())
5474            .expect("font set should resolve regular test font");
5475        let bold_style = TextStyle {
5476            span_style: SpanStyle {
5477                font_weight: Some(FontWeight::BOLD),
5478                ..Default::default()
5479            },
5480            ..Default::default()
5481        };
5482        let bold = fonts
5483            .resolve(&bold_style)
5484            .expect("font set should resolve bold test font");
5485
5486        assert_eq!(regular.weight(), FontWeight::NORMAL);
5487        assert_eq!(bold.weight(), FontWeight::BOLD);
5488
5489        let regular_metrics =
5490            measure_text_with_font("Counter App", &TextStyle::default(), 18.0, regular);
5491        let bold_metrics = measure_text_with_font("Counter App", &bold_style, 18.0, bold);
5492        assert!(
5493            bold_metrics.width > regular_metrics.width,
5494            "bold face resolution should affect real text metrics: regular={regular_metrics:?} bold={bold_metrics:?}"
5495        );
5496    }
5497
5498    fn registered_face(family: &FontFamily, weight: FontWeight) -> SoftwareTextFont {
5499        SoftwareTextFont::from_registered_bytes(
5500            family,
5501            weight,
5502            FontStyle::Normal,
5503            include_bytes!("../assets/NotoSansMerged.ttf").to_vec(),
5504        )
5505        .expect("registered test face")
5506    }
5507
5508    fn style_naming(family: &FontFamily) -> TextStyle {
5509        TextStyle {
5510            span_style: SpanStyle {
5511                font_family: Some(family.clone()),
5512                ..Default::default()
5513            },
5514            ..Default::default()
5515        }
5516    }
5517
5518    fn unregistered_face() -> SoftwareTextFont {
5519        SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansBold.ttf").to_vec())
5520            .expect("unregistered test face")
5521    }
5522
5523    #[test]
5524    fn a_named_family_resolves_the_face_registered_under_it() {
5525        // The file's own `name` table says Noto Sans; the app filed it as
5526        // "Game UI", and asking for that has to find it.
5527        let family = FontFamily::named("Game UI");
5528        let fonts = SoftwareTextFontSet::from_faces(vec![
5529            unregistered_face(),
5530            registered_face(&family, FontWeight::NORMAL),
5531        ]);
5532
5533        let resolved = fonts
5534            .resolve(&style_naming(&family))
5535            .expect("registered face");
5536        assert_eq!(
5537            resolved.registered_family(),
5538            Some(FontFamilyKey::of(&family))
5539        );
5540    }
5541
5542    #[test]
5543    fn a_file_backed_family_never_resolves_a_face_filed_under_another_one() {
5544        let mine = FontFamily::loaded_typeface_path("/fonts/Mine.ttf");
5545        let theirs = FontFamily::loaded_typeface_path("/fonts/Theirs.ttf");
5546        let fallback =
5547            SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansMerged.ttf").to_vec())
5548                .expect("fallback test face");
5549        let theirs_face = SoftwareTextFont::from_registered_bytes(
5550            &theirs,
5551            FontWeight::BOLD,
5552            FontStyle::Normal,
5553            include_bytes!("../assets/NotoSansBold.ttf").to_vec(),
5554        )
5555        .expect("registered test face");
5556        let fonts = SoftwareTextFontSet::from_faces(vec![fallback.clone(), theirs_face]);
5557
5558        assert_eq!(
5559            fonts
5560                .resolve(&style_naming(&mine))
5561                .expect("fallback face")
5562                .content_hash(),
5563            fallback.content_hash(),
5564            "an unregistered family must fall back rather than borrow someone else's face"
5565        );
5566        assert_eq!(
5567            fonts
5568                .resolve(&style_naming(&theirs))
5569                .expect("registered face")
5570                .registered_family(),
5571            Some(FontFamilyKey::of(&theirs)),
5572            "the family that was registered still resolves to its own face"
5573        );
5574    }
5575
5576    #[test]
5577    fn a_generic_family_only_constrains_the_set_once_a_face_is_registered_for_it() {
5578        let bold_sans_serif = TextStyle {
5579            span_style: SpanStyle {
5580                font_family: Some(FontFamily::SansSerif),
5581                font_weight: Some(FontWeight::BOLD),
5582                ..Default::default()
5583            },
5584            ..Default::default()
5585        };
5586
5587        // Nothing claims `sans-serif`, so weight matching still runs over the
5588        // whole set the way it did before app-supplied families existed.
5589        let unclaimed = software_text_font_set_from_fonts_or_default(&[
5590            include_bytes!("../assets/NotoSansMerged.ttf"),
5591            include_bytes!("../assets/NotoSansBold.ttf"),
5592        ]);
5593        assert_eq!(
5594            unclaimed
5595                .resolve(&bold_sans_serif)
5596                .expect("bold face")
5597                .weight(),
5598            FontWeight::BOLD
5599        );
5600
5601        // Once a face is filed under `sans-serif` it wins, because that is what
5602        // the app said the alias means.
5603        let claimed = SoftwareTextFontSet::from_faces(vec![
5604            SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansBold.ttf").to_vec())
5605                .expect("bold test face"),
5606            registered_face(&FontFamily::SansSerif, FontWeight::NORMAL),
5607        ]);
5608        let resolved = claimed.resolve(&bold_sans_serif).expect("system face");
5609        assert_eq!(
5610            resolved.registered_family(),
5611            Some(FontFamilyKey::of(&FontFamily::SansSerif))
5612        );
5613    }
5614
5615    #[test]
5616    fn an_app_supplied_family_measures_once_and_is_served_from_the_metrics_cache() {
5617        let family = FontFamily::named("Game UI");
5618        let measurer = SoftwareTextMeasurer::from_font_set(
5619            SoftwareTextFontSet::from_faces(vec![registered_face(&family, FontWeight::NORMAL)]),
5620            64,
5621        );
5622        let style = style_naming(&family);
5623        let text = AnnotatedString::from("SCORE 1234");
5624
5625        let first = measurer.measure(&text, &style);
5626        let stats_after_first = measurer.lock_cache().glyph_metrics.stats();
5627        for _ in 0..60 {
5628            assert_eq!(measurer.measure(&text, &style), first);
5629        }
5630
5631        assert_eq!(
5632            measurer.lock_cache().glyph_metrics.stats(),
5633            stats_after_first,
5634            "repeat frames of an unchanged string must not re-shape against the app face"
5635        );
5636    }
5637
5638    #[test]
5639    fn software_text_metrics_use_largest_annotated_span_font_size() {
5640        let font = default_software_text_font().expect("bundled default test font");
5641        let text = AnnotatedString::builder()
5642            .push_style(SpanStyle {
5643                font_size: cranpose_ui::text::TextUnit::Sp(30.0),
5644                ..Default::default()
5645            })
5646            .append("BIG ")
5647            .pop()
5648            .push_style(SpanStyle {
5649                font_size: cranpose_ui::text::TextUnit::Sp(10.0),
5650                ..Default::default()
5651            })
5652            .append("small")
5653            .pop()
5654            .to_annotated_string();
5655
5656        let metrics = measure_annotated_text_with_font(&text, &TextStyle::default(), 14.0, &font);
5657
5658        assert!(
5659            metrics.height >= 30.0,
5660            "rich text metrics must include the largest span height: {metrics:?}"
5661        );
5662        assert!(
5663            metrics.width > 48.0,
5664            "rich text metrics should measure run widths at their span sizes: {metrics:?}"
5665        );
5666    }
5667
5668    #[test]
5669    fn software_text_line_height_matches_full_measurement_without_width_layout() {
5670        let measurer = SoftwareTextMeasurer::new(
5671            default_software_text_font().expect("bundled default test font"),
5672            8,
5673        );
5674        let text = AnnotatedString::builder()
5675            .append("normal ")
5676            .push_style(SpanStyle {
5677                font_size: cranpose_ui::text::TextUnit::Sp(32.0),
5678                ..Default::default()
5679            })
5680            .append("large")
5681            .pop()
5682            .append("\nsecond line")
5683            .to_annotated_string();
5684        let style = TextStyle::default();
5685
5686        let measured = measurer.measure(&text, &style);
5687        let line_height = measurer.line_height(&text, &style);
5688
5689        assert_eq!(line_height, measured.line_height);
5690        assert!(
5691            line_height > measurer.line_height(&AnnotatedString::from("normal"), &style),
5692            "span font size should affect fast line-height lookup"
5693        );
5694    }
5695
5696    #[test]
5697    fn solid_text_atlas_line_advance_matches_measured_line_height() {
5698        let font = default_software_text_font().expect("bundled default test font");
5699        let fonts = SoftwareTextFontSet::from_font(font);
5700        let style = TextStyle::default();
5701        let text = AnnotatedString::from("A\nA\nA\nA");
5702        let font_size = style.resolve_font_size(14.0);
5703        let metrics = measure_annotated_text_with_font_set(&text, &style, font_size, &fonts);
5704        let rect = Rect {
5705            x: 0.0,
5706            y: 0.0,
5707            width: 120.0,
5708            height: metrics.height,
5709        };
5710        let mut glyph_cache = SoftwareGlyphRasterCache::with_capacity_at_least_one(16);
5711        let mut run = Vec::new();
5712
5713        collect_solid_text_atlas_run(
5714            &text,
5715            rect,
5716            &style,
5717            Color(1.0, 1.0, 1.0, 1.0),
5718            font_size,
5719            1.0,
5720            &fonts,
5721            &mut glyph_cache,
5722            &mut run,
5723        )
5724        .expect("atlas-compatible text");
5725
5726        let mut glyph_y: Vec<i32> = run.iter().map(|glyph| glyph.placement().y).collect();
5727        glyph_y.sort_unstable();
5728        glyph_y.dedup();
5729        assert_eq!(glyph_y.len(), 4);
5730        for window in glyph_y.windows(2) {
5731            let advance = (window[1] - window[0]) as f32;
5732            assert!(
5733                (advance - metrics.line_height).abs() <= 1.0,
5734                "glyph advance {advance} should match measured line height {}",
5735                metrics.line_height
5736            );
5737        }
5738    }
5739
5740    #[test]
5741    fn software_text_metrics_cache_keys_include_span_styles() {
5742        let measurer = SoftwareTextMeasurer::new(
5743            default_software_text_font().expect("bundled default test font"),
5744            8,
5745        );
5746        let plain = AnnotatedString::from("BIG small");
5747        let rich = AnnotatedString::builder()
5748            .push_style(SpanStyle {
5749                font_size: cranpose_ui::text::TextUnit::Sp(30.0),
5750                ..Default::default()
5751            })
5752            .append("BIG ")
5753            .pop()
5754            .append("small")
5755            .to_annotated_string();
5756
5757        let plain_metrics = measurer.measure(&plain, &TextStyle::default());
5758        let rich_metrics = measurer.measure(&rich, &TextStyle::default());
5759
5760        assert!(
5761            rich_metrics.height > plain_metrics.height,
5762            "cached plain text metrics must not be reused for styled text: plain={plain_metrics:?} rich={rich_metrics:?}"
5763        );
5764    }
5765
5766    #[test]
5767    fn software_text_metrics_cache_recovers_after_poison() {
5768        let measurer = SoftwareTextMeasurer::new(
5769            default_software_text_font().expect("bundled default test font"),
5770            8,
5771        );
5772        let text = AnnotatedString::from("Recovered text metrics");
5773
5774        let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5775            let _guard = measurer
5776                .cache
5777                .lock()
5778                .unwrap_or_else(|poisoned| poisoned.into_inner());
5779            panic!("poison software text metrics cache for recovery test");
5780        }));
5781
5782        assert!(poison_result.is_err());
5783
5784        let metrics = measurer.measure(&text, &TextStyle::default());
5785        assert!(metrics.width > 0.0);
5786        assert!(metrics.height > 0.0);
5787
5788        let subset =
5789            measurer.measure_subsequence(&text, 0.."Recovered".len(), &TextStyle::default());
5790        assert!(subset.width > 0.0);
5791        assert!(subset.width < metrics.width);
5792    }
5793
5794    #[test]
5795    fn software_text_prefix_widths_match_subsequence_measurement() {
5796        let measurer = SoftwareTextMeasurer::new(
5797            default_software_text_font().expect("bundled default test font"),
5798            8,
5799        );
5800        let style = TextStyle {
5801            span_style: SpanStyle {
5802                font_size: cranpose_ui::text::TextUnit::Sp(18.0),
5803                ..Default::default()
5804            },
5805            ..Default::default()
5806        };
5807        let text = AnnotatedString::from("Hello Prefix Widths");
5808        let widths = measurer
5809            .measure_line_prefix_widths(&text, 0..text.text.len(), &style)
5810            .expect("uniform line should expose prefix widths");
5811
5812        let start = "Hello ".len();
5813        let end = "Hello Prefix".len();
5814        let expected = measurer
5815            .measure_subsequence(&text, start..end, &style)
5816            .width;
5817        let actual = widths
5818            .width_for_char_range(6, 12)
5819            .expect("valid char range");
5820
5821        assert!(
5822            (actual - expected).abs() < 0.01,
5823            "prefix width should match exact subsequence width: actual={actual}, expected={expected}"
5824        );
5825    }
5826
5827    #[test]
5828    fn software_text_line_width_and_prefix_width_share_cached_plan() {
5829        let measurer = SoftwareTextMeasurer::new(
5830            default_software_text_font().expect("bundled default test font"),
5831            8,
5832        );
5833        let style = TextStyle::default();
5834        let text = AnnotatedString::from("shared prefix plan ".repeat(32).as_str());
5835        let line_range = 0..text.text.len();
5836
5837        let width = measurer
5838            .measure_line_width(&text, line_range.clone(), &style)
5839            .expect("software text should expose a line width");
5840        let stats_after_width = {
5841            let cache = measurer.lock_cache();
5842            assert_eq!(cache.line_prefix_widths.len(), 1);
5843            cache.glyph_metrics.stats()
5844        };
5845
5846        let widths = measurer
5847            .measure_line_prefix_widths(&text, line_range, &style)
5848            .expect("line width probe should cache the prefix plan");
5849        let stats_after_prefix = measurer.lock_cache().glyph_metrics.stats();
5850
5851        assert_eq!(stats_after_prefix, stats_after_width);
5852        assert!(
5853            (width - widths.width_for_char_range(0, widths.char_count()).unwrap()).abs() < 0.01,
5854            "cached line-width probe and prefix plan must agree"
5855        );
5856    }
5857
5858    #[test]
5859    fn software_text_glyph_metrics_cache_reuses_common_glyphs_across_unique_lines() {
5860        let measurer = SoftwareTextMeasurer::new(
5861            default_software_text_font().expect("bundled default test font"),
5862            8,
5863        );
5864        let style = TextStyle::default();
5865        let first = AnnotatedString::from("algorithm data structure ".repeat(24).as_str());
5866        let second =
5867            AnnotatedString::from("algorithmic structures repeat data ".repeat(24).as_str());
5868
5869        measurer
5870            .measure_line_prefix_widths(&first, 0..first.text.len(), &style)
5871            .expect("first unique line should measure");
5872        let stats_after_first = measurer.lock_cache().glyph_metrics.stats();
5873
5874        measurer
5875            .measure_line_prefix_widths(&second, 0..second.text.len(), &style)
5876            .expect("second unique line should measure");
5877        let stats_after_second = measurer.lock_cache().glyph_metrics.stats();
5878
5879        assert!(
5880            stats_after_second.glyph_hits > stats_after_first.glyph_hits,
5881            "unique markdown rows should reuse retained glyph metrics: first={stats_after_first:?} second={stats_after_second:?}"
5882        );
5883        assert!(
5884            stats_after_second.kern_hits > stats_after_first.kern_hits,
5885            "unique markdown rows should reuse retained kerning metrics: first={stats_after_first:?} second={stats_after_second:?}"
5886        );
5887    }
5888
5889    #[test]
5890    fn rasterized_gradient_text_shows_color_transition() {
5891        let font = test_font();
5892        // Use a gradient sized to the rendered text width so left=red, right=blue.
5893        // We first do a plain measurement pass to know the text width.
5894        let plain_style = TextStyle::default();
5895        let probe = rasterize_text_to_image_with_font(
5896            "MMMMMMMM",
5897            Rect {
5898                x: 0.0,
5899                y: 0.0,
5900                width: 320.0,
5901                height: 96.0,
5902            },
5903            &plain_style,
5904            Color::WHITE,
5905            48.0,
5906            1.0,
5907            &font,
5908        )
5909        .expect("probe image");
5910        let (ink_x_min, ink_x_max) = ink_x_range(&probe).expect("probe must contain ink");
5911        let gradient_end = ink_x_max as f32;
5912
5913        let style = TextStyle {
5914            span_style: SpanStyle {
5915                brush: Some(Brush::linear_gradient_range(
5916                    vec![Color::RED, Color::BLUE],
5917                    Point::new(0.0, 0.0),
5918                    Point::new(gradient_end, 0.0),
5919                )),
5920                ..Default::default()
5921            },
5922            ..Default::default()
5923        };
5924
5925        let image = rasterize_text_to_image_with_font(
5926            "MMMMMMMM",
5927            Rect {
5928                x: 0.0,
5929                y: 0.0,
5930                width: 320.0,
5931                height: 96.0,
5932            },
5933            &style,
5934            Color::WHITE,
5935            48.0,
5936            1.0,
5937            &font,
5938        )
5939        .expect("rasterized image");
5940
5941        let ink_span = ink_x_max.saturating_sub(ink_x_min).max(1);
5942        let left_end = ink_x_min + ink_span * 3 / 10;
5943        let right_start = ink_x_max.saturating_sub(ink_span * 3 / 10);
5944        let left = average_ink_rgb(&image, ink_x_min, left_end, 8, 90).expect("left ink");
5945        let right = average_ink_rgb(&image, right_start, ink_x_max, 8, 90).expect("right ink");
5946        assert!(
5947            left[0] > left[2] * 1.1,
5948            "left region should be red dominant, got {left:?}"
5949        );
5950        assert!(
5951            right[2] > right[0] * 1.1,
5952            "right region should be blue dominant, got {right:?}"
5953        );
5954    }
5955
5956    #[test]
5957    fn rasterized_stroke_and_fill_ink_coverage_differs() {
5958        let font = test_font();
5959        let fill_style = TextStyle::default();
5960        let stroke_style = TextStyle {
5961            span_style: SpanStyle {
5962                draw_style: Some(TextDrawStyle::Stroke { width: 6.0 }),
5963                ..Default::default()
5964            },
5965            ..Default::default()
5966        };
5967        let rect = Rect {
5968            x: 0.0,
5969            y: 0.0,
5970            width: 320.0,
5971            height: 96.0,
5972        };
5973
5974        let fill = rasterize_text_to_image_with_font(
5975            "MMMMMMMM",
5976            rect,
5977            &fill_style,
5978            Color::WHITE,
5979            48.0,
5980            1.0,
5981            &font,
5982        )
5983        .expect("fill image");
5984        let stroke = rasterize_text_to_image_with_font(
5985            "MMMMMMMM",
5986            rect,
5987            &stroke_style,
5988            Color::WHITE,
5989            48.0,
5990            1.0,
5991            &font,
5992        )
5993        .expect("stroke image");
5994
5995        let fill_ink = count_ink_pixels(&fill);
5996        let stroke_ink = count_ink_pixels(&stroke);
5997        assert_ne!(fill.pixels(), stroke.pixels());
5998        assert!(
5999            fill_ink.abs_diff(stroke_ink) > 300,
6000            "fill/stroke ink coverage should differ; fill={fill_ink}, stroke={stroke_ink}"
6001        );
6002    }
6003
6004    #[test]
6005    fn stroke_path_uses_miter_join_for_acute_apexes() {
6006        let font = test_font();
6007        let fill_style = TextStyle::default();
6008        let stroke_width = 12.0;
6009        let stroke_style = TextStyle {
6010            span_style: SpanStyle {
6011                draw_style: Some(TextDrawStyle::Stroke {
6012                    width: stroke_width,
6013                }),
6014                ..Default::default()
6015            },
6016            ..Default::default()
6017        };
6018        let rect = Rect {
6019            x: 0.0,
6020            y: 0.0,
6021            width: 180.0,
6022            height: 140.0,
6023        };
6024
6025        let fill = rasterize_text_to_image_with_font(
6026            "A",
6027            rect,
6028            &fill_style,
6029            Color::WHITE,
6030            110.0,
6031            1.0,
6032            &font,
6033        )
6034        .expect("fill image");
6035        let stroke = rasterize_text_to_image_with_font(
6036            "A",
6037            rect,
6038            &stroke_style,
6039            Color::WHITE,
6040            110.0,
6041            1.0,
6042            &font,
6043        )
6044        .expect("stroke image");
6045
6046        let fill_top = top_ink_row(&fill).expect("fill top row");
6047        let stroke_top = top_ink_row(&stroke).expect("stroke top row");
6048        let reference_dilation =
6049            rasterize_reference_dilation_stroke("A", rect, 110.0, stroke_width, &font);
6050        let reference_top = top_ink_row(&reference_dilation).expect("reference top row");
6051        let extra_extension = fill_top.saturating_sub(stroke_top) as f32;
6052        let half_stroke = stroke_width * 0.5;
6053        assert!(
6054            extra_extension >= half_stroke - 0.25,
6055            "stroke apex should extend by roughly at least half stroke width; fill_top={fill_top}, stroke_top={stroke_top}, half_stroke={half_stroke:.2}"
6056        );
6057        assert!(
6058            stroke.pixels() != reference_dilation.pixels(),
6059            "path stroke should diverge from mask-dilation reference output"
6060        );
6061        assert!(
6062            stroke_top <= reference_top,
6063            "miter stroke should keep acute apex at least as extended as mask-dilation reference; stroke_top={stroke_top}, reference_top={reference_top}"
6064        );
6065    }
6066
6067    #[test]
6068    fn shadow_blur_radius_changes_spread_for_shared_raster_path() {
6069        let font = test_font();
6070        let base_shadow = Shadow {
6071            color: Color(0.0, 0.0, 0.0, 0.9),
6072            offset: Point::new(5.5, 4.25),
6073            blur_radius: 0.0,
6074        };
6075        let hard_shadow_style = TextStyle {
6076            span_style: SpanStyle {
6077                shadow: Some(base_shadow),
6078                ..Default::default()
6079            },
6080            ..Default::default()
6081        };
6082        let blurred_shadow_style = TextStyle {
6083            span_style: SpanStyle {
6084                shadow: Some(Shadow {
6085                    blur_radius: 9.0,
6086                    ..base_shadow
6087                }),
6088                ..Default::default()
6089            },
6090            ..Default::default()
6091        };
6092        let rect = Rect {
6093            x: 0.0,
6094            y: 0.0,
6095            width: 320.0,
6096            height: 120.0,
6097        };
6098
6099        let hard_shadow = rasterize_text_to_image_with_font(
6100            "Shared shadow",
6101            rect,
6102            &hard_shadow_style,
6103            Color::TRANSPARENT,
6104            48.0,
6105            1.0,
6106            &font,
6107        )
6108        .expect("hard shadow image");
6109        let blurred_shadow = rasterize_text_to_image_with_font(
6110            "Shared shadow",
6111            rect,
6112            &blurred_shadow_style,
6113            Color::TRANSPARENT,
6114            48.0,
6115            1.0,
6116            &font,
6117        )
6118        .expect("blurred shadow image");
6119
6120        let hard_ink = count_ink_pixels(&hard_shadow);
6121        let blurred_ink = count_ink_pixels(&blurred_shadow);
6122        assert_ne!(
6123            hard_shadow.pixels(),
6124            blurred_shadow.pixels(),
6125            "blur radius should change rasterized shadow output"
6126        );
6127        assert!(
6128            blurred_ink > hard_ink,
6129            "blurred shadow should spread to more pixels; hard={hard_ink}, blurred={blurred_ink}"
6130        );
6131    }
6132
6133    #[test]
6134    fn text_motion_changes_fractional_shadow_sampling() {
6135        let font = test_font();
6136        let base_shadow = Shadow {
6137            color: Color(0.0, 0.0, 0.0, 0.9),
6138            offset: Point::new(3.35, 2.65),
6139            blur_radius: 6.0,
6140        };
6141        let static_style = TextStyle {
6142            span_style: SpanStyle {
6143                shadow: Some(base_shadow),
6144                ..Default::default()
6145            },
6146            paragraph_style: cranpose_ui::text::ParagraphStyle {
6147                text_motion: Some(TextMotion::Static),
6148                ..Default::default()
6149            },
6150        };
6151        let animated_style = TextStyle {
6152            span_style: SpanStyle {
6153                shadow: Some(base_shadow),
6154                ..Default::default()
6155            },
6156            paragraph_style: cranpose_ui::text::ParagraphStyle {
6157                text_motion: Some(TextMotion::Animated),
6158                ..Default::default()
6159            },
6160        };
6161        let rect = Rect {
6162            x: 11.35,
6163            y: 7.65,
6164            width: 280.0,
6165            height: 120.0,
6166        };
6167
6168        let static_image = rasterize_text_to_image_with_font(
6169            "Motion shadow",
6170            rect,
6171            &static_style,
6172            Color::TRANSPARENT,
6173            42.0,
6174            1.0,
6175            &font,
6176        )
6177        .expect("static image");
6178        let animated_image = rasterize_text_to_image_with_font(
6179            "Motion shadow",
6180            rect,
6181            &animated_style,
6182            Color::TRANSPARENT,
6183            42.0,
6184            1.0,
6185            &font,
6186        )
6187        .expect("animated image");
6188
6189        assert_ne!(
6190            static_image.pixels(),
6191            animated_image.pixels(),
6192            "TextMotion::Static should quantize shadow placement while Animated keeps fractional sampling"
6193        );
6194    }
6195
6196    #[test]
6197    fn static_text_motion_aligns_glyph_positions_to_pixel_grid() {
6198        let font = test_font();
6199        let base_glyph = layout_line_glyphs(&font, "A", 17.0, point(0.0, 13.37))
6200            .into_iter()
6201            .next()
6202            .expect("glyph");
6203        let static_aligned = align_glyph_for_text_motion(base_glyph, true);
6204        let static_position = static_aligned.position;
6205        assert!(
6206            (static_position.x - static_position.x.round()).abs() < f32::EPSILON,
6207            "static text should snap glyph x to pixel grid"
6208        );
6209        assert!(
6210            (static_position.y - static_position.y.round()).abs() < f32::EPSILON,
6211            "static text should snap glyph y to pixel grid"
6212        );
6213
6214        let animated_source = layout_line_glyphs(&font, "A", 17.0, point(0.0, 13.37))
6215            .into_iter()
6216            .next()
6217            .expect("glyph");
6218        let animated_aligned = align_glyph_for_text_motion(animated_source, false);
6219        let animated_position = animated_aligned.position;
6220        assert!(
6221            (animated_position.y - 13.37).abs() < 1e-3,
6222            "animated text should preserve fractional glyph position"
6223        );
6224    }
6225}