Skip to main content

cranpose_render_common/
software_text_raster.rs

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