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