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