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