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