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