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