Skip to main content

cranpose_render_common/
software_text_raster.rs

1use ab_glyph::{point, Font, FontArc, Glyph, OutlinedGlyph, PxScale, ScaleFont};
2use cranpose_core::hash::default as default_hash;
3use cranpose_ui::text::{
4    AnnotatedString, FontFamily, FontStyle, FontSynthesis, FontWeight, Shadow, TextDrawStyle,
5    TextMotion, TextStyle,
6};
7use cranpose_ui::text_layout_result::{GlyphLayout, LineLayout, TextLayoutData, TextLayoutResult};
8use cranpose_ui::{TextMeasurer, TextMetrics};
9use cranpose_ui_graphics::{Color, ImageBitmap, Rect};
10use std::hash::{Hash, Hasher};
11use std::rc::Rc;
12use std::sync::{Arc, Mutex, MutexGuard};
13use tiny_skia::{LineCap, LineJoin, Paint, Path, PathBuilder, Pixmap, Stroke, Transform};
14
15use crate::bounded_lru_cache::BoundedLruCache;
16use crate::brush_sampling::{color_to_rgba, sample_brush_rgba};
17use crate::font_layout::{
18    align_glyph_to_pixel_grid, layout_line_glyphs, line_advance_width, pixel_bounds_from_outlined,
19    vertical_metrics, GlyphPixelBounds,
20};
21#[cfg(feature = "text-hyphenation")]
22use crate::text_hyphenation::HyphenationDictionaryError;
23use crate::text_hyphenation::HyphenationDictionaryStore;
24use crate::Brush;
25
26const COMPOSE_STROKE_MITER_LIMIT: f32 = 4.0;
27const SHADOW_SIGMA_SCALE: f32 = 0.57735;
28const SHADOW_SIGMA_BIAS: f32 = 0.5;
29const MAX_GAUSSIAN_KERNEL_HALF: i32 = 128;
30#[doc(hidden)]
31pub const DEFAULT_SOFTWARE_TEXT_FONT_BYTES: &[u8] = include_bytes!("../assets/NotoSansMerged.ttf");
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
34pub enum SoftwareTextFontError {
35    #[error("invalid software text font bytes")]
36    InvalidFont,
37}
38
39#[derive(Clone)]
40pub struct SoftwareTextFont {
41    font: FontArc,
42    metadata: SoftwareTextFontMetadata,
43    score: TextFontScore,
44}
45
46#[derive(Clone)]
47struct SoftwareTextFontMetadata {
48    families: Arc<[String]>,
49    weight: FontWeight,
50    style: FontStyle,
51    ab_glyph_scale_factor: f32,
52}
53
54impl SoftwareTextFont {
55    pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Result<Self, SoftwareTextFontError> {
56        let bytes = bytes.into();
57        let metadata = software_text_font_metadata(bytes.as_slice());
58        let font = FontArc::try_from_vec(bytes).map_err(|_| SoftwareTextFontError::InvalidFont)?;
59        let score =
60            text_font_score_from_parts(&font, metadata.ab_glyph_scale_factor, metadata.weight);
61        Ok(Self {
62            font,
63            metadata,
64            score,
65        })
66    }
67
68    pub fn family_names(&self) -> &[String] {
69        &self.metadata.families
70    }
71
72    pub fn weight(&self) -> FontWeight {
73        self.metadata.weight
74    }
75
76    pub fn style(&self) -> FontStyle {
77        self.metadata.style
78    }
79
80    fn ab_glyph_px_size(&self, logical_font_size: f32) -> f32 {
81        logical_font_size * self.metadata.ab_glyph_scale_factor
82    }
83}
84
85pub fn try_default_software_text_font() -> Result<SoftwareTextFont, SoftwareTextFontError> {
86    SoftwareTextFont::from_bytes(DEFAULT_SOFTWARE_TEXT_FONT_BYTES.to_vec())
87}
88
89pub fn default_software_text_font() -> Option<SoftwareTextFont> {
90    try_default_software_text_font().ok()
91}
92
93#[derive(Clone)]
94pub struct SoftwareTextFontSet {
95    fonts: Arc<[SoftwareTextFont]>,
96    default_index: Option<usize>,
97}
98
99impl SoftwareTextFontSet {
100    pub fn empty() -> Self {
101        Self {
102            fonts: Arc::from(Vec::new()),
103            default_index: None,
104        }
105    }
106
107    pub fn from_font(font: SoftwareTextFont) -> Self {
108        Self {
109            fonts: Arc::from(vec![font]),
110            default_index: Some(0),
111        }
112    }
113
114    pub fn from_fonts_or_default(fonts: &[&[u8]]) -> Self {
115        let mut parsed = Vec::with_capacity(fonts.len().max(1));
116        for font in fonts {
117            if let Ok(candidate) = SoftwareTextFont::from_bytes((*font).to_vec()) {
118                parsed.push(candidate);
119            }
120        }
121        if parsed.is_empty() {
122            if let Some(default_font) = default_software_text_font() {
123                parsed.push(default_font);
124            }
125        }
126
127        let default_index = (!parsed.is_empty()).then(|| default_font_index(&parsed));
128        Self {
129            fonts: Arc::from(parsed),
130            default_index,
131        }
132    }
133
134    pub fn default_font(&self) -> Option<&SoftwareTextFont> {
135        self.default_index.and_then(|index| self.fonts.get(index))
136    }
137
138    pub fn resolve(&self, style: &TextStyle) -> Option<&SoftwareTextFont> {
139        let target_weight = style.span_style.font_weight.unwrap_or_default();
140        let target_style = style.span_style.font_style.unwrap_or_default();
141        let family_name = requested_family_name(style.span_style.font_family.as_ref());
142
143        let mut best: Option<(usize, u32)> = None;
144        for (index, font) in self.fonts.iter().enumerate() {
145            let Some(score) = font_match_score(font, target_weight, target_style, family_name)
146            else {
147                continue;
148            };
149            if best.is_none_or(|(_, best_score)| score < best_score) {
150                best = Some((index, score));
151            }
152        }
153
154        let index = best.map(|(index, _)| index).or(self.default_index);
155        index.and_then(|index| self.fonts.get(index))
156    }
157}
158
159pub fn software_text_font_from_fonts_or_default(fonts: &[&[u8]]) -> Option<SoftwareTextFont> {
160    SoftwareTextFontSet::from_fonts_or_default(fonts)
161        .default_font()
162        .cloned()
163}
164
165pub fn software_text_font_set_from_fonts_or_default(fonts: &[&[u8]]) -> SoftwareTextFontSet {
166    SoftwareTextFontSet::from_fonts_or_default(fonts)
167}
168
169#[derive(Clone, Copy)]
170struct TextFontScore {
171    supported_latin_chars: usize,
172    latin_sample_width: f32,
173}
174
175impl TextFontScore {
176    fn is_complete_default_face(self) -> bool {
177        const LATIN_SAMPLE_CHAR_COUNT: usize = 21;
178        self.supported_latin_chars == LATIN_SAMPLE_CHAR_COUNT && self.latin_sample_width > 1.0
179    }
180
181    fn is_better_than(self, other: Self) -> bool {
182        self.supported_latin_chars > other.supported_latin_chars
183            || (self.supported_latin_chars == other.supported_latin_chars
184                && self.latin_sample_width > other.latin_sample_width)
185    }
186}
187
188fn text_font_score(font: &SoftwareTextFont) -> TextFontScore {
189    font.score
190}
191
192fn text_font_score_from_parts(
193    font: &FontArc,
194    ab_glyph_scale_factor: f32,
195    weight: FontWeight,
196) -> TextFontScore {
197    const SAMPLE: &str = "UNDER The quick brown fox";
198    let glyph_font_size = 18.0 * ab_glyph_scale_factor;
199    let scaled_font = font.as_scaled(PxScale::from(glyph_font_size));
200    let supported_latin_chars = SAMPLE
201        .chars()
202        .filter(|ch| !ch.is_whitespace())
203        .filter(|ch| scaled_font.glyph_id(*ch).0 != 0)
204        .count();
205    let latin_sample_width = measure_text_impl(
206        SAMPLE,
207        &TextStyle::default(),
208        18.0,
209        glyph_font_size,
210        font,
211        FontStyle::Normal,
212        weight,
213    )
214    .width;
215    TextFontScore {
216        supported_latin_chars,
217        latin_sample_width,
218    }
219}
220
221fn default_font_index(fonts: &[SoftwareTextFont]) -> usize {
222    let mut best: Option<(usize, TextFontScore)> = None;
223    for (index, font) in fonts.iter().enumerate() {
224        let score = text_font_score(font);
225        if font.style() == FontStyle::Normal
226            && font.weight() == FontWeight::NORMAL
227            && score.is_complete_default_face()
228        {
229            return index;
230        }
231        if best
232            .as_ref()
233            .is_none_or(|(_, best_score)| score.is_better_than(*best_score))
234        {
235            best = Some((index, score));
236        }
237    }
238    best.map(|(index, _)| index).unwrap_or(0)
239}
240
241fn requested_family_name(font_family: Option<&FontFamily>) -> Option<&str> {
242    match font_family {
243        Some(FontFamily::Named(name)) => Some(name.as_str()),
244        _ => None,
245    }
246}
247
248fn font_match_score(
249    font: &SoftwareTextFont,
250    target_weight: FontWeight,
251    target_style: FontStyle,
252    family_name: Option<&str>,
253) -> Option<u32> {
254    let family_penalty = match family_name {
255        Some(name) if font_family_matches(font, name) => 0,
256        Some(_) => return None,
257        None => 0,
258    };
259    let style_penalty = if font.style() == target_style {
260        0
261    } else {
262        10_000
263    };
264    let weight_penalty = (i32::from(font.weight().0) - i32::from(target_weight.0)).unsigned_abs();
265    let coverage_penalty =
266        (21usize.saturating_sub(text_font_score(font).supported_latin_chars) as u32) * 1_000;
267
268    Some(family_penalty + style_penalty + weight_penalty + coverage_penalty)
269}
270
271fn font_family_matches(font: &SoftwareTextFont, requested: &str) -> bool {
272    font.family_names()
273        .iter()
274        .any(|family| family.eq_ignore_ascii_case(requested))
275}
276
277fn software_text_font_metadata(bytes: &[u8]) -> SoftwareTextFontMetadata {
278    let Some(face) = ttf_parser::Face::parse(bytes, 0).ok() else {
279        return SoftwareTextFontMetadata {
280            families: Arc::from(Vec::<String>::new()),
281            weight: FontWeight::NORMAL,
282            style: FontStyle::Normal,
283            ab_glyph_scale_factor: 1.0,
284        };
285    };
286
287    let mut families = Vec::new();
288    for name in face.names() {
289        if matches!(
290            name.name_id,
291            ttf_parser::name_id::TYPOGRAPHIC_FAMILY | ttf_parser::name_id::FAMILY
292        ) {
293            if let Some(value) = name.to_string().filter(|value| !value.is_empty()) {
294                if !families
295                    .iter()
296                    .any(|existing: &String| existing.eq_ignore_ascii_case(&value))
297                {
298                    families.push(value);
299                }
300            }
301        }
302    }
303    let weight = FontWeight::try_new(face.weight().to_number()).unwrap_or(FontWeight::NORMAL);
304    let style = if face.is_italic() {
305        FontStyle::Italic
306    } else {
307        FontStyle::Normal
308    };
309    let units_per_em = face.units_per_em() as f32;
310    let height = (face.ascender() as f32 - face.descender() as f32).abs();
311    let ab_glyph_scale_factor =
312        if units_per_em.is_finite() && units_per_em > 0.0 && height.is_finite() && height > 0.0 {
313            height / units_per_em
314        } else {
315            1.0
316        };
317
318    SoftwareTextFontMetadata {
319        families: Arc::from(families),
320        weight,
321        style,
322        ab_glyph_scale_factor,
323    }
324}
325
326#[derive(Clone)]
327struct TextMetricsKey {
328    text: Rc<str>,
329    font_size_bits: u32,
330    style_hash: u64,
331    span_styles_hash: u64,
332}
333
334impl PartialEq for TextMetricsKey {
335    fn eq(&self, other: &Self) -> bool {
336        (Rc::ptr_eq(&self.text, &other.text) || *self.text == *other.text)
337            && self.font_size_bits == other.font_size_bits
338            && self.style_hash == other.style_hash
339            && self.span_styles_hash == other.span_styles_hash
340    }
341}
342
343impl Eq for TextMetricsKey {}
344
345impl Hash for TextMetricsKey {
346    fn hash<H: Hasher>(&self, state: &mut H) {
347        self.text.hash(state);
348        self.font_size_bits.hash(state);
349        self.style_hash.hash(state);
350        self.span_styles_hash.hash(state);
351    }
352}
353
354struct SoftwareTextMetricsCache {
355    map: BoundedLruCache<TextMetricsKey, TextMetrics>,
356}
357
358impl SoftwareTextMetricsCache {
359    fn new(capacity: usize) -> Self {
360        Self {
361            map: BoundedLruCache::with_capacity_at_least_one(capacity),
362        }
363    }
364
365    fn get_or_measure(
366        &mut self,
367        fonts: &SoftwareTextFontSet,
368        text: &AnnotatedString,
369        style: &TextStyle,
370    ) -> TextMetrics {
371        let font_size = resolve_font_size(style);
372        let key = TextMetricsKey {
373            text: Rc::from(text.text.as_str()),
374            font_size_bits: font_size.to_bits(),
375            style_hash: style.measurement_hash(),
376            span_styles_hash: text.span_styles_hash(),
377        };
378        if let Some(metrics) = self.map.get(&key).copied() {
379            return metrics;
380        }
381
382        let metrics = measure_annotated_text_with_font_set(text, style, font_size, fonts);
383        self.map.put(key, metrics);
384        metrics
385    }
386}
387
388pub struct SoftwareTextMeasurer {
389    fonts: SoftwareTextFontSet,
390    cache: Mutex<SoftwareTextMetricsCache>,
391    hyphenation: HyphenationDictionaryStore,
392}
393
394impl SoftwareTextMeasurer {
395    pub fn new(font: SoftwareTextFont, cache_capacity: usize) -> Self {
396        Self::from_font_set(SoftwareTextFontSet::from_font(font), cache_capacity)
397    }
398
399    pub fn from_font_set(fonts: SoftwareTextFontSet, cache_capacity: usize) -> Self {
400        Self {
401            fonts,
402            cache: Mutex::new(SoftwareTextMetricsCache::new(cache_capacity)),
403            hyphenation: HyphenationDictionaryStore::new(),
404        }
405    }
406
407    pub fn from_fonts_or_default(fonts: &[&[u8]], cache_capacity: usize) -> Self {
408        Self::from_font_set(
409            software_text_font_set_from_fonts_or_default(fonts),
410            cache_capacity,
411        )
412    }
413
414    fn lock_cache(&self) -> MutexGuard<'_, SoftwareTextMetricsCache> {
415        self.cache
416            .lock()
417            .unwrap_or_else(|poisoned| poisoned.into_inner())
418    }
419
420    #[cfg(feature = "text-hyphenation")]
421    pub fn register_hyphenation_dictionary_path(
422        &self,
423        locale: &str,
424        path: impl AsRef<std::path::Path>,
425    ) -> Result<(), HyphenationDictionaryError> {
426        self.hyphenation.register_dictionary_path(locale, path)
427    }
428
429    #[cfg(feature = "text-hyphenation")]
430    pub fn register_hyphenation_dictionary_reader(
431        &self,
432        locale: &str,
433        reader: &mut impl std::io::Read,
434    ) -> Result<(), HyphenationDictionaryError> {
435        self.hyphenation.register_dictionary_reader(locale, reader)
436    }
437}
438
439impl TextMeasurer for SoftwareTextMeasurer {
440    fn measure(&self, text: &cranpose_ui::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
441        self.lock_cache().get_or_measure(&self.fonts, text, style)
442    }
443
444    fn measure_subsequence(
445        &self,
446        text: &cranpose_ui::text::AnnotatedString,
447        range: std::ops::Range<usize>,
448        style: &TextStyle,
449    ) -> TextMetrics {
450        let text = text.subsequence(range);
451        self.lock_cache().get_or_measure(&self.fonts, &text, style)
452    }
453
454    fn get_offset_for_position(
455        &self,
456        text: &cranpose_ui::text::AnnotatedString,
457        style: &TextStyle,
458        x: f32,
459        y: f32,
460    ) -> usize {
461        if let Some(font) = self.fonts.resolve(style) {
462            text_offset_for_position_with_font(text.text.as_str(), style, x, y, font)
463        } else {
464            fallback_text_offset_for_position(text.text.as_str(), style, x, y)
465        }
466    }
467
468    fn get_cursor_x_for_offset(
469        &self,
470        text: &cranpose_ui::text::AnnotatedString,
471        style: &TextStyle,
472        offset: usize,
473    ) -> f32 {
474        if let Some(font) = self.fonts.resolve(style) {
475            cursor_x_for_offset_with_font(text.text.as_str(), style, offset, font)
476        } else {
477            fallback_cursor_x_for_offset(text.text.as_str(), style, offset)
478        }
479    }
480
481    fn layout(
482        &self,
483        text: &cranpose_ui::text::AnnotatedString,
484        style: &TextStyle,
485    ) -> TextLayoutResult {
486        if let Some(font) = self.fonts.resolve(style) {
487            layout_text_with_font(text.text.as_str(), style, font)
488        } else {
489            fallback_layout_text(text.text.as_str(), style)
490        }
491    }
492
493    fn choose_auto_hyphen_break(
494        &self,
495        line: &str,
496        style: &TextStyle,
497        segment_start_char: usize,
498        measured_break_char: usize,
499    ) -> Option<usize> {
500        self.hyphenation.choose_auto_hyphen_break(
501            line,
502            style,
503            segment_start_char,
504            measured_break_char,
505        )
506    }
507}
508
509pub fn software_text_content_hash(text: &cranpose_ui::text::AnnotatedString) -> u64 {
510    let mut state = default_hash::new();
511    text.text.hash(&mut state);
512    text.span_styles_hash().hash(&mut state);
513    state.finish()
514}
515
516#[derive(Clone, Copy)]
517enum GlyphRasterStyle {
518    Fill,
519    Stroke { width_px: f32 },
520}
521
522struct GlyphMask {
523    alpha: Vec<f32>,
524    width: usize,
525    height: usize,
526    origin_x: i32,
527    origin_y: i32,
528}
529
530struct RasterFontRef<'a, F> {
531    font: &'a F,
532    ab_glyph_scale_factor: f32,
533    weight: FontWeight,
534    style: FontStyle,
535}
536
537#[derive(Clone, Copy)]
538struct TextWeightSynthesis {
539    embolden_px: f32,
540    advance_scale: f32,
541}
542
543impl TextWeightSynthesis {
544    fn none() -> Self {
545        Self {
546            embolden_px: 0.0,
547            advance_scale: 1.0,
548        }
549    }
550
551    fn for_style(
552        style: &TextStyle,
553        resolved_weight: FontWeight,
554        font_size: f32,
555        scale: f32,
556    ) -> Self {
557        let requested_weight = style.span_style.font_weight.unwrap_or_default();
558        if requested_weight <= resolved_weight {
559            return Self::none();
560        }
561
562        let synthesis = style
563            .span_style
564            .font_synthesis
565            .unwrap_or(FontSynthesis::All);
566        if !matches!(synthesis, FontSynthesis::All | FontSynthesis::Weight) {
567            return Self::none();
568        }
569
570        let weight_delta = (requested_weight.value() - resolved_weight.value()) as f32;
571        let strength = (weight_delta / 300.0).clamp(0.0, 1.5);
572        Self {
573            embolden_px: (font_size * scale * 0.055 * strength).clamp(0.0, 3.0 * scale),
574            advance_scale: 1.0 + 0.085 * strength.min(1.0),
575        }
576    }
577
578    fn apply_width(self, width: f32) -> f32 {
579        width * self.advance_scale
580    }
581}
582
583#[derive(Clone, Copy)]
584struct TextStyleSynthesis {
585    slant: f32,
586    font_size: f32,
587    scale: f32,
588}
589
590impl TextStyleSynthesis {
591    fn none() -> Self {
592        Self {
593            slant: 0.0,
594            font_size: 0.0,
595            scale: 1.0,
596        }
597    }
598
599    fn for_style(style: &TextStyle, resolved_style: FontStyle, font_size: f32, scale: f32) -> Self {
600        let requested_style = style.span_style.font_style.unwrap_or_default();
601        if requested_style != FontStyle::Italic || resolved_style == FontStyle::Italic {
602            return Self::none();
603        }
604
605        let synthesis = style
606            .span_style
607            .font_synthesis
608            .unwrap_or(FontSynthesis::All);
609        if !matches!(synthesis, FontSynthesis::All | FontSynthesis::Style) {
610            return Self::none();
611        }
612
613        Self {
614            slant: 0.22,
615            font_size,
616            scale,
617        }
618    }
619
620    fn visual_overhang_px(self) -> f32 {
621        if self.slant <= 0.0 || !self.font_size.is_finite() || !self.scale.is_finite() {
622            return 0.0;
623        }
624        (self.font_size * self.scale * self.slant).ceil().max(0.0)
625    }
626}
627
628pub fn rasterize_text_to_image(
629    text: &str,
630    rect: Rect,
631    style: &TextStyle,
632    fallback_color: Color,
633    font_size: f32,
634    scale: f32,
635    font: &SoftwareTextFont,
636) -> Option<ImageBitmap> {
637    rasterize_text_to_image_impl(
638        text,
639        rect,
640        style,
641        fallback_color,
642        font_size,
643        scale,
644        RasterFontRef {
645            font: &font.font,
646            ab_glyph_scale_factor: font.metadata.ab_glyph_scale_factor,
647            weight: font.weight(),
648            style: font.style(),
649        },
650    )
651}
652
653pub fn measure_text_with_font(
654    text: &str,
655    style: &TextStyle,
656    font_size: f32,
657    font: &SoftwareTextFont,
658) -> TextMetrics {
659    measure_text_impl(
660        text,
661        style,
662        font_size,
663        font.ab_glyph_px_size(font_size),
664        &font.font,
665        font.style(),
666        font.weight(),
667    )
668}
669
670pub fn measure_annotated_text_with_font(
671    text: &AnnotatedString,
672    style: &TextStyle,
673    font_size: f32,
674    font: &SoftwareTextFont,
675) -> TextMetrics {
676    if text.span_styles.is_empty() {
677        return measure_text_with_font(text.text.as_str(), style, font_size, font);
678    }
679    measure_annotated_text_with_resolver(
680        text,
681        style,
682        font_size,
683        &SoftwareTextFontSet::from_font(font.clone()),
684    )
685}
686
687pub fn measure_annotated_text_with_font_set(
688    text: &AnnotatedString,
689    style: &TextStyle,
690    font_size: f32,
691    fonts: &SoftwareTextFontSet,
692) -> TextMetrics {
693    if text.span_styles.is_empty() {
694        if let Some(font) = fonts.resolve(style) {
695            return measure_text_with_font(text.text.as_str(), style, font_size, font);
696        }
697        return fallback_text_metrics(text.text.as_str(), style, font_size);
698    }
699    measure_annotated_text_with_resolver(text, style, font_size, fonts)
700}
701
702pub fn text_offset_for_position_with_font(
703    text: &str,
704    style: &TextStyle,
705    x: f32,
706    y: f32,
707    font: &SoftwareTextFont,
708) -> usize {
709    if text.is_empty() {
710        return 0;
711    }
712
713    let font_size = resolve_font_size(style);
714    let glyph_font_size = font.ab_glyph_px_size(font_size);
715    let line_height = resolve_line_height(style, font_size * 1.4);
716
717    let line_index = (y / line_height).floor().max(0.0) as usize;
718    let lines: Vec<&str> = text.split('\n').collect();
719    let target_line = line_index.min(lines.len().saturating_sub(1));
720
721    let mut line_start_byte = 0;
722    for line in lines.iter().take(target_line) {
723        line_start_byte += line.len() + 1;
724    }
725
726    let line_text = lines.get(target_line).unwrap_or(&"");
727    if line_text.is_empty() {
728        return line_start_byte;
729    }
730
731    let mut best_offset = 0;
732    let mut best_distance = f32::INFINITY;
733    let mut current_byte_offset = 0;
734
735    for c in line_text.chars() {
736        let prefix = &line_text[..current_byte_offset];
737        let glyph_x = measure_text_impl(
738            prefix,
739            style,
740            font_size,
741            glyph_font_size,
742            &font.font,
743            font.style(),
744            font.weight(),
745        )
746        .width;
747
748        let char_str = &line_text[current_byte_offset..current_byte_offset + c.len_utf8()];
749        let char_width = measure_text_impl(
750            char_str,
751            style,
752            font_size,
753            glyph_font_size,
754            &font.font,
755            font.style(),
756            font.weight(),
757        )
758        .width
759        .max(font_size * 0.5);
760
761        let left_dist = (x - glyph_x).abs();
762        if left_dist < best_distance {
763            best_distance = left_dist;
764            best_offset = current_byte_offset;
765        }
766
767        let right_x = glyph_x + char_width;
768        let right_dist = (x - right_x).abs();
769        if right_dist < best_distance {
770            best_distance = right_dist;
771            best_offset = current_byte_offset + c.len_utf8();
772        }
773
774        current_byte_offset += c.len_utf8();
775    }
776
777    let total_width = measure_text_impl(
778        line_text,
779        style,
780        font_size,
781        glyph_font_size,
782        &font.font,
783        font.style(),
784        font.weight(),
785    )
786    .width;
787    let end_dist = (x - total_width).abs();
788    if end_dist < best_distance {
789        best_offset = line_text.len();
790    }
791
792    line_start_byte + best_offset.min(line_text.len())
793}
794
795pub fn cursor_x_for_offset_with_font(
796    text: &str,
797    style: &TextStyle,
798    offset: usize,
799    font: &SoftwareTextFont,
800) -> f32 {
801    let clamped_offset = clamp_to_char_boundary(text, offset.min(text.len()));
802    if clamped_offset == 0 {
803        return 0.0;
804    }
805
806    let font_size = resolve_font_size(style);
807    measure_text_impl(
808        &text[..clamped_offset],
809        style,
810        font_size,
811        font.ab_glyph_px_size(font_size),
812        &font.font,
813        font.style(),
814        font.weight(),
815    )
816    .width
817}
818
819pub fn layout_text_with_font(
820    text: &str,
821    style: &TextStyle,
822    font: &SoftwareTextFont,
823) -> TextLayoutResult {
824    let font_size = resolve_font_size(style);
825    let glyph_font_size = font.ab_glyph_px_size(font_size);
826    let resolved_weight = font.weight();
827    let resolved_style = font.style();
828    let weight_synthesis = TextWeightSynthesis::for_style(style, resolved_weight, font_size, 1.0);
829    let font = &font.font;
830    let line_height = resolve_line_height(style, font_size * 1.4);
831    let letter_spacing = resolve_letter_spacing(style, font_size);
832    let scaled_font = font.as_scaled(PxScale::from(glyph_font_size));
833
834    let mut glyph_x_positions = Vec::new();
835    let mut char_to_byte = Vec::new();
836    let mut glyph_layouts = Vec::new();
837    let mut lines = Vec::new();
838    let mut current_x = 0.0f32;
839    let mut line_start = 0;
840    let mut y = 0.0f32;
841
842    let mut iter = text.char_indices().peekable();
843    while let Some((byte_offset, c)) = iter.next() {
844        glyph_x_positions.push(current_x);
845        char_to_byte.push(byte_offset);
846
847        if c == '\n' {
848            lines.push(LineLayout {
849                start_offset: line_start,
850                end_offset: byte_offset,
851                y,
852                height: line_height,
853            });
854            line_start = byte_offset + 1;
855            y += line_height;
856            current_x = 0.0;
857        } else {
858            let glyph_id = scaled_font.glyph_id(c);
859            let glyph_width =
860                weight_synthesis.apply_width(scaled_font.h_advance(glyph_id).max(0.0));
861            let glyph_end = byte_offset + c.len_utf8();
862            if glyph_end > byte_offset {
863                glyph_layouts.push(GlyphLayout {
864                    line_index: lines.len(),
865                    start_offset: byte_offset,
866                    end_offset: glyph_end,
867                    x: current_x,
868                    y,
869                    width: glyph_width,
870                    height: line_height,
871                });
872            }
873            current_x += glyph_width;
874            if let Some((_, next)) = iter.peek() {
875                if *next != '\n' {
876                    current_x += letter_spacing;
877                }
878            }
879        }
880    }
881
882    glyph_x_positions.push(current_x);
883    char_to_byte.push(text.len());
884
885    lines.push(LineLayout {
886        start_offset: line_start,
887        end_offset: text.len(),
888        y,
889        height: line_height,
890    });
891
892    let metrics = measure_text_impl(
893        text,
894        style,
895        font_size,
896        glyph_font_size,
897        font,
898        resolved_style,
899        resolved_weight,
900    );
901    TextLayoutResult::new(
902        text,
903        TextLayoutData {
904            width: metrics.width,
905            height: metrics.height,
906            line_height,
907            glyph_x_positions,
908            char_to_byte,
909            lines,
910            glyph_layouts,
911        },
912    )
913}
914
915pub fn rasterize_text_to_image_with_font(
916    text: &str,
917    rect: Rect,
918    style: &TextStyle,
919    fallback_color: Color,
920    font_size: f32,
921    scale: f32,
922    font: &impl Font,
923) -> Option<ImageBitmap> {
924    rasterize_text_to_image_impl(
925        text,
926        rect,
927        style,
928        fallback_color,
929        font_size,
930        scale,
931        RasterFontRef {
932            font,
933            ab_glyph_scale_factor: 1.0,
934            weight: FontWeight::NORMAL,
935            style: FontStyle::Normal,
936        },
937    )
938}
939
940fn rasterize_text_to_image_impl(
941    text: &str,
942    rect: Rect,
943    style: &TextStyle,
944    fallback_color: Color,
945    font_size: f32,
946    scale: f32,
947    font_ref: RasterFontRef<'_, impl Font>,
948) -> Option<ImageBitmap> {
949    if text.is_empty()
950        || rect.width <= 0.0
951        || rect.height <= 0.0
952        || !font_size.is_finite()
953        || font_size <= 0.0
954        || !scale.is_finite()
955        || scale <= 0.0
956    {
957        return None;
958    }
959
960    let width = rect.width.ceil().max(1.0) as u32;
961    let height = rect.height.ceil().max(1.0) as u32;
962    let mut canvas = vec![[0.0f32; 4]; (width * height) as usize];
963
964    let fallback_brush = Brush::solid(fallback_color);
965    let (brush, brush_alpha_multiplier) = match style.span_style.brush.as_ref() {
966        Some(brush) => (brush, style.span_style.alpha.unwrap_or(1.0).clamp(0.0, 1.0)),
967        None => (&fallback_brush, 1.0),
968    };
969    let raster_style = match style.span_style.draw_style.unwrap_or(TextDrawStyle::Fill) {
970        TextDrawStyle::Fill => GlyphRasterStyle::Fill,
971        TextDrawStyle::Stroke { width } => {
972            if width.is_finite() && width > 0.0 {
973                GlyphRasterStyle::Stroke {
974                    width_px: width * scale,
975                }
976            } else {
977                GlyphRasterStyle::Fill
978            }
979        }
980    };
981    let shadow = style
982        .span_style
983        .shadow
984        .filter(|shadow| shadow.color.3 > 0.0);
985    let static_text_motion = style
986        .paragraph_style
987        .text_motion
988        .unwrap_or(TextMotion::Static)
989        == TextMotion::Static;
990
991    let origin_x = if static_text_motion {
992        0.0
993    } else {
994        rect.x.fract()
995    };
996    let origin_y = if static_text_motion {
997        0.0
998    } else {
999        rect.y.fract()
1000    };
1001
1002    let font = font_ref.font;
1003    let font_px_size = font_size * scale * font_ref.ab_glyph_scale_factor;
1004    let weight_synthesis = TextWeightSynthesis::for_style(style, font_ref.weight, font_size, scale);
1005    let style_synthesis = TextStyleSynthesis::for_style(style, font_ref.style, font_size, scale);
1006    let metrics = vertical_metrics(font, font_px_size);
1007    let line_height = (style.resolve_line_height(14.0, font_size * 1.4) * scale).max(1.0);
1008    let first_baseline_y = baseline_y_for_line_box(metrics, line_height);
1009
1010    for (line_idx, line) in text.split('\n').enumerate() {
1011        let baseline_y = first_baseline_y + line_idx as f32 * line_height + origin_y;
1012        let offset = point(origin_x, baseline_y);
1013
1014        for glyph in layout_line_glyphs(font, line, font_px_size, offset) {
1015            let glyph = align_glyph_for_text_motion(glyph, static_text_motion);
1016            let Some((outlined, bounds)) = outline_glyph_with_bounds(font, &glyph) else {
1017                continue;
1018            };
1019            let Some(mask) = build_glyph_mask(font, &glyph, &outlined, bounds, raster_style) else {
1020                continue;
1021            };
1022            let mask = synthesize_glyph_weight(mask, weight_synthesis);
1023            let mask = synthesize_glyph_style(mask, style_synthesis);
1024
1025            if let Some(shadow) = shadow {
1026                draw_shadow_mask(
1027                    &mut canvas,
1028                    width,
1029                    height,
1030                    &mask,
1031                    shadow,
1032                    scale,
1033                    static_text_motion,
1034                );
1035            }
1036
1037            draw_mask_glyph(
1038                &mut canvas,
1039                width,
1040                height,
1041                &mask,
1042                brush,
1043                brush_alpha_multiplier,
1044                rect,
1045            );
1046        }
1047    }
1048
1049    let mut rgba = vec![0u8; canvas.len() * 4];
1050    for (index, pixel) in canvas.iter().enumerate() {
1051        let base = index * 4;
1052        rgba[base] = (pixel[0].clamp(0.0, 1.0) * 255.0).round() as u8;
1053        rgba[base + 1] = (pixel[1].clamp(0.0, 1.0) * 255.0).round() as u8;
1054        rgba[base + 2] = (pixel[2].clamp(0.0, 1.0) * 255.0).round() as u8;
1055        rgba[base + 3] = (pixel[3].clamp(0.0, 1.0) * 255.0).round() as u8;
1056    }
1057
1058    ImageBitmap::from_rgba8(width, height, rgba).ok()
1059}
1060
1061fn resolve_font_size(style: &TextStyle) -> f32 {
1062    style.resolve_font_size(14.0)
1063}
1064
1065fn baseline_y_for_line_box(
1066    metrics: crate::font_layout::FontVerticalMetrics,
1067    line_height: f32,
1068) -> f32 {
1069    metrics.ascent + (line_height - metrics.natural_line_height) * 0.5
1070}
1071
1072fn resolve_line_height(style: &TextStyle, font_size: f32) -> f32 {
1073    style.resolve_line_height(14.0, font_size)
1074}
1075
1076fn resolve_letter_spacing(style: &TextStyle, font_size: f32) -> f32 {
1077    let _ = font_size;
1078    style.resolve_letter_spacing(14.0)
1079}
1080
1081fn fallback_char_width(font_size: f32) -> f32 {
1082    font_size.max(1.0) * 0.55
1083}
1084
1085fn fallback_line_height(style: &TextStyle, font_size: f32) -> f32 {
1086    resolve_line_height(style, font_size.max(1.0) * 1.2)
1087}
1088
1089fn fallback_line_heights(text: &str, style: &TextStyle, font_size: f32) -> Vec<f32> {
1090    let line_count = text.split('\n').count().max(1);
1091    vec![fallback_line_height(style, font_size); line_count]
1092}
1093
1094fn fallback_text_metrics(text: &str, style: &TextStyle, font_size: f32) -> TextMetrics {
1095    let line_height = fallback_line_height(style, font_size);
1096    let char_width = fallback_char_width(font_size);
1097    let letter_spacing = resolve_letter_spacing(style, font_size);
1098    let mut line_count = 0usize;
1099    let mut max_width = 0.0f32;
1100
1101    for line in text.split('\n') {
1102        line_count += 1;
1103        let char_count = line.chars().count();
1104        let spacing = char_count.saturating_sub(1) as f32 * letter_spacing;
1105        max_width = max_width.max(char_count as f32 * char_width + spacing);
1106    }
1107
1108    let line_count = line_count.max(1);
1109    TextMetrics {
1110        width: max_width,
1111        height: line_count as f32 * line_height,
1112        line_height,
1113        line_count,
1114    }
1115}
1116
1117fn fallback_cursor_x_for_offset(text: &str, style: &TextStyle, offset: usize) -> f32 {
1118    let font_size = resolve_font_size(style);
1119    let clamped = clamp_to_char_boundary(text, offset.min(text.len()));
1120    let line_start = text[..clamped].rfind('\n').map_or(0, |index| index + 1);
1121    let char_count = text[line_start..clamped].chars().count();
1122    let spacing = char_count.saturating_sub(1) as f32 * resolve_letter_spacing(style, font_size);
1123    char_count as f32 * fallback_char_width(font_size) + spacing
1124}
1125
1126fn fallback_text_offset_for_position(text: &str, style: &TextStyle, x: f32, y: f32) -> usize {
1127    if text.is_empty() {
1128        return 0;
1129    }
1130
1131    let font_size = resolve_font_size(style);
1132    let line_height = fallback_line_height(style, font_size);
1133    let line_index = (y / line_height).floor().max(0.0) as usize;
1134    let lines: Vec<&str> = text.split('\n').collect();
1135    let target_line = line_index.min(lines.len().saturating_sub(1));
1136
1137    let mut line_start_byte = 0;
1138    for line in lines.iter().take(target_line) {
1139        line_start_byte += line.len() + 1;
1140    }
1141
1142    let line_text = lines.get(target_line).copied().unwrap_or("");
1143    if line_text.is_empty() {
1144        return line_start_byte;
1145    }
1146
1147    let advance =
1148        (fallback_char_width(font_size) + resolve_letter_spacing(style, font_size)).max(1.0);
1149    let target_char = (x / advance).round().max(0.0) as usize;
1150    line_start_byte + byte_offset_for_char_index(line_text, target_char)
1151}
1152
1153fn fallback_layout_text(text: &str, style: &TextStyle) -> TextLayoutResult {
1154    let font_size = resolve_font_size(style);
1155    let line_height = fallback_line_height(style, font_size);
1156    let char_width = fallback_char_width(font_size);
1157    let letter_spacing = resolve_letter_spacing(style, font_size);
1158
1159    let mut glyph_x_positions = Vec::new();
1160    let mut char_to_byte = Vec::new();
1161    let mut glyph_layouts = Vec::new();
1162    let mut lines = Vec::new();
1163    let mut current_x = 0.0f32;
1164    let mut line_start = 0;
1165    let mut y = 0.0f32;
1166
1167    let mut iter = text.char_indices().peekable();
1168    while let Some((byte_offset, ch)) = iter.next() {
1169        glyph_x_positions.push(current_x);
1170        char_to_byte.push(byte_offset);
1171
1172        if ch == '\n' {
1173            lines.push(LineLayout {
1174                start_offset: line_start,
1175                end_offset: byte_offset,
1176                y,
1177                height: line_height,
1178            });
1179            line_start = byte_offset + 1;
1180            y += line_height;
1181            current_x = 0.0;
1182        } else {
1183            glyph_layouts.push(GlyphLayout {
1184                line_index: lines.len(),
1185                start_offset: byte_offset,
1186                end_offset: byte_offset + ch.len_utf8(),
1187                x: current_x,
1188                y,
1189                width: char_width,
1190                height: line_height,
1191            });
1192            current_x += char_width;
1193            if let Some((_, next)) = iter.peek() {
1194                if *next != '\n' {
1195                    current_x += letter_spacing;
1196                }
1197            }
1198        }
1199    }
1200
1201    glyph_x_positions.push(current_x);
1202    char_to_byte.push(text.len());
1203    lines.push(LineLayout {
1204        start_offset: line_start,
1205        end_offset: text.len(),
1206        y,
1207        height: line_height,
1208    });
1209
1210    let metrics = fallback_text_metrics(text, style, font_size);
1211    TextLayoutResult::new(
1212        text,
1213        TextLayoutData {
1214            width: metrics.width,
1215            height: metrics.height,
1216            line_height,
1217            glyph_x_positions,
1218            char_to_byte,
1219            glyph_layouts,
1220            lines,
1221        },
1222    )
1223}
1224
1225fn byte_offset_for_char_index(text: &str, char_index: usize) -> usize {
1226    text.char_indices()
1227        .map(|(index, _)| index)
1228        .nth(char_index)
1229        .unwrap_or(text.len())
1230}
1231
1232fn measure_text_impl(
1233    text: &str,
1234    style: &TextStyle,
1235    font_size: f32,
1236    glyph_font_size: f32,
1237    font: &impl Font,
1238    resolved_style: FontStyle,
1239    resolved_weight: FontWeight,
1240) -> TextMetrics {
1241    let line_height = resolve_line_height(style, font_size * 1.4);
1242    let letter_spacing = resolve_letter_spacing(style, font_size);
1243    let weight_synthesis = TextWeightSynthesis::for_style(style, resolved_weight, font_size, 1.0);
1244    let style_synthesis = TextStyleSynthesis::for_style(style, resolved_style, font_size, 1.0);
1245
1246    let lines: Vec<&str> = text.split('\n').collect();
1247    let line_count = lines.len().max(1);
1248
1249    let mut max_width: f32 = 0.0;
1250    for line in &lines {
1251        let line_width = line_advance_width(font, line, glyph_font_size);
1252        let char_spacing = (line.chars().count().saturating_sub(1) as f32) * letter_spacing;
1253        let line_width = (weight_synthesis.apply_width(line_width) + char_spacing).max(0.0);
1254        let line_width = if line.is_empty() {
1255            line_width
1256        } else {
1257            line_width + style_synthesis.visual_overhang_px()
1258        };
1259        max_width = max_width.max(line_width);
1260    }
1261
1262    TextMetrics {
1263        width: max_width,
1264        height: line_count as f32 * line_height,
1265        line_height,
1266        line_count,
1267    }
1268}
1269
1270fn measure_annotated_text_with_resolver(
1271    text: &AnnotatedString,
1272    style: &TextStyle,
1273    font_size: f32,
1274    fonts: &SoftwareTextFontSet,
1275) -> TextMetrics {
1276    let Some(base_font) = fonts.resolve(style) else {
1277        return fallback_text_metrics(text.text.as_str(), style, font_size);
1278    };
1279    let base_line_height = line_height_for_style(style, font_size, &base_font.font);
1280    let mut boundaries = text.span_boundaries();
1281    for (offset, ch) in text.text.char_indices() {
1282        if ch == '\n' {
1283            boundaries.push(offset);
1284            boundaries.push(offset + ch.len_utf8());
1285        }
1286    }
1287    boundaries.sort_unstable();
1288    boundaries.dedup();
1289    boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
1290
1291    let mut line_count = 1usize;
1292    let mut max_width = 0.0f32;
1293    let mut current_line_width = 0.0f32;
1294
1295    for range in boundaries.windows(2) {
1296        let start = range[0];
1297        let end = range[1];
1298        if start == end {
1299            continue;
1300        }
1301        let segment = &text.text[start..end];
1302        let segment_style = effective_style_for_range(text, style, start, end);
1303        let segment_font_size = resolve_font_size(&segment_style);
1304        let Some(segment_font) = fonts.resolve(&segment_style) else {
1305            let mut remaining = segment;
1306            loop {
1307                if let Some(newline_offset) = remaining.find('\n') {
1308                    let before_newline = &remaining[..newline_offset];
1309                    if !before_newline.is_empty() {
1310                        current_line_width += fallback_text_metrics(
1311                            before_newline,
1312                            &segment_style,
1313                            segment_font_size,
1314                        )
1315                        .width;
1316                    }
1317                    max_width = max_width.max(current_line_width);
1318                    current_line_width = 0.0;
1319                    line_count += 1;
1320                    remaining = &remaining[newline_offset + 1..];
1321                    if remaining.is_empty() {
1322                        break;
1323                    }
1324                } else {
1325                    if !remaining.is_empty() {
1326                        current_line_width +=
1327                            fallback_text_metrics(remaining, &segment_style, segment_font_size)
1328                                .width;
1329                    }
1330                    break;
1331                }
1332            }
1333            continue;
1334        };
1335
1336        let mut remaining = segment;
1337        loop {
1338            if let Some(newline_offset) = remaining.find('\n') {
1339                let before_newline = &remaining[..newline_offset];
1340                if !before_newline.is_empty() {
1341                    let metrics = measure_text_impl(
1342                        before_newline,
1343                        &segment_style,
1344                        segment_font_size,
1345                        segment_font.ab_glyph_px_size(segment_font_size),
1346                        &segment_font.font,
1347                        segment_font.style(),
1348                        segment_font.weight(),
1349                    );
1350                    current_line_width += metrics.width;
1351                }
1352                max_width = max_width.max(current_line_width);
1353                current_line_width = 0.0;
1354                line_count += 1;
1355                remaining = &remaining[newline_offset + 1..];
1356                if remaining.is_empty() {
1357                    break;
1358                }
1359            } else {
1360                if !remaining.is_empty() {
1361                    let metrics = measure_text_impl(
1362                        remaining,
1363                        &segment_style,
1364                        segment_font_size,
1365                        segment_font.ab_glyph_px_size(segment_font_size),
1366                        &segment_font.font,
1367                        segment_font.style(),
1368                        segment_font.weight(),
1369                    );
1370                    current_line_width += metrics.width;
1371                }
1372                break;
1373            }
1374        }
1375    }
1376
1377    max_width = max_width.max(current_line_width);
1378
1379    let line_heights = annotated_line_heights_with_resolver(text, style, font_size, fonts);
1380    let total_height = line_heights.iter().sum();
1381    let max_line_height = line_heights.into_iter().fold(base_line_height, f32::max);
1382
1383    TextMetrics {
1384        width: max_width,
1385        height: total_height,
1386        line_height: max_line_height,
1387        line_count,
1388    }
1389}
1390
1391fn annotated_line_heights_with_resolver(
1392    text: &AnnotatedString,
1393    style: &TextStyle,
1394    font_size: f32,
1395    fonts: &SoftwareTextFontSet,
1396) -> Vec<f32> {
1397    let Some(base_font) = fonts.resolve(style) else {
1398        return fallback_line_heights(text.text.as_str(), style, font_size);
1399    };
1400    let base_line_height = line_height_for_style(style, font_size, &base_font.font);
1401    let mut line_heights = vec![base_line_height];
1402    let mut boundaries = text.span_boundaries();
1403    for (offset, ch) in text.text.char_indices() {
1404        if ch == '\n' {
1405            boundaries.push(offset);
1406            boundaries.push(offset + ch.len_utf8());
1407        }
1408    }
1409    boundaries.sort_unstable();
1410    boundaries.dedup();
1411    boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
1412
1413    let mut line_index = 0usize;
1414    for range in boundaries.windows(2) {
1415        let start = range[0];
1416        let end = range[1];
1417        if start == end {
1418            continue;
1419        }
1420        let segment = &text.text[start..end];
1421        let segment_style = effective_style_for_range(text, style, start, end);
1422        let segment_font_size = resolve_font_size(&segment_style);
1423        let segment_line_height = if let Some(segment_font) = fonts.resolve(&segment_style) {
1424            line_height_for_style(&segment_style, segment_font_size, &segment_font.font)
1425        } else {
1426            fallback_line_height(&segment_style, segment_font_size)
1427        };
1428        for ch in segment.chars() {
1429            line_heights[line_index] = line_heights[line_index].max(segment_line_height);
1430            if ch == '\n' {
1431                line_index += 1;
1432                if line_heights.len() <= line_index {
1433                    line_heights.push(base_line_height);
1434                }
1435            }
1436        }
1437    }
1438
1439    line_heights
1440}
1441
1442fn effective_style_for_range(
1443    text: &AnnotatedString,
1444    style: &TextStyle,
1445    start: usize,
1446    end: usize,
1447) -> TextStyle {
1448    let mut effective = style.clone();
1449    for span in &text.span_styles {
1450        if span.range.start < end && span.range.end > start {
1451            effective.span_style = effective.span_style.merge(&span.item);
1452        }
1453    }
1454    effective
1455}
1456
1457fn line_height_for_style(style: &TextStyle, font_size: f32, font: &impl Font) -> f32 {
1458    let _ = font;
1459    resolve_line_height(style, font_size * 1.4)
1460}
1461
1462fn clamp_to_char_boundary(text: &str, mut offset: usize) -> usize {
1463    offset = offset.min(text.len());
1464    while offset > 0 && !text.is_char_boundary(offset) {
1465        offset -= 1;
1466    }
1467    offset
1468}
1469
1470fn align_glyph_for_text_motion(glyph: Glyph, static_text_motion: bool) -> Glyph {
1471    align_glyph_to_pixel_grid(glyph, static_text_motion)
1472}
1473
1474fn blend_src_over(dst: &mut [f32; 4], src: [f32; 4]) {
1475    let src_alpha = src[3].clamp(0.0, 1.0);
1476    if src_alpha <= 0.0 {
1477        return;
1478    }
1479
1480    let dst_alpha = dst[3].clamp(0.0, 1.0);
1481    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
1482
1483    if out_alpha <= f32::EPSILON {
1484        *dst = [0.0, 0.0, 0.0, 0.0];
1485        return;
1486    }
1487
1488    for channel in 0..3 {
1489        let src_premult = src[channel].clamp(0.0, 1.0) * src_alpha;
1490        let dst_premult = dst[channel].clamp(0.0, 1.0) * dst_alpha;
1491        dst[channel] =
1492            ((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0);
1493    }
1494    dst[3] = out_alpha;
1495}
1496
1497fn draw_mask_glyph(
1498    canvas: &mut [[f32; 4]],
1499    width: u32,
1500    height: u32,
1501    mask: &GlyphMask,
1502    brush: &Brush,
1503    brush_alpha_multiplier: f32,
1504    brush_rect: Rect,
1505) {
1506    for y in 0..mask.height {
1507        let py = mask.origin_y + y as i32;
1508        if py < 0 || py >= height as i32 {
1509            continue;
1510        }
1511
1512        for x in 0..mask.width {
1513            let px = mask.origin_x + x as i32;
1514            if px < 0 || px >= width as i32 {
1515                continue;
1516            }
1517
1518            let coverage = mask.alpha[y * mask.width + x];
1519            if coverage <= 0.0 {
1520                continue;
1521            }
1522
1523            let sample = sample_brush_rgba(
1524                brush,
1525                brush_rect,
1526                brush_rect.x + px as f32 + 0.5,
1527                brush_rect.y + py as f32 + 0.5,
1528            );
1529            let alpha = coverage * sample[3] * brush_alpha_multiplier;
1530            if alpha <= 0.0 {
1531                continue;
1532            }
1533            let idx = (py as u32 * width + px as u32) as usize;
1534            blend_src_over(
1535                &mut canvas[idx],
1536                [sample[0], sample[1], sample[2], alpha.clamp(0.0, 1.0)],
1537            );
1538        }
1539    }
1540}
1541
1542fn draw_shadow_mask(
1543    canvas: &mut [[f32; 4]],
1544    width: u32,
1545    height: u32,
1546    mask: &GlyphMask,
1547    shadow: Shadow,
1548    text_scale: f32,
1549    static_text_motion: bool,
1550) {
1551    if mask.width == 0 || mask.height == 0 {
1552        return;
1553    }
1554
1555    let shadow_dx = shadow.offset.x * text_scale;
1556    let shadow_dy = shadow.offset.y * text_scale;
1557    let blur_radius = (shadow.blur_radius * text_scale).max(0.0);
1558    let sigma = shadow_blur_sigma(blur_radius);
1559    let blur_margin = if sigma > 0.0 {
1560        (sigma * 3.0).ceil() as i32
1561    } else {
1562        0
1563    };
1564
1565    let padded_width = mask.width + (blur_margin as usize) * 2;
1566    let padded_height = mask.height + (blur_margin as usize) * 2;
1567    let mut padded_mask = vec![0.0f32; padded_width * padded_height];
1568
1569    for y in 0..mask.height {
1570        let src_offset = y * mask.width;
1571        let dst_offset = (y + blur_margin as usize) * padded_width + blur_margin as usize;
1572        padded_mask[dst_offset..dst_offset + mask.width]
1573            .copy_from_slice(&mask.alpha[src_offset..src_offset + mask.width]);
1574    }
1575
1576    let blurred = if sigma > 0.0 {
1577        gaussian_blur_alpha(&padded_mask, padded_width, padded_height, sigma)
1578    } else {
1579        padded_mask
1580    };
1581
1582    let shadow_rgba = color_to_rgba(shadow.color);
1583    let shadow_origin_x = mask.origin_x - blur_margin;
1584    let shadow_origin_y = mask.origin_y - blur_margin;
1585
1586    for y in 0..padded_height {
1587        for x in 0..padded_width {
1588            let alpha = blurred[y * padded_width + x] * shadow_rgba[3];
1589            if alpha <= 0.0 {
1590                continue;
1591            }
1592
1593            let target_x = shadow_origin_x as f32 + x as f32 + shadow_dx;
1594            let target_y = shadow_origin_y as f32 + y as f32 + shadow_dy;
1595            if static_text_motion {
1596                blend_shadow_pixel(
1597                    canvas,
1598                    width,
1599                    height,
1600                    target_x.round() as i32,
1601                    target_y.round() as i32,
1602                    shadow_rgba,
1603                    alpha.clamp(0.0, 1.0),
1604                );
1605            } else {
1606                blend_shadow_pixel_subpixel(
1607                    canvas,
1608                    width,
1609                    height,
1610                    target_x,
1611                    target_y,
1612                    shadow_rgba,
1613                    alpha.clamp(0.0, 1.0),
1614                );
1615            }
1616        }
1617    }
1618}
1619
1620fn blend_shadow_pixel(
1621    canvas: &mut [[f32; 4]],
1622    width: u32,
1623    height: u32,
1624    px: i32,
1625    py: i32,
1626    color: [f32; 4],
1627    alpha: f32,
1628) {
1629    if px < 0 || py < 0 || px >= width as i32 || py >= height as i32 || alpha <= 0.0 {
1630        return;
1631    }
1632    let idx = (py as u32 * width + px as u32) as usize;
1633    blend_src_over(
1634        &mut canvas[idx],
1635        [color[0], color[1], color[2], alpha.clamp(0.0, 1.0)],
1636    );
1637}
1638
1639fn blend_shadow_pixel_subpixel(
1640    canvas: &mut [[f32; 4]],
1641    width: u32,
1642    height: u32,
1643    x: f32,
1644    y: f32,
1645    color: [f32; 4],
1646    alpha: f32,
1647) {
1648    if alpha <= 0.0 {
1649        return;
1650    }
1651
1652    let base_x = x.floor();
1653    let base_y = y.floor();
1654    let frac_x = x - base_x;
1655    let frac_y = y - base_y;
1656    let base_x_i32 = base_x as i32;
1657    let base_y_i32 = base_y as i32;
1658    let weights = [
1659        ((1.0 - frac_x) * (1.0 - frac_y), 0i32, 0i32),
1660        (frac_x * (1.0 - frac_y), 1, 0),
1661        ((1.0 - frac_x) * frac_y, 0, 1),
1662        (frac_x * frac_y, 1, 1),
1663    ];
1664
1665    for (weight, dx, dy) in weights {
1666        if weight <= 0.0 {
1667            continue;
1668        }
1669        blend_shadow_pixel(
1670            canvas,
1671            width,
1672            height,
1673            base_x_i32 + dx,
1674            base_y_i32 + dy,
1675            color,
1676            alpha * weight,
1677        );
1678    }
1679}
1680
1681fn shadow_blur_sigma(blur_radius: f32) -> f32 {
1682    if blur_radius <= 0.0 {
1683        0.0
1684    } else {
1685        (blur_radius * SHADOW_SIGMA_SCALE + SHADOW_SIGMA_BIAS).max(0.5)
1686    }
1687}
1688
1689fn gaussian_blur_alpha(src: &[f32], width: usize, height: usize, sigma: f32) -> Vec<f32> {
1690    let kernel = gaussian_kernel_1d(sigma);
1691    if kernel.len() == 1 {
1692        return src.to_vec();
1693    }
1694    let half = (kernel.len() / 2) as i32;
1695
1696    let mut horizontal = vec![0.0f32; src.len()];
1697    for y in 0..height {
1698        for x in 0..width {
1699            let mut sum = 0.0f32;
1700            for (index, weight) in kernel.iter().enumerate() {
1701                let offset = index as i32 - half;
1702                let sample_x = (x as i32 + offset).clamp(0, width as i32 - 1) as usize;
1703                sum += src[y * width + sample_x] * *weight;
1704            }
1705            horizontal[y * width + x] = sum;
1706        }
1707    }
1708
1709    let mut output = vec![0.0f32; src.len()];
1710    for y in 0..height {
1711        for x in 0..width {
1712            let mut sum = 0.0f32;
1713            for (index, weight) in kernel.iter().enumerate() {
1714                let offset = index as i32 - half;
1715                let sample_y = (y as i32 + offset).clamp(0, height as i32 - 1) as usize;
1716                sum += horizontal[sample_y * width + x] * *weight;
1717            }
1718            output[y * width + x] = sum;
1719        }
1720    }
1721
1722    output
1723}
1724
1725fn gaussian_kernel_1d(sigma: f32) -> Vec<f32> {
1726    let half = ((sigma * 3.0).ceil() as i32).clamp(1, MAX_GAUSSIAN_KERNEL_HALF);
1727    if half <= 0 {
1728        return vec![1.0];
1729    }
1730
1731    let mut kernel = Vec::with_capacity((half * 2 + 1) as usize);
1732    let mut sum = 0.0f32;
1733    for offset in -half..=half {
1734        let distance = offset as f32;
1735        let weight = (-0.5 * (distance / sigma).powi(2)).exp();
1736        kernel.push(weight);
1737        sum += weight;
1738    }
1739
1740    if sum > f32::EPSILON {
1741        for weight in &mut kernel {
1742            *weight /= sum;
1743        }
1744    }
1745
1746    kernel
1747}
1748
1749fn outline_glyph_with_bounds(
1750    font: &impl Font,
1751    glyph: &Glyph,
1752) -> Option<(OutlinedGlyph, GlyphPixelBounds)> {
1753    let outlined = font.outline_glyph(glyph.clone())?;
1754    let bounds = pixel_bounds_from_outlined(&outlined);
1755    Some((outlined, bounds))
1756}
1757
1758fn build_glyph_mask(
1759    font: &impl Font,
1760    glyph: &Glyph,
1761    outlined: &OutlinedGlyph,
1762    bounds: GlyphPixelBounds,
1763    style: GlyphRasterStyle,
1764) -> Option<GlyphMask> {
1765    match style {
1766        GlyphRasterStyle::Fill => build_fill_mask(outlined, bounds),
1767        GlyphRasterStyle::Stroke { width_px } => {
1768            build_stroke_mask(font, glyph, outlined, bounds, width_px)
1769        }
1770    }
1771}
1772
1773fn build_fill_mask(outlined: &OutlinedGlyph, bounds: GlyphPixelBounds) -> Option<GlyphMask> {
1774    let mask_width = bounds.width();
1775    let mask_height = bounds.height();
1776    if mask_width == 0 || mask_height == 0 {
1777        return None;
1778    }
1779
1780    let mut alpha = vec![0.0f32; mask_width * mask_height];
1781    outlined.draw(|gx, gy, value| {
1782        let idx = gy as usize * mask_width + gx as usize;
1783        alpha[idx] = value;
1784    });
1785
1786    Some(GlyphMask {
1787        alpha,
1788        width: mask_width,
1789        height: mask_height,
1790        origin_x: bounds.min_x,
1791        origin_y: bounds.min_y,
1792    })
1793}
1794
1795fn build_stroke_mask(
1796    font: &impl Font,
1797    glyph: &Glyph,
1798    outlined: &OutlinedGlyph,
1799    bounds: GlyphPixelBounds,
1800    stroke_width_px: f32,
1801) -> Option<GlyphMask> {
1802    if !stroke_width_px.is_finite() || stroke_width_px <= 0.0 {
1803        return build_fill_mask(outlined, bounds);
1804    }
1805
1806    let mask_width = bounds.max_x - bounds.min_x;
1807    let mask_height = bounds.max_y - bounds.min_y;
1808    if mask_width <= 0 || mask_height <= 0 {
1809        return None;
1810    }
1811
1812    let half_width = stroke_width_px * 0.5;
1813    let miter_pad = (half_width * COMPOSE_STROKE_MITER_LIMIT).ceil();
1814    let pad = miter_pad.max(1.0) as i32 + 1;
1815    let path = build_outline_path(font, glyph, bounds, pad)?;
1816    let raster_width = mask_width + pad * 2;
1817    let raster_height = mask_height + pad * 2;
1818    if raster_width <= 0 || raster_height <= 0 {
1819        return None;
1820    }
1821
1822    let mut pixmap = Pixmap::new(raster_width as u32, raster_height as u32)?;
1823    let mut paint = Paint::default();
1824    paint.set_color_rgba8(255, 255, 255, 255);
1825    paint.anti_alias = true;
1826
1827    let stroke = Stroke {
1828        width: stroke_width_px,
1829        line_cap: LineCap::Butt,
1830        line_join: LineJoin::Miter,
1831        miter_limit: COMPOSE_STROKE_MITER_LIMIT,
1832        ..Stroke::default()
1833    };
1834
1835    pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
1836
1837    let alpha = pixmap
1838        .data()
1839        .chunks_exact(4)
1840        .map(|pixel| pixel[3] as f32 / 255.0)
1841        .collect();
1842
1843    Some(GlyphMask {
1844        alpha,
1845        width: raster_width as usize,
1846        height: raster_height as usize,
1847        origin_x: bounds.min_x - pad,
1848        origin_y: bounds.min_y - pad,
1849    })
1850}
1851
1852fn synthesize_glyph_weight(mask: GlyphMask, synthesis: TextWeightSynthesis) -> GlyphMask {
1853    let horizontal_shift = synthetic_weight_shift_px(synthesis.embolden_px);
1854    if horizontal_shift == 0 || mask.width == 0 || mask.height == 0 {
1855        return mask;
1856    }
1857
1858    let vertical_shift = (horizontal_shift / 2).min(1);
1859    let output_width = mask.width + horizontal_shift;
1860    let output_height = mask.height + vertical_shift * 2;
1861    let mut alpha = vec![0.0f32; output_width * output_height];
1862    for y in 0..mask.height {
1863        for x in 0..mask.width {
1864            let coverage = mask.alpha[y * mask.width + x];
1865            if coverage <= 0.0 {
1866                continue;
1867            }
1868            for dy in 0..=(vertical_shift * 2) {
1869                let output_y = y + dy;
1870                for dx in 0..=horizontal_shift {
1871                    let output_x = x + dx;
1872                    let output_index = output_y * output_width + output_x;
1873                    if coverage > alpha[output_index] {
1874                        alpha[output_index] = coverage;
1875                    }
1876                }
1877            }
1878        }
1879    }
1880
1881    GlyphMask {
1882        alpha,
1883        width: output_width,
1884        height: output_height,
1885        origin_x: mask.origin_x,
1886        origin_y: mask.origin_y - vertical_shift as i32,
1887    }
1888}
1889
1890fn synthesize_glyph_style(mask: GlyphMask, synthesis: TextStyleSynthesis) -> GlyphMask {
1891    if synthesis.slant <= 0.0 || mask.width == 0 || mask.height == 0 {
1892        return mask;
1893    }
1894
1895    let max_shift = ((mask.height.saturating_sub(1)) as f32 * synthesis.slant).ceil() as usize;
1896    if max_shift == 0 {
1897        return mask;
1898    }
1899
1900    let output_width = mask.width + max_shift + 1;
1901    let mut alpha = vec![0.0f32; output_width * mask.height];
1902    for y in 0..mask.height {
1903        let shift = (mask.height.saturating_sub(1) - y) as f32 * synthesis.slant;
1904        let shift_floor = shift.floor() as usize;
1905        let shift_fraction = shift - shift.floor();
1906        for x in 0..mask.width {
1907            let coverage = mask.alpha[y * mask.width + x];
1908            if coverage <= 0.0 {
1909                continue;
1910            }
1911
1912            let output_x = x + shift_floor;
1913            let left_index = y * output_width + output_x;
1914            let left_coverage = coverage * (1.0 - shift_fraction);
1915            if left_coverage > alpha[left_index] {
1916                alpha[left_index] = left_coverage;
1917            }
1918
1919            if shift_fraction > 0.0 {
1920                let right_index = left_index + 1;
1921                let right_coverage = coverage * shift_fraction;
1922                if right_coverage > alpha[right_index] {
1923                    alpha[right_index] = right_coverage;
1924                }
1925            }
1926        }
1927    }
1928
1929    GlyphMask {
1930        alpha,
1931        width: output_width,
1932        height: mask.height,
1933        origin_x: mask.origin_x,
1934        origin_y: mask.origin_y,
1935    }
1936}
1937
1938fn synthetic_weight_shift_px(embolden_px: f32) -> usize {
1939    if !embolden_px.is_finite() || embolden_px < 0.35 {
1940        return 0;
1941    }
1942    embolden_px.ceil().max(1.0) as usize
1943}
1944
1945fn build_outline_path(
1946    font: &impl Font,
1947    glyph: &Glyph,
1948    bounds: GlyphPixelBounds,
1949    pad: i32,
1950) -> Option<Path> {
1951    let outline = font.outline(glyph.id)?;
1952    let scale_factor = font.as_scaled(glyph.scale).scale_factor();
1953    let mut builder = PathBuilder::new();
1954    let mut has_segments = false;
1955    let mut current_end = None;
1956    let mut subpath_start = None;
1957
1958    for curve in outline.curves {
1959        match curve {
1960            ab_glyph::OutlineCurve::Line(p0, p1) => {
1961                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
1962                let end = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
1963                if current_end != Some(start) {
1964                    if current_end.is_some() {
1965                        builder.close();
1966                    }
1967                    builder.move_to(start.0, start.1);
1968                    subpath_start = Some(start);
1969                }
1970                builder.line_to(end.0, end.1);
1971                if subpath_start == Some(end) {
1972                    builder.close();
1973                    current_end = None;
1974                    subpath_start = None;
1975                } else {
1976                    current_end = Some(end);
1977                }
1978            }
1979            ab_glyph::OutlineCurve::Quad(p0, p1, p2) => {
1980                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
1981                let control = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
1982                let end = transform_outline_point(p2, scale_factor, glyph, bounds, pad);
1983                if current_end != Some(start) {
1984                    if current_end.is_some() {
1985                        builder.close();
1986                    }
1987                    builder.move_to(start.0, start.1);
1988                    subpath_start = Some(start);
1989                }
1990                builder.quad_to(control.0, control.1, end.0, end.1);
1991                if subpath_start == Some(end) {
1992                    builder.close();
1993                    current_end = None;
1994                    subpath_start = None;
1995                } else {
1996                    current_end = Some(end);
1997                }
1998            }
1999            ab_glyph::OutlineCurve::Cubic(p0, p1, p2, p3) => {
2000                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
2001                let control1 = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
2002                let control2 = transform_outline_point(p2, scale_factor, glyph, bounds, pad);
2003                let end = transform_outline_point(p3, scale_factor, glyph, bounds, pad);
2004                if current_end != Some(start) {
2005                    if current_end.is_some() {
2006                        builder.close();
2007                    }
2008                    builder.move_to(start.0, start.1);
2009                    subpath_start = Some(start);
2010                }
2011                builder.cubic_to(control1.0, control1.1, control2.0, control2.1, end.0, end.1);
2012                if subpath_start == Some(end) {
2013                    builder.close();
2014                    current_end = None;
2015                    subpath_start = None;
2016                } else {
2017                    current_end = Some(end);
2018                }
2019            }
2020        }
2021        has_segments = true;
2022    }
2023
2024    if !has_segments {
2025        return None;
2026    }
2027
2028    if current_end.is_some() {
2029        builder.close();
2030    }
2031
2032    builder.finish()
2033}
2034
2035fn transform_outline_point(
2036    point: ab_glyph::Point,
2037    scale_factor: ab_glyph::PxScaleFactor,
2038    glyph: &Glyph,
2039    bounds: GlyphPixelBounds,
2040    pad: i32,
2041) -> (f32, f32) {
2042    (
2043        point.x * scale_factor.horizontal + glyph.position.x - bounds.min_x as f32 + pad as f32,
2044        point.y * -scale_factor.vertical + glyph.position.y - bounds.min_y as f32 + pad as f32,
2045    )
2046}
2047
2048#[cfg(test)]
2049mod tests {
2050    use super::*;
2051    use cranpose_ui::text::SpanStyle;
2052    use cranpose_ui_graphics::Point;
2053
2054    fn count_ink_pixels(image: &ImageBitmap) -> usize {
2055        image
2056            .pixels()
2057            .chunks_exact(4)
2058            .filter(|px| px[3] > 0)
2059            .count()
2060    }
2061
2062    fn average_ink_rgb(
2063        image: &ImageBitmap,
2064        x_start: u32,
2065        x_end: u32,
2066        y_start: u32,
2067        y_end: u32,
2068    ) -> Option<[f32; 3]> {
2069        let width = image.width();
2070        let height = image.height();
2071        let mut sums = [0.0f32; 3];
2072        let mut count = 0usize;
2073        let pixels = image.pixels();
2074
2075        let x_end = x_end.min(width);
2076        let y_end = y_end.min(height);
2077        for y in y_start.min(height)..y_end {
2078            for x in x_start.min(width)..x_end {
2079                let idx = ((y * width + x) * 4) as usize;
2080                let alpha = pixels[idx + 3];
2081                if alpha == 0 {
2082                    continue;
2083                }
2084                sums[0] += pixels[idx] as f32 / 255.0;
2085                sums[1] += pixels[idx + 1] as f32 / 255.0;
2086                sums[2] += pixels[idx + 2] as f32 / 255.0;
2087                count += 1;
2088            }
2089        }
2090
2091        if count == 0 {
2092            return None;
2093        }
2094        Some([
2095            sums[0] / count as f32,
2096            sums[1] / count as f32,
2097            sums[2] / count as f32,
2098        ])
2099    }
2100
2101    fn ink_x_range(image: &ImageBitmap) -> Option<(u32, u32)> {
2102        let width = image.width();
2103        let height = image.height();
2104        let pixels = image.pixels();
2105        let mut min_x = u32::MAX;
2106        let mut max_x = 0u32;
2107        let mut found = false;
2108        for y in 0..height {
2109            for x in 0..width {
2110                let idx = ((y * width + x) * 4) as usize;
2111                if pixels[idx + 3] > 0 {
2112                    min_x = min_x.min(x);
2113                    max_x = max_x.max(x + 1);
2114                    found = true;
2115                }
2116            }
2117        }
2118        found.then_some((min_x, max_x))
2119    }
2120
2121    fn ink_y_range(image: &ImageBitmap) -> Option<(u32, u32)> {
2122        let width = image.width();
2123        let height = image.height();
2124        let pixels = image.pixels();
2125        let mut min_y = u32::MAX;
2126        let mut max_y = 0u32;
2127        let mut found = false;
2128        for y in 0..height {
2129            for x in 0..width {
2130                let idx = ((y * width + x) * 4) as usize;
2131                if pixels[idx + 3] > 0 {
2132                    min_y = min_y.min(y);
2133                    max_y = max_y.max(y + 1);
2134                    found = true;
2135                }
2136            }
2137        }
2138        found.then_some((min_y, max_y))
2139    }
2140
2141    fn ink_centroid_x(image: &ImageBitmap, y_start: u32, y_end: u32) -> Option<f32> {
2142        let width = image.width();
2143        let height = image.height();
2144        let pixels = image.pixels();
2145        let mut weighted_x = 0.0f32;
2146        let mut total_alpha = 0.0f32;
2147
2148        for y in y_start.min(height)..y_end.min(height) {
2149            for x in 0..width {
2150                let idx = ((y * width + x) * 4) as usize;
2151                let alpha = pixels[idx + 3] as f32 / 255.0;
2152                if alpha <= 0.0 {
2153                    continue;
2154                }
2155                weighted_x += x as f32 * alpha;
2156                total_alpha += alpha;
2157            }
2158        }
2159
2160        (total_alpha > 0.0).then_some(weighted_x / total_alpha)
2161    }
2162
2163    fn vertical_slant_delta(image: &ImageBitmap) -> f32 {
2164        let (top, bottom) = ink_y_range(image).expect("image should contain ink");
2165        let mid = top + (bottom - top).max(1) / 2;
2166        let top_x = ink_centroid_x(image, top, mid).expect("top ink centroid");
2167        let bottom_x = ink_centroid_x(image, mid, bottom).expect("bottom ink centroid");
2168        top_x - bottom_x
2169    }
2170
2171    fn top_ink_row(image: &ImageBitmap) -> Option<u32> {
2172        let width = image.width();
2173        let height = image.height();
2174        let pixels = image.pixels();
2175        for y in 0..height {
2176            for x in 0..width {
2177                let idx = ((y * width + x) * 4) as usize;
2178                if pixels[idx + 3] > 0 {
2179                    return Some(y);
2180                }
2181            }
2182        }
2183        None
2184    }
2185
2186    fn reference_dilation_offsets(radius: i32) -> Vec<(i32, i32)> {
2187        let mut offsets = Vec::new();
2188        let squared_radius = radius * radius;
2189        for dy in -radius..=radius {
2190            for dx in -radius..=radius {
2191                if dx * dx + dy * dy <= squared_radius {
2192                    offsets.push((dx, dy));
2193                }
2194            }
2195        }
2196        if offsets.is_empty() {
2197            offsets.push((0, 0));
2198        }
2199        offsets
2200    }
2201
2202    fn reference_dilation_stroke_mask(fill: &GlyphMask, stroke_width: f32) -> GlyphMask {
2203        let radius = (stroke_width * 0.5).ceil() as i32;
2204        let offsets = reference_dilation_offsets(radius);
2205        let out_width = fill.width as i32 + radius * 2;
2206        let out_height = fill.height as i32 + radius * 2;
2207        let fill_width_i32 = fill.width as i32;
2208        let fill_height_i32 = fill.height as i32;
2209        let mut alpha = vec![0.0f32; (out_width * out_height) as usize];
2210
2211        for out_y in 0..out_height {
2212            let oy = out_y - radius;
2213            for out_x in 0..out_width {
2214                let ox = out_x - radius;
2215                let base_alpha =
2216                    if ox >= 0 && oy >= 0 && ox < fill_width_i32 && oy < fill_height_i32 {
2217                        fill.alpha[oy as usize * fill.width + ox as usize]
2218                    } else {
2219                        0.0
2220                    };
2221
2222                let mut dilated_alpha = 0.0f32;
2223                for (dx, dy) in &offsets {
2224                    let sx = ox + dx;
2225                    let sy = oy + dy;
2226                    if sx < 0 || sy < 0 || sx >= fill_width_i32 || sy >= fill_height_i32 {
2227                        continue;
2228                    }
2229                    let sample = fill.alpha[sy as usize * fill.width + sx as usize];
2230                    if sample > dilated_alpha {
2231                        dilated_alpha = sample;
2232                        if dilated_alpha >= 0.999 {
2233                            break;
2234                        }
2235                    }
2236                }
2237                alpha[out_y as usize * out_width as usize + out_x as usize] =
2238                    (dilated_alpha - base_alpha).max(0.0);
2239            }
2240        }
2241
2242        GlyphMask {
2243            alpha,
2244            width: out_width as usize,
2245            height: out_height as usize,
2246            origin_x: fill.origin_x - radius,
2247            origin_y: fill.origin_y - radius,
2248        }
2249    }
2250
2251    fn rasterize_reference_dilation_stroke(
2252        text: &str,
2253        rect: Rect,
2254        font_size: f32,
2255        stroke_width: f32,
2256        font: &impl Font,
2257    ) -> ImageBitmap {
2258        let width = rect.width.ceil().max(1.0) as u32;
2259        let height = rect.height.ceil().max(1.0) as u32;
2260        let mut canvas = vec![[0.0f32; 4]; (width * height) as usize];
2261
2262        let metrics = vertical_metrics(font, font_size);
2263        let baseline = baseline_y_for_line_box(metrics, font_size * 1.4);
2264        for glyph in layout_line_glyphs(font, text, font_size, point(0.0, baseline)) {
2265            let Some((outlined, bounds)) = outline_glyph_with_bounds(font, &glyph) else {
2266                continue;
2267            };
2268            let Some(fill) = build_fill_mask(&outlined, bounds) else {
2269                continue;
2270            };
2271            let reference = reference_dilation_stroke_mask(&fill, stroke_width);
2272            draw_mask_glyph(
2273                &mut canvas,
2274                width,
2275                height,
2276                &reference,
2277                &Brush::solid(Color::WHITE),
2278                1.0,
2279                rect,
2280            );
2281        }
2282
2283        let mut rgba = vec![0u8; canvas.len() * 4];
2284        for (index, pixel) in canvas.iter().enumerate() {
2285            let base = index * 4;
2286            rgba[base] = (pixel[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2287            rgba[base + 1] = (pixel[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2288            rgba[base + 2] = (pixel[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2289            rgba[base + 3] = (pixel[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2290        }
2291        ImageBitmap::from_rgba8(width, height, rgba).expect("reference dilation image")
2292    }
2293
2294    fn test_font() -> ab_glyph::FontRef<'static> {
2295        ab_glyph::FontRef::try_from_slice(include_bytes!("../assets/NotoSansMerged.ttf"))
2296            .expect("font")
2297    }
2298
2299    fn test_software_font() -> SoftwareTextFont {
2300        SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansMerged.ttf").to_vec())
2301            .expect("font")
2302    }
2303
2304    #[test]
2305    fn software_text_font_rejects_invalid_bytes() {
2306        assert!(SoftwareTextFont::from_bytes(vec![0, 1, 2, 3]).is_err());
2307    }
2308
2309    #[test]
2310    fn default_software_text_font_has_no_process_global_cache() {
2311        let source = include_str!("software_text_raster.rs");
2312        let once_lock = ["Once", "Lock"].concat();
2313        let cached_default = ["static ", "FONT"].concat();
2314        let default_font_fn = ["fn ", "default_font()"].concat();
2315
2316        assert!(
2317            !source.contains(&cached_default)
2318                && !source.contains(&default_font_fn)
2319                && !source.contains(&once_lock),
2320            "default software text font construction must be explicit renderer/app-owned state, not a process-global cache"
2321        );
2322    }
2323
2324    #[test]
2325    fn software_text_measurer_empty_font_set_uses_deterministic_fallback_without_panicking() {
2326        let measurer = SoftwareTextMeasurer::from_font_set(SoftwareTextFontSet::empty(), 4);
2327        let style = TextStyle {
2328            span_style: SpanStyle {
2329                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2330                ..Default::default()
2331            },
2332            ..Default::default()
2333        };
2334        let text = AnnotatedString::from("ab\nc");
2335
2336        let metrics = measurer.measure(&text, &style);
2337        assert_eq!(metrics.line_count, 2);
2338        assert!(metrics.width > 0.0);
2339        assert!(metrics.height >= metrics.line_height * 2.0);
2340
2341        let cursor_x = measurer.get_cursor_x_for_offset(&text, &style, 2);
2342        assert!(cursor_x > 0.0);
2343        let second_line_offset =
2344            measurer.get_offset_for_position(&text, &style, 0.0, metrics.line_height);
2345        assert!(
2346            second_line_offset >= "ab\n".len(),
2347            "fallback hit testing should resolve into the second line: {second_line_offset}"
2348        );
2349
2350        let layout = measurer.layout(&text, &style);
2351        assert_eq!(layout.lines.len(), 2);
2352        assert_eq!(layout.glyph_layouts().len(), 3);
2353    }
2354
2355    #[test]
2356    fn software_text_metrics_layout_and_cursor_share_font_backend() {
2357        let font = test_software_font();
2358        let style = TextStyle {
2359            span_style: SpanStyle {
2360                font_size: cranpose_ui::text::TextUnit::Sp(18.0),
2361                ..Default::default()
2362            },
2363            ..Default::default()
2364        };
2365        let text = "Text\nBackend";
2366
2367        let metrics = measure_text_with_font(text, &style, 18.0, &font);
2368        let layout = layout_text_with_font(text, &style, &font);
2369
2370        assert!(metrics.width > 0.0);
2371        assert_eq!(metrics.line_count, 2);
2372        assert_eq!(layout.lines.len(), 2);
2373        assert_eq!(layout.height, metrics.height);
2374        assert!(layout.glyph_layouts().len() >= "TextBackend".len());
2375
2376        let offset =
2377            text_offset_for_position_with_font(text, &style, 0.0, metrics.line_height, &font);
2378        assert!(
2379            offset >= "Text\n".len(),
2380            "second-line hit testing should return a byte offset on the second line: {offset}"
2381        );
2382        let cursor_x = cursor_x_for_offset_with_font(text, &style, "Text".len(), &font);
2383        assert!(cursor_x > 0.0);
2384    }
2385
2386    #[test]
2387    fn software_text_metrics_keep_requested_font_size_for_default_font() {
2388        let font = default_software_text_font().expect("bundled default test font");
2389        let style = TextStyle {
2390            span_style: SpanStyle {
2391                font_size: cranpose_ui::text::TextUnit::Sp(14.0),
2392                ..Default::default()
2393            },
2394            ..Default::default()
2395        };
2396
2397        let metrics = measure_text_with_font("Counter App", &style, 14.0, &font);
2398        assert!(
2399            (metrics.width - 83.16).abs() < 0.05 && (metrics.height - 19.6).abs() < 0.05,
2400            "14sp demo text must use font em metrics, not ab_glyph height units: {metrics:?}"
2401        );
2402    }
2403
2404    #[test]
2405    fn software_text_synthesizes_missing_bold_weight() {
2406        let font = test_software_font();
2407        let normal_style = TextStyle {
2408            span_style: SpanStyle {
2409                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2410                ..Default::default()
2411            },
2412            ..Default::default()
2413        };
2414        let bold_style = TextStyle {
2415            span_style: SpanStyle {
2416                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2417                font_weight: Some(FontWeight::BOLD),
2418                ..Default::default()
2419            },
2420            ..Default::default()
2421        };
2422        let no_synthesis_style = TextStyle {
2423            span_style: SpanStyle {
2424                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2425                font_weight: Some(FontWeight::BOLD),
2426                font_synthesis: Some(FontSynthesis::None),
2427                ..Default::default()
2428            },
2429            ..Default::default()
2430        };
2431
2432        let normal = measure_text_with_font("Save Raster WebP", &normal_style, 20.0, &font);
2433        let synthesized = measure_text_with_font("Save Raster WebP", &bold_style, 20.0, &font);
2434        let disabled = measure_text_with_font("Save Raster WebP", &no_synthesis_style, 20.0, &font);
2435
2436        assert!(
2437            synthesized.width > normal.width * 1.04,
2438            "bold fallback should synthesize heavier advances: normal={normal:?} synthesized={synthesized:?}"
2439        );
2440        assert!(
2441            (disabled.width - normal.width).abs() < 0.01,
2442            "explicit FontSynthesis::None should preserve regular metrics: normal={normal:?} disabled={disabled:?}"
2443        );
2444    }
2445
2446    #[test]
2447    fn rasterized_synthetic_bold_adds_ink_without_changing_line_box() {
2448        let font = test_software_font();
2449        let normal_style = TextStyle {
2450            span_style: SpanStyle {
2451                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2452                ..Default::default()
2453            },
2454            ..Default::default()
2455        };
2456        let bold_style = TextStyle {
2457            span_style: SpanStyle {
2458                font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2459                font_weight: Some(FontWeight::BOLD),
2460                ..Default::default()
2461            },
2462            ..Default::default()
2463        };
2464        let normal_metrics = measure_text_with_font("Composer", &normal_style, 20.0, &font);
2465        let bold_metrics = measure_text_with_font("Composer", &bold_style, 20.0, &font);
2466
2467        let normal = rasterize_text_to_image(
2468            "Composer",
2469            Rect {
2470                x: 0.0,
2471                y: 0.0,
2472                width: normal_metrics.width.ceil(),
2473                height: normal_metrics.height.ceil(),
2474            },
2475            &normal_style,
2476            Color::WHITE,
2477            20.0,
2478            1.0,
2479            &font,
2480        )
2481        .expect("normal text image");
2482        let bold = rasterize_text_to_image(
2483            "Composer",
2484            Rect {
2485                x: 0.0,
2486                y: 0.0,
2487                width: bold_metrics.width.ceil(),
2488                height: bold_metrics.height.ceil(),
2489            },
2490            &bold_style,
2491            Color::WHITE,
2492            20.0,
2493            1.0,
2494            &font,
2495        )
2496        .expect("bold text image");
2497
2498        assert_eq!(bold.height(), normal.height());
2499        assert!(
2500            count_ink_pixels(&bold) > count_ink_pixels(&normal),
2501            "synthetic bold should increase rasterized ink coverage"
2502        );
2503    }
2504
2505    #[test]
2506    fn software_text_synthesizes_missing_italic_style() {
2507        let font = test_software_font();
2508        let normal_style = TextStyle {
2509            span_style: SpanStyle {
2510                font_size: cranpose_ui::text::TextUnit::Sp(36.0),
2511                ..Default::default()
2512            },
2513            ..Default::default()
2514        };
2515        let italic_style = TextStyle {
2516            span_style: SpanStyle {
2517                font_size: cranpose_ui::text::TextUnit::Sp(36.0),
2518                font_style: Some(FontStyle::Italic),
2519                ..Default::default()
2520            },
2521            ..Default::default()
2522        };
2523        let no_synthesis_style = TextStyle {
2524            span_style: SpanStyle {
2525                font_size: cranpose_ui::text::TextUnit::Sp(36.0),
2526                font_style: Some(FontStyle::Italic),
2527                font_synthesis: Some(FontSynthesis::None),
2528                ..Default::default()
2529            },
2530            ..Default::default()
2531        };
2532
2533        let normal_metrics = measure_text_with_font("Italic", &normal_style, 36.0, &font);
2534        let italic_metrics = measure_text_with_font("Italic", &italic_style, 36.0, &font);
2535        let disabled_metrics = measure_text_with_font("Italic", &no_synthesis_style, 36.0, &font);
2536
2537        assert!(
2538            italic_metrics.width > normal_metrics.width + 6.0,
2539            "italic fallback should reserve slanted visual overhang: normal={normal_metrics:?} italic={italic_metrics:?}"
2540        );
2541        assert!(
2542            (disabled_metrics.width - normal_metrics.width).abs() < 0.01,
2543            "explicit FontSynthesis::None should preserve regular metrics: normal={normal_metrics:?} disabled={disabled_metrics:?}"
2544        );
2545
2546        let normal = rasterize_text_to_image(
2547            "Italic",
2548            Rect {
2549                x: 0.0,
2550                y: 0.0,
2551                width: normal_metrics.width.ceil(),
2552                height: normal_metrics.height.ceil(),
2553            },
2554            &normal_style,
2555            Color::WHITE,
2556            36.0,
2557            1.0,
2558            &font,
2559        )
2560        .expect("normal text image");
2561        let italic = rasterize_text_to_image(
2562            "Italic",
2563            Rect {
2564                x: 0.0,
2565                y: 0.0,
2566                width: italic_metrics.width.ceil(),
2567                height: italic_metrics.height.ceil(),
2568            },
2569            &italic_style,
2570            Color::WHITE,
2571            36.0,
2572            1.0,
2573            &font,
2574        )
2575        .expect("italic text image");
2576        let disabled = rasterize_text_to_image(
2577            "Italic",
2578            Rect {
2579                x: 0.0,
2580                y: 0.0,
2581                width: disabled_metrics.width.ceil(),
2582                height: disabled_metrics.height.ceil(),
2583            },
2584            &no_synthesis_style,
2585            Color::WHITE,
2586            36.0,
2587            1.0,
2588            &font,
2589        )
2590        .expect("disabled italic text image");
2591
2592        assert_eq!(
2593            normal.pixels(),
2594            disabled.pixels(),
2595            "FontSynthesis::None must not synthesize oblique glyphs"
2596        );
2597        assert!(
2598            vertical_slant_delta(&italic) > vertical_slant_delta(&normal) + 2.0,
2599            "synthetic italic should visibly lean top ink to the right"
2600        );
2601    }
2602
2603    #[test]
2604    fn rasterized_default_text_fills_expected_visual_height() {
2605        let font = default_software_text_font().expect("bundled default test font");
2606        let style = TextStyle {
2607            span_style: SpanStyle {
2608                font_size: cranpose_ui::text::TextUnit::Sp(14.0),
2609                ..Default::default()
2610            },
2611            ..Default::default()
2612        };
2613        let metrics = measure_text_with_font("Counter App", &style, 14.0, &font);
2614        let image = rasterize_text_to_image(
2615            "Counter App",
2616            Rect {
2617                x: 0.0,
2618                y: 0.0,
2619                width: metrics.width.ceil(),
2620                height: metrics.height.ceil(),
2621            },
2622            &style,
2623            Color::WHITE,
2624            14.0,
2625            1.0,
2626            &font,
2627        )
2628        .expect("text image");
2629        let (top, bottom) = ink_y_range(&image).expect("text should contain ink");
2630        let ink_height = bottom - top;
2631
2632        assert!(
2633            ink_height >= 13,
2634            "14sp default text ink should keep visual height parity with the WGPU baseline: top={top} bottom={bottom} image={}x{}",
2635            image.width(),
2636            image.height()
2637        );
2638    }
2639
2640    #[test]
2641    fn software_text_font_selection_preserves_first_complete_default_face() {
2642        let regular =
2643            SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansMerged.ttf").to_vec())
2644                .expect("regular test font should load");
2645        let font = software_text_font_from_fonts_or_default(&[
2646            include_bytes!("../assets/NotoSansMerged.ttf"),
2647            include_bytes!("../assets/NotoSansBold.ttf"),
2648            include_bytes!("../assets/TwemojiMozilla.ttf"),
2649        ])
2650        .expect("font selection should resolve a test font");
2651        let style = TextStyle {
2652            span_style: SpanStyle {
2653                font_size: cranpose_ui::text::TextUnit::Sp(18.0),
2654                ..Default::default()
2655            },
2656            ..Default::default()
2657        };
2658
2659        let regular_metrics = measure_text_with_font("UNDER", &style, 18.0, &regular);
2660        let metrics = measure_text_with_font("UNDER", &style, 18.0, &font);
2661        assert!(
2662            (metrics.width - regular_metrics.width).abs() < 0.01,
2663            "font selection should keep the declared regular face for default text: selected={metrics:?}, regular={regular_metrics:?}"
2664        );
2665    }
2666
2667    #[test]
2668    fn software_text_font_resolution_reuses_cached_font_score() {
2669        let font = test_software_font();
2670        assert!(
2671            font.score.is_complete_default_face(),
2672            "test font should cache complete Latin coverage at load time: supported={} width={}",
2673            font.score.supported_latin_chars,
2674            font.score.latin_sample_width
2675        );
2676
2677        let fonts = SoftwareTextFontSet::from_font(font.clone());
2678        let resolved = fonts
2679            .resolve(&TextStyle {
2680                span_style: SpanStyle {
2681                    font_weight: Some(FontWeight::BOLD),
2682                    ..Default::default()
2683                },
2684                ..Default::default()
2685            })
2686            .expect("font set should resolve a test font");
2687
2688        assert_eq!(
2689            resolved.score.supported_latin_chars,
2690            font.score.supported_latin_chars
2691        );
2692        assert_eq!(
2693            resolved.score.latin_sample_width,
2694            font.score.latin_sample_width
2695        );
2696    }
2697
2698    #[test]
2699    fn software_text_font_set_resolves_requested_weight() {
2700        let fonts = software_text_font_set_from_fonts_or_default(&[
2701            include_bytes!("../assets/NotoSansMerged.ttf"),
2702            include_bytes!("../assets/NotoSansBold.ttf"),
2703            include_bytes!("../assets/TwemojiMozilla.ttf"),
2704        ]);
2705        let regular = fonts
2706            .resolve(&TextStyle::default())
2707            .expect("font set should resolve regular test font");
2708        let bold_style = TextStyle {
2709            span_style: SpanStyle {
2710                font_weight: Some(FontWeight::BOLD),
2711                ..Default::default()
2712            },
2713            ..Default::default()
2714        };
2715        let bold = fonts
2716            .resolve(&bold_style)
2717            .expect("font set should resolve bold test font");
2718
2719        assert_eq!(regular.weight(), FontWeight::NORMAL);
2720        assert_eq!(bold.weight(), FontWeight::BOLD);
2721
2722        let regular_metrics =
2723            measure_text_with_font("Counter App", &TextStyle::default(), 18.0, regular);
2724        let bold_metrics = measure_text_with_font("Counter App", &bold_style, 18.0, bold);
2725        assert!(
2726            bold_metrics.width > regular_metrics.width,
2727            "bold face resolution should affect real text metrics: regular={regular_metrics:?} bold={bold_metrics:?}"
2728        );
2729    }
2730
2731    #[test]
2732    fn software_text_metrics_use_largest_annotated_span_font_size() {
2733        let font = default_software_text_font().expect("bundled default test font");
2734        let text = AnnotatedString::builder()
2735            .push_style(SpanStyle {
2736                font_size: cranpose_ui::text::TextUnit::Sp(30.0),
2737                ..Default::default()
2738            })
2739            .append("BIG ")
2740            .pop()
2741            .push_style(SpanStyle {
2742                font_size: cranpose_ui::text::TextUnit::Sp(10.0),
2743                ..Default::default()
2744            })
2745            .append("small")
2746            .pop()
2747            .to_annotated_string();
2748
2749        let metrics = measure_annotated_text_with_font(&text, &TextStyle::default(), 14.0, &font);
2750
2751        assert!(
2752            metrics.height >= 30.0,
2753            "rich text metrics must include the largest span height: {metrics:?}"
2754        );
2755        assert!(
2756            metrics.width > 48.0,
2757            "rich text metrics should measure run widths at their span sizes: {metrics:?}"
2758        );
2759    }
2760
2761    #[test]
2762    fn software_text_metrics_cache_keys_include_span_styles() {
2763        let measurer = SoftwareTextMeasurer::new(
2764            default_software_text_font().expect("bundled default test font"),
2765            8,
2766        );
2767        let plain = AnnotatedString::from("BIG small");
2768        let rich = AnnotatedString::builder()
2769            .push_style(SpanStyle {
2770                font_size: cranpose_ui::text::TextUnit::Sp(30.0),
2771                ..Default::default()
2772            })
2773            .append("BIG ")
2774            .pop()
2775            .append("small")
2776            .to_annotated_string();
2777
2778        let plain_metrics = measurer.measure(&plain, &TextStyle::default());
2779        let rich_metrics = measurer.measure(&rich, &TextStyle::default());
2780
2781        assert!(
2782            rich_metrics.height > plain_metrics.height,
2783            "cached plain text metrics must not be reused for styled text: plain={plain_metrics:?} rich={rich_metrics:?}"
2784        );
2785    }
2786
2787    #[test]
2788    fn software_text_metrics_cache_recovers_after_poison() {
2789        let measurer = SoftwareTextMeasurer::new(
2790            default_software_text_font().expect("bundled default test font"),
2791            8,
2792        );
2793        let text = AnnotatedString::from("Recovered text metrics");
2794
2795        let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2796            let _guard = measurer
2797                .cache
2798                .lock()
2799                .unwrap_or_else(|poisoned| poisoned.into_inner());
2800            panic!("poison software text metrics cache for recovery test");
2801        }));
2802
2803        assert!(poison_result.is_err());
2804
2805        let metrics = measurer.measure(&text, &TextStyle::default());
2806        assert!(metrics.width > 0.0);
2807        assert!(metrics.height > 0.0);
2808
2809        let subset =
2810            measurer.measure_subsequence(&text, 0.."Recovered".len(), &TextStyle::default());
2811        assert!(subset.width > 0.0);
2812        assert!(subset.width < metrics.width);
2813    }
2814
2815    #[test]
2816    fn rasterized_gradient_text_shows_color_transition() {
2817        let font = test_font();
2818        // Use a gradient sized to the rendered text width so left=red, right=blue.
2819        // We first do a plain measurement pass to know the text width.
2820        let plain_style = TextStyle::default();
2821        let probe = rasterize_text_to_image_with_font(
2822            "MMMMMMMM",
2823            Rect {
2824                x: 0.0,
2825                y: 0.0,
2826                width: 320.0,
2827                height: 96.0,
2828            },
2829            &plain_style,
2830            Color::WHITE,
2831            48.0,
2832            1.0,
2833            &font,
2834        )
2835        .expect("probe image");
2836        let (ink_x_min, ink_x_max) = ink_x_range(&probe).expect("probe must contain ink");
2837        let gradient_end = ink_x_max as f32;
2838
2839        let style = TextStyle {
2840            span_style: SpanStyle {
2841                brush: Some(Brush::linear_gradient_range(
2842                    vec![Color::RED, Color::BLUE],
2843                    Point::new(0.0, 0.0),
2844                    Point::new(gradient_end, 0.0),
2845                )),
2846                ..Default::default()
2847            },
2848            ..Default::default()
2849        };
2850
2851        let image = rasterize_text_to_image_with_font(
2852            "MMMMMMMM",
2853            Rect {
2854                x: 0.0,
2855                y: 0.0,
2856                width: 320.0,
2857                height: 96.0,
2858            },
2859            &style,
2860            Color::WHITE,
2861            48.0,
2862            1.0,
2863            &font,
2864        )
2865        .expect("rasterized image");
2866
2867        let ink_span = ink_x_max.saturating_sub(ink_x_min).max(1);
2868        let left_end = ink_x_min + ink_span * 3 / 10;
2869        let right_start = ink_x_max.saturating_sub(ink_span * 3 / 10);
2870        let left = average_ink_rgb(&image, ink_x_min, left_end, 8, 90).expect("left ink");
2871        let right = average_ink_rgb(&image, right_start, ink_x_max, 8, 90).expect("right ink");
2872        assert!(
2873            left[0] > left[2] * 1.1,
2874            "left region should be red dominant, got {left:?}"
2875        );
2876        assert!(
2877            right[2] > right[0] * 1.1,
2878            "right region should be blue dominant, got {right:?}"
2879        );
2880    }
2881
2882    #[test]
2883    fn rasterized_stroke_and_fill_ink_coverage_differs() {
2884        let font = test_font();
2885        let fill_style = TextStyle::default();
2886        let stroke_style = TextStyle {
2887            span_style: SpanStyle {
2888                draw_style: Some(TextDrawStyle::Stroke { width: 6.0 }),
2889                ..Default::default()
2890            },
2891            ..Default::default()
2892        };
2893        let rect = Rect {
2894            x: 0.0,
2895            y: 0.0,
2896            width: 320.0,
2897            height: 96.0,
2898        };
2899
2900        let fill = rasterize_text_to_image_with_font(
2901            "MMMMMMMM",
2902            rect,
2903            &fill_style,
2904            Color::WHITE,
2905            48.0,
2906            1.0,
2907            &font,
2908        )
2909        .expect("fill image");
2910        let stroke = rasterize_text_to_image_with_font(
2911            "MMMMMMMM",
2912            rect,
2913            &stroke_style,
2914            Color::WHITE,
2915            48.0,
2916            1.0,
2917            &font,
2918        )
2919        .expect("stroke image");
2920
2921        let fill_ink = count_ink_pixels(&fill);
2922        let stroke_ink = count_ink_pixels(&stroke);
2923        assert_ne!(fill.pixels(), stroke.pixels());
2924        assert!(
2925            fill_ink.abs_diff(stroke_ink) > 300,
2926            "fill/stroke ink coverage should differ; fill={fill_ink}, stroke={stroke_ink}"
2927        );
2928    }
2929
2930    #[test]
2931    fn stroke_path_uses_miter_join_for_acute_apexes() {
2932        let font = test_font();
2933        let fill_style = TextStyle::default();
2934        let stroke_width = 12.0;
2935        let stroke_style = TextStyle {
2936            span_style: SpanStyle {
2937                draw_style: Some(TextDrawStyle::Stroke {
2938                    width: stroke_width,
2939                }),
2940                ..Default::default()
2941            },
2942            ..Default::default()
2943        };
2944        let rect = Rect {
2945            x: 0.0,
2946            y: 0.0,
2947            width: 180.0,
2948            height: 140.0,
2949        };
2950
2951        let fill = rasterize_text_to_image_with_font(
2952            "A",
2953            rect,
2954            &fill_style,
2955            Color::WHITE,
2956            110.0,
2957            1.0,
2958            &font,
2959        )
2960        .expect("fill image");
2961        let stroke = rasterize_text_to_image_with_font(
2962            "A",
2963            rect,
2964            &stroke_style,
2965            Color::WHITE,
2966            110.0,
2967            1.0,
2968            &font,
2969        )
2970        .expect("stroke image");
2971
2972        let fill_top = top_ink_row(&fill).expect("fill top row");
2973        let stroke_top = top_ink_row(&stroke).expect("stroke top row");
2974        let reference_dilation =
2975            rasterize_reference_dilation_stroke("A", rect, 110.0, stroke_width, &font);
2976        let reference_top = top_ink_row(&reference_dilation).expect("reference top row");
2977        let extra_extension = fill_top.saturating_sub(stroke_top) as f32;
2978        let half_stroke = stroke_width * 0.5;
2979        assert!(
2980            extra_extension >= half_stroke - 0.25,
2981            "stroke apex should extend by roughly at least half stroke width; fill_top={fill_top}, stroke_top={stroke_top}, half_stroke={half_stroke:.2}"
2982        );
2983        assert!(
2984            stroke.pixels() != reference_dilation.pixels(),
2985            "path stroke should diverge from mask-dilation reference output"
2986        );
2987        assert!(
2988            stroke_top <= reference_top,
2989            "miter stroke should keep acute apex at least as extended as mask-dilation reference; stroke_top={stroke_top}, reference_top={reference_top}"
2990        );
2991    }
2992
2993    #[test]
2994    fn shadow_blur_radius_changes_spread_for_shared_raster_path() {
2995        let font = test_font();
2996        let base_shadow = Shadow {
2997            color: Color(0.0, 0.0, 0.0, 0.9),
2998            offset: Point::new(5.5, 4.25),
2999            blur_radius: 0.0,
3000        };
3001        let hard_shadow_style = TextStyle {
3002            span_style: SpanStyle {
3003                shadow: Some(base_shadow),
3004                ..Default::default()
3005            },
3006            ..Default::default()
3007        };
3008        let blurred_shadow_style = TextStyle {
3009            span_style: SpanStyle {
3010                shadow: Some(Shadow {
3011                    blur_radius: 9.0,
3012                    ..base_shadow
3013                }),
3014                ..Default::default()
3015            },
3016            ..Default::default()
3017        };
3018        let rect = Rect {
3019            x: 0.0,
3020            y: 0.0,
3021            width: 320.0,
3022            height: 120.0,
3023        };
3024
3025        let hard_shadow = rasterize_text_to_image_with_font(
3026            "Shared shadow",
3027            rect,
3028            &hard_shadow_style,
3029            Color::TRANSPARENT,
3030            48.0,
3031            1.0,
3032            &font,
3033        )
3034        .expect("hard shadow image");
3035        let blurred_shadow = rasterize_text_to_image_with_font(
3036            "Shared shadow",
3037            rect,
3038            &blurred_shadow_style,
3039            Color::TRANSPARENT,
3040            48.0,
3041            1.0,
3042            &font,
3043        )
3044        .expect("blurred shadow image");
3045
3046        let hard_ink = count_ink_pixels(&hard_shadow);
3047        let blurred_ink = count_ink_pixels(&blurred_shadow);
3048        assert_ne!(
3049            hard_shadow.pixels(),
3050            blurred_shadow.pixels(),
3051            "blur radius should change rasterized shadow output"
3052        );
3053        assert!(
3054            blurred_ink > hard_ink,
3055            "blurred shadow should spread to more pixels; hard={hard_ink}, blurred={blurred_ink}"
3056        );
3057    }
3058
3059    #[test]
3060    fn text_motion_changes_fractional_shadow_sampling() {
3061        let font = test_font();
3062        let base_shadow = Shadow {
3063            color: Color(0.0, 0.0, 0.0, 0.9),
3064            offset: Point::new(3.35, 2.65),
3065            blur_radius: 6.0,
3066        };
3067        let static_style = TextStyle {
3068            span_style: SpanStyle {
3069                shadow: Some(base_shadow),
3070                ..Default::default()
3071            },
3072            paragraph_style: cranpose_ui::text::ParagraphStyle {
3073                text_motion: Some(TextMotion::Static),
3074                ..Default::default()
3075            },
3076        };
3077        let animated_style = TextStyle {
3078            span_style: SpanStyle {
3079                shadow: Some(base_shadow),
3080                ..Default::default()
3081            },
3082            paragraph_style: cranpose_ui::text::ParagraphStyle {
3083                text_motion: Some(TextMotion::Animated),
3084                ..Default::default()
3085            },
3086        };
3087        let rect = Rect {
3088            x: 11.35,
3089            y: 7.65,
3090            width: 280.0,
3091            height: 120.0,
3092        };
3093
3094        let static_image = rasterize_text_to_image_with_font(
3095            "Motion shadow",
3096            rect,
3097            &static_style,
3098            Color::TRANSPARENT,
3099            42.0,
3100            1.0,
3101            &font,
3102        )
3103        .expect("static image");
3104        let animated_image = rasterize_text_to_image_with_font(
3105            "Motion shadow",
3106            rect,
3107            &animated_style,
3108            Color::TRANSPARENT,
3109            42.0,
3110            1.0,
3111            &font,
3112        )
3113        .expect("animated image");
3114
3115        assert_ne!(
3116            static_image.pixels(),
3117            animated_image.pixels(),
3118            "TextMotion::Static should quantize shadow placement while Animated keeps fractional sampling"
3119        );
3120    }
3121
3122    #[test]
3123    fn static_text_motion_aligns_glyph_positions_to_pixel_grid() {
3124        let font = test_font();
3125        let base_glyph = layout_line_glyphs(&font, "A", 17.0, point(0.0, 13.37))
3126            .into_iter()
3127            .next()
3128            .expect("glyph");
3129        let static_aligned = align_glyph_for_text_motion(base_glyph, true);
3130        let static_position = static_aligned.position;
3131        assert!(
3132            (static_position.x - static_position.x.round()).abs() < f32::EPSILON,
3133            "static text should snap glyph x to pixel grid"
3134        );
3135        assert!(
3136            (static_position.y - static_position.y.round()).abs() < f32::EPSILON,
3137            "static text should snap glyph y to pixel grid"
3138        );
3139
3140        let animated_source = layout_line_glyphs(&font, "A", 17.0, point(0.0, 13.37))
3141            .into_iter()
3142            .next()
3143            .expect("glyph");
3144        let animated_aligned = align_glyph_for_text_motion(animated_source, false);
3145        let animated_position = animated_aligned.position;
3146        assert!(
3147            (animated_position.y - 13.37).abs() < 1e-3,
3148            "animated text should preserve fractional glyph position"
3149        );
3150    }
3151}