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