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