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