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