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