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