Skip to main content

cranpose_render_common/
software_text_raster.rs

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