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