1use ab_glyph::{point, Font, FontArc, Glyph, OutlinedGlyph, PxScale, ScaleFont};
2use cranpose_core::hash::default as default_hash;
3use cranpose_ui::text::{
4 AnnotatedString, FontFamily, FontStyle, FontSynthesis, FontWeight, Shadow, TextDrawStyle,
5 TextMotion, TextStyle,
6};
7use cranpose_ui::text_layout_result::{GlyphLayout, LineLayout, TextLayoutData, TextLayoutResult};
8use cranpose_ui::{TextMeasurer, TextMetrics};
9use cranpose_ui_graphics::{Color, ImageBitmap, Rect};
10use std::hash::{Hash, Hasher};
11use std::rc::Rc;
12use std::sync::{Arc, Mutex, MutexGuard};
13use tiny_skia::{LineCap, LineJoin, Paint, Path, PathBuilder, Pixmap, Stroke, Transform};
14
15use crate::bounded_lru_cache::BoundedLruCache;
16use crate::brush_sampling::{color_to_rgba, sample_brush_rgba};
17use crate::font_layout::{
18 align_glyph_to_pixel_grid, layout_line_glyphs, line_advance_width, pixel_bounds_from_outlined,
19 vertical_metrics, GlyphPixelBounds,
20};
21#[cfg(feature = "text-hyphenation")]
22use crate::text_hyphenation::HyphenationDictionaryError;
23use crate::text_hyphenation::HyphenationDictionaryStore;
24use crate::Brush;
25
26const COMPOSE_STROKE_MITER_LIMIT: f32 = 4.0;
27const SHADOW_SIGMA_SCALE: f32 = 0.57735;
28const SHADOW_SIGMA_BIAS: f32 = 0.5;
29const MAX_GAUSSIAN_KERNEL_HALF: i32 = 128;
30#[doc(hidden)]
31pub const DEFAULT_SOFTWARE_TEXT_FONT_BYTES: &[u8] = include_bytes!("../assets/NotoSansMerged.ttf");
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
34pub enum SoftwareTextFontError {
35 #[error("invalid software text font bytes")]
36 InvalidFont,
37}
38
39#[derive(Clone)]
40pub struct SoftwareTextFont {
41 font: FontArc,
42 metadata: SoftwareTextFontMetadata,
43 score: TextFontScore,
44}
45
46#[derive(Clone)]
47struct SoftwareTextFontMetadata {
48 families: Arc<[String]>,
49 weight: FontWeight,
50 style: FontStyle,
51 ab_glyph_scale_factor: f32,
52}
53
54impl SoftwareTextFont {
55 pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Result<Self, SoftwareTextFontError> {
56 let bytes = bytes.into();
57 let metadata = software_text_font_metadata(bytes.as_slice());
58 let font = FontArc::try_from_vec(bytes).map_err(|_| SoftwareTextFontError::InvalidFont)?;
59 let score =
60 text_font_score_from_parts(&font, metadata.ab_glyph_scale_factor, metadata.weight);
61 Ok(Self {
62 font,
63 metadata,
64 score,
65 })
66 }
67
68 pub fn family_names(&self) -> &[String] {
69 &self.metadata.families
70 }
71
72 pub fn weight(&self) -> FontWeight {
73 self.metadata.weight
74 }
75
76 pub fn style(&self) -> FontStyle {
77 self.metadata.style
78 }
79
80 fn ab_glyph_px_size(&self, logical_font_size: f32) -> f32 {
81 logical_font_size * self.metadata.ab_glyph_scale_factor
82 }
83}
84
85pub fn try_default_software_text_font() -> Result<SoftwareTextFont, SoftwareTextFontError> {
86 SoftwareTextFont::from_bytes(DEFAULT_SOFTWARE_TEXT_FONT_BYTES.to_vec())
87}
88
89pub fn default_software_text_font() -> Option<SoftwareTextFont> {
90 try_default_software_text_font().ok()
91}
92
93#[derive(Clone)]
94pub struct SoftwareTextFontSet {
95 fonts: Arc<[SoftwareTextFont]>,
96 default_index: Option<usize>,
97}
98
99impl SoftwareTextFontSet {
100 pub fn empty() -> Self {
101 Self {
102 fonts: Arc::from(Vec::new()),
103 default_index: None,
104 }
105 }
106
107 pub fn from_font(font: SoftwareTextFont) -> Self {
108 Self {
109 fonts: Arc::from(vec![font]),
110 default_index: Some(0),
111 }
112 }
113
114 pub fn from_fonts_or_default(fonts: &[&[u8]]) -> Self {
115 let mut parsed = Vec::with_capacity(fonts.len().max(1));
116 for font in fonts {
117 if let Ok(candidate) = SoftwareTextFont::from_bytes((*font).to_vec()) {
118 parsed.push(candidate);
119 }
120 }
121 if parsed.is_empty() {
122 if let Some(default_font) = default_software_text_font() {
123 parsed.push(default_font);
124 }
125 }
126
127 let default_index = (!parsed.is_empty()).then(|| default_font_index(&parsed));
128 Self {
129 fonts: Arc::from(parsed),
130 default_index,
131 }
132 }
133
134 pub fn default_font(&self) -> Option<&SoftwareTextFont> {
135 self.default_index.and_then(|index| self.fonts.get(index))
136 }
137
138 pub fn resolve(&self, style: &TextStyle) -> Option<&SoftwareTextFont> {
139 let target_weight = style.span_style.font_weight.unwrap_or_default();
140 let target_style = style.span_style.font_style.unwrap_or_default();
141 let family_name = requested_family_name(style.span_style.font_family.as_ref());
142
143 let mut best: Option<(usize, u32)> = None;
144 for (index, font) in self.fonts.iter().enumerate() {
145 let Some(score) = font_match_score(font, target_weight, target_style, family_name)
146 else {
147 continue;
148 };
149 if best.is_none_or(|(_, best_score)| score < best_score) {
150 best = Some((index, score));
151 }
152 }
153
154 let index = best.map(|(index, _)| index).or(self.default_index);
155 index.and_then(|index| self.fonts.get(index))
156 }
157}
158
159pub fn software_text_font_from_fonts_or_default(fonts: &[&[u8]]) -> Option<SoftwareTextFont> {
160 SoftwareTextFontSet::from_fonts_or_default(fonts)
161 .default_font()
162 .cloned()
163}
164
165pub fn software_text_font_set_from_fonts_or_default(fonts: &[&[u8]]) -> SoftwareTextFontSet {
166 SoftwareTextFontSet::from_fonts_or_default(fonts)
167}
168
169#[derive(Clone, Copy)]
170struct TextFontScore {
171 supported_latin_chars: usize,
172 latin_sample_width: f32,
173}
174
175impl TextFontScore {
176 fn is_complete_default_face(self) -> bool {
177 const LATIN_SAMPLE_CHAR_COUNT: usize = 21;
178 self.supported_latin_chars == LATIN_SAMPLE_CHAR_COUNT && self.latin_sample_width > 1.0
179 }
180
181 fn is_better_than(self, other: Self) -> bool {
182 self.supported_latin_chars > other.supported_latin_chars
183 || (self.supported_latin_chars == other.supported_latin_chars
184 && self.latin_sample_width > other.latin_sample_width)
185 }
186}
187
188fn text_font_score(font: &SoftwareTextFont) -> TextFontScore {
189 font.score
190}
191
192fn text_font_score_from_parts(
193 font: &FontArc,
194 ab_glyph_scale_factor: f32,
195 weight: FontWeight,
196) -> TextFontScore {
197 const SAMPLE: &str = "UNDER The quick brown fox";
198 let glyph_font_size = 18.0 * ab_glyph_scale_factor;
199 let scaled_font = font.as_scaled(PxScale::from(glyph_font_size));
200 let supported_latin_chars = SAMPLE
201 .chars()
202 .filter(|ch| !ch.is_whitespace())
203 .filter(|ch| scaled_font.glyph_id(*ch).0 != 0)
204 .count();
205 let latin_sample_width = measure_text_impl(
206 SAMPLE,
207 &TextStyle::default(),
208 18.0,
209 glyph_font_size,
210 font,
211 FontStyle::Normal,
212 weight,
213 )
214 .width;
215 TextFontScore {
216 supported_latin_chars,
217 latin_sample_width,
218 }
219}
220
221fn default_font_index(fonts: &[SoftwareTextFont]) -> usize {
222 let mut best: Option<(usize, TextFontScore)> = None;
223 for (index, font) in fonts.iter().enumerate() {
224 let score = text_font_score(font);
225 if font.style() == FontStyle::Normal
226 && font.weight() == FontWeight::NORMAL
227 && score.is_complete_default_face()
228 {
229 return index;
230 }
231 if best
232 .as_ref()
233 .is_none_or(|(_, best_score)| score.is_better_than(*best_score))
234 {
235 best = Some((index, score));
236 }
237 }
238 best.map(|(index, _)| index).unwrap_or(0)
239}
240
241fn requested_family_name(font_family: Option<&FontFamily>) -> Option<&str> {
242 match font_family {
243 Some(FontFamily::Named(name)) => Some(name.as_str()),
244 _ => None,
245 }
246}
247
248fn font_match_score(
249 font: &SoftwareTextFont,
250 target_weight: FontWeight,
251 target_style: FontStyle,
252 family_name: Option<&str>,
253) -> Option<u32> {
254 let family_penalty = match family_name {
255 Some(name) if font_family_matches(font, name) => 0,
256 Some(_) => return None,
257 None => 0,
258 };
259 let style_penalty = if font.style() == target_style {
260 0
261 } else {
262 10_000
263 };
264 let weight_penalty = (i32::from(font.weight().0) - i32::from(target_weight.0)).unsigned_abs();
265 let coverage_penalty =
266 (21usize.saturating_sub(text_font_score(font).supported_latin_chars) as u32) * 1_000;
267
268 Some(family_penalty + style_penalty + weight_penalty + coverage_penalty)
269}
270
271fn font_family_matches(font: &SoftwareTextFont, requested: &str) -> bool {
272 font.family_names()
273 .iter()
274 .any(|family| family.eq_ignore_ascii_case(requested))
275}
276
277fn software_text_font_metadata(bytes: &[u8]) -> SoftwareTextFontMetadata {
278 let Some(face) = ttf_parser::Face::parse(bytes, 0).ok() else {
279 return SoftwareTextFontMetadata {
280 families: Arc::from(Vec::<String>::new()),
281 weight: FontWeight::NORMAL,
282 style: FontStyle::Normal,
283 ab_glyph_scale_factor: 1.0,
284 };
285 };
286
287 let mut families = Vec::new();
288 for name in face.names() {
289 if matches!(
290 name.name_id,
291 ttf_parser::name_id::TYPOGRAPHIC_FAMILY | ttf_parser::name_id::FAMILY
292 ) {
293 if let Some(value) = name.to_string().filter(|value| !value.is_empty()) {
294 if !families
295 .iter()
296 .any(|existing: &String| existing.eq_ignore_ascii_case(&value))
297 {
298 families.push(value);
299 }
300 }
301 }
302 }
303 let weight = FontWeight::try_new(face.weight().to_number()).unwrap_or(FontWeight::NORMAL);
304 let style = if face.is_italic() {
305 FontStyle::Italic
306 } else {
307 FontStyle::Normal
308 };
309 let units_per_em = face.units_per_em() as f32;
310 let height = (face.ascender() as f32 - face.descender() as f32).abs();
311 let ab_glyph_scale_factor =
312 if units_per_em.is_finite() && units_per_em > 0.0 && height.is_finite() && height > 0.0 {
313 height / units_per_em
314 } else {
315 1.0
316 };
317
318 SoftwareTextFontMetadata {
319 families: Arc::from(families),
320 weight,
321 style,
322 ab_glyph_scale_factor,
323 }
324}
325
326#[derive(Clone)]
327struct TextMetricsKey {
328 text: Rc<str>,
329 font_size_bits: u32,
330 style_hash: u64,
331 span_styles_hash: u64,
332}
333
334impl PartialEq for TextMetricsKey {
335 fn eq(&self, other: &Self) -> bool {
336 (Rc::ptr_eq(&self.text, &other.text) || *self.text == *other.text)
337 && self.font_size_bits == other.font_size_bits
338 && self.style_hash == other.style_hash
339 && self.span_styles_hash == other.span_styles_hash
340 }
341}
342
343impl Eq for TextMetricsKey {}
344
345impl Hash for TextMetricsKey {
346 fn hash<H: Hasher>(&self, state: &mut H) {
347 self.text.hash(state);
348 self.font_size_bits.hash(state);
349 self.style_hash.hash(state);
350 self.span_styles_hash.hash(state);
351 }
352}
353
354struct SoftwareTextMetricsCache {
355 map: BoundedLruCache<TextMetricsKey, TextMetrics>,
356}
357
358impl SoftwareTextMetricsCache {
359 fn new(capacity: usize) -> Self {
360 Self {
361 map: BoundedLruCache::with_capacity_at_least_one(capacity),
362 }
363 }
364
365 fn get_or_measure(
366 &mut self,
367 fonts: &SoftwareTextFontSet,
368 text: &AnnotatedString,
369 style: &TextStyle,
370 ) -> TextMetrics {
371 let font_size = resolve_font_size(style);
372 let key = TextMetricsKey {
373 text: Rc::from(text.text.as_str()),
374 font_size_bits: font_size.to_bits(),
375 style_hash: style.measurement_hash(),
376 span_styles_hash: text.span_styles_hash(),
377 };
378 if let Some(metrics) = self.map.get(&key).copied() {
379 return metrics;
380 }
381
382 let metrics = measure_annotated_text_with_font_set(text, style, font_size, fonts);
383 self.map.put(key, metrics);
384 metrics
385 }
386}
387
388pub struct SoftwareTextMeasurer {
389 fonts: SoftwareTextFontSet,
390 cache: Mutex<SoftwareTextMetricsCache>,
391 hyphenation: HyphenationDictionaryStore,
392}
393
394impl SoftwareTextMeasurer {
395 pub fn new(font: SoftwareTextFont, cache_capacity: usize) -> Self {
396 Self::from_font_set(SoftwareTextFontSet::from_font(font), cache_capacity)
397 }
398
399 pub fn from_font_set(fonts: SoftwareTextFontSet, cache_capacity: usize) -> Self {
400 Self {
401 fonts,
402 cache: Mutex::new(SoftwareTextMetricsCache::new(cache_capacity)),
403 hyphenation: HyphenationDictionaryStore::new(),
404 }
405 }
406
407 pub fn from_fonts_or_default(fonts: &[&[u8]], cache_capacity: usize) -> Self {
408 Self::from_font_set(
409 software_text_font_set_from_fonts_or_default(fonts),
410 cache_capacity,
411 )
412 }
413
414 fn lock_cache(&self) -> MutexGuard<'_, SoftwareTextMetricsCache> {
415 self.cache
416 .lock()
417 .unwrap_or_else(|poisoned| poisoned.into_inner())
418 }
419
420 #[cfg(feature = "text-hyphenation")]
421 pub fn register_hyphenation_dictionary_path(
422 &self,
423 locale: &str,
424 path: impl AsRef<std::path::Path>,
425 ) -> Result<(), HyphenationDictionaryError> {
426 self.hyphenation.register_dictionary_path(locale, path)
427 }
428
429 #[cfg(feature = "text-hyphenation")]
430 pub fn register_hyphenation_dictionary_reader(
431 &self,
432 locale: &str,
433 reader: &mut impl std::io::Read,
434 ) -> Result<(), HyphenationDictionaryError> {
435 self.hyphenation.register_dictionary_reader(locale, reader)
436 }
437}
438
439impl TextMeasurer for SoftwareTextMeasurer {
440 fn measure(&self, text: &cranpose_ui::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
441 self.lock_cache().get_or_measure(&self.fonts, text, style)
442 }
443
444 fn measure_subsequence(
445 &self,
446 text: &cranpose_ui::text::AnnotatedString,
447 range: std::ops::Range<usize>,
448 style: &TextStyle,
449 ) -> TextMetrics {
450 let text = text.subsequence(range);
451 self.lock_cache().get_or_measure(&self.fonts, &text, style)
452 }
453
454 fn get_offset_for_position(
455 &self,
456 text: &cranpose_ui::text::AnnotatedString,
457 style: &TextStyle,
458 x: f32,
459 y: f32,
460 ) -> usize {
461 if let Some(font) = self.fonts.resolve(style) {
462 text_offset_for_position_with_font(text.text.as_str(), style, x, y, font)
463 } else {
464 fallback_text_offset_for_position(text.text.as_str(), style, x, y)
465 }
466 }
467
468 fn get_cursor_x_for_offset(
469 &self,
470 text: &cranpose_ui::text::AnnotatedString,
471 style: &TextStyle,
472 offset: usize,
473 ) -> f32 {
474 if let Some(font) = self.fonts.resolve(style) {
475 cursor_x_for_offset_with_font(text.text.as_str(), style, offset, font)
476 } else {
477 fallback_cursor_x_for_offset(text.text.as_str(), style, offset)
478 }
479 }
480
481 fn layout(
482 &self,
483 text: &cranpose_ui::text::AnnotatedString,
484 style: &TextStyle,
485 ) -> TextLayoutResult {
486 if let Some(font) = self.fonts.resolve(style) {
487 layout_text_with_font(text.text.as_str(), style, font)
488 } else {
489 fallback_layout_text(text.text.as_str(), style)
490 }
491 }
492
493 fn choose_auto_hyphen_break(
494 &self,
495 line: &str,
496 style: &TextStyle,
497 segment_start_char: usize,
498 measured_break_char: usize,
499 ) -> Option<usize> {
500 self.hyphenation.choose_auto_hyphen_break(
501 line,
502 style,
503 segment_start_char,
504 measured_break_char,
505 )
506 }
507}
508
509pub fn software_text_content_hash(text: &cranpose_ui::text::AnnotatedString) -> u64 {
510 let mut state = default_hash::new();
511 text.text.hash(&mut state);
512 text.span_styles_hash().hash(&mut state);
513 state.finish()
514}
515
516#[derive(Clone, Copy)]
517enum GlyphRasterStyle {
518 Fill,
519 Stroke { width_px: f32 },
520}
521
522struct GlyphMask {
523 alpha: Vec<f32>,
524 width: usize,
525 height: usize,
526 origin_x: i32,
527 origin_y: i32,
528}
529
530struct RasterFontRef<'a, F> {
531 font: &'a F,
532 ab_glyph_scale_factor: f32,
533 weight: FontWeight,
534 style: FontStyle,
535}
536
537#[derive(Clone, Copy)]
538struct TextWeightSynthesis {
539 embolden_px: f32,
540 advance_scale: f32,
541}
542
543impl TextWeightSynthesis {
544 fn none() -> Self {
545 Self {
546 embolden_px: 0.0,
547 advance_scale: 1.0,
548 }
549 }
550
551 fn for_style(
552 style: &TextStyle,
553 resolved_weight: FontWeight,
554 font_size: f32,
555 scale: f32,
556 ) -> Self {
557 let requested_weight = style.span_style.font_weight.unwrap_or_default();
558 if requested_weight <= resolved_weight {
559 return Self::none();
560 }
561
562 let synthesis = style
563 .span_style
564 .font_synthesis
565 .unwrap_or(FontSynthesis::All);
566 if !matches!(synthesis, FontSynthesis::All | FontSynthesis::Weight) {
567 return Self::none();
568 }
569
570 let weight_delta = (requested_weight.value() - resolved_weight.value()) as f32;
571 let strength = (weight_delta / 300.0).clamp(0.0, 1.5);
572 Self {
573 embolden_px: (font_size * scale * 0.055 * strength).clamp(0.0, 3.0 * scale),
574 advance_scale: 1.0 + 0.085 * strength.min(1.0),
575 }
576 }
577
578 fn apply_width(self, width: f32) -> f32 {
579 width * self.advance_scale
580 }
581}
582
583#[derive(Clone, Copy)]
584struct TextStyleSynthesis {
585 slant: f32,
586 font_size: f32,
587 scale: f32,
588}
589
590impl TextStyleSynthesis {
591 fn none() -> Self {
592 Self {
593 slant: 0.0,
594 font_size: 0.0,
595 scale: 1.0,
596 }
597 }
598
599 fn for_style(style: &TextStyle, resolved_style: FontStyle, font_size: f32, scale: f32) -> Self {
600 let requested_style = style.span_style.font_style.unwrap_or_default();
601 if requested_style != FontStyle::Italic || resolved_style == FontStyle::Italic {
602 return Self::none();
603 }
604
605 let synthesis = style
606 .span_style
607 .font_synthesis
608 .unwrap_or(FontSynthesis::All);
609 if !matches!(synthesis, FontSynthesis::All | FontSynthesis::Style) {
610 return Self::none();
611 }
612
613 Self {
614 slant: 0.22,
615 font_size,
616 scale,
617 }
618 }
619
620 fn visual_overhang_px(self) -> f32 {
621 if self.slant <= 0.0 || !self.font_size.is_finite() || !self.scale.is_finite() {
622 return 0.0;
623 }
624 (self.font_size * self.scale * self.slant).ceil().max(0.0)
625 }
626}
627
628pub fn rasterize_text_to_image(
629 text: &str,
630 rect: Rect,
631 style: &TextStyle,
632 fallback_color: Color,
633 font_size: f32,
634 scale: f32,
635 font: &SoftwareTextFont,
636) -> Option<ImageBitmap> {
637 rasterize_text_to_image_impl(
638 text,
639 rect,
640 style,
641 fallback_color,
642 font_size,
643 scale,
644 RasterFontRef {
645 font: &font.font,
646 ab_glyph_scale_factor: font.metadata.ab_glyph_scale_factor,
647 weight: font.weight(),
648 style: font.style(),
649 },
650 )
651}
652
653pub fn measure_text_with_font(
654 text: &str,
655 style: &TextStyle,
656 font_size: f32,
657 font: &SoftwareTextFont,
658) -> TextMetrics {
659 measure_text_impl(
660 text,
661 style,
662 font_size,
663 font.ab_glyph_px_size(font_size),
664 &font.font,
665 font.style(),
666 font.weight(),
667 )
668}
669
670pub fn measure_annotated_text_with_font(
671 text: &AnnotatedString,
672 style: &TextStyle,
673 font_size: f32,
674 font: &SoftwareTextFont,
675) -> TextMetrics {
676 if text.span_styles.is_empty() {
677 return measure_text_with_font(text.text.as_str(), style, font_size, font);
678 }
679 measure_annotated_text_with_resolver(
680 text,
681 style,
682 font_size,
683 &SoftwareTextFontSet::from_font(font.clone()),
684 )
685}
686
687pub fn measure_annotated_text_with_font_set(
688 text: &AnnotatedString,
689 style: &TextStyle,
690 font_size: f32,
691 fonts: &SoftwareTextFontSet,
692) -> TextMetrics {
693 if text.span_styles.is_empty() {
694 if let Some(font) = fonts.resolve(style) {
695 return measure_text_with_font(text.text.as_str(), style, font_size, font);
696 }
697 return fallback_text_metrics(text.text.as_str(), style, font_size);
698 }
699 measure_annotated_text_with_resolver(text, style, font_size, fonts)
700}
701
702pub fn text_offset_for_position_with_font(
703 text: &str,
704 style: &TextStyle,
705 x: f32,
706 y: f32,
707 font: &SoftwareTextFont,
708) -> usize {
709 if text.is_empty() {
710 return 0;
711 }
712
713 let font_size = resolve_font_size(style);
714 let glyph_font_size = font.ab_glyph_px_size(font_size);
715 let line_height = resolve_line_height(style, font_size * 1.4);
716
717 let line_index = (y / line_height).floor().max(0.0) as usize;
718 let lines: Vec<&str> = text.split('\n').collect();
719 let target_line = line_index.min(lines.len().saturating_sub(1));
720
721 let mut line_start_byte = 0;
722 for line in lines.iter().take(target_line) {
723 line_start_byte += line.len() + 1;
724 }
725
726 let line_text = lines.get(target_line).unwrap_or(&"");
727 if line_text.is_empty() {
728 return line_start_byte;
729 }
730
731 let mut best_offset = 0;
732 let mut best_distance = f32::INFINITY;
733 let mut current_byte_offset = 0;
734
735 for c in line_text.chars() {
736 let prefix = &line_text[..current_byte_offset];
737 let glyph_x = measure_text_impl(
738 prefix,
739 style,
740 font_size,
741 glyph_font_size,
742 &font.font,
743 font.style(),
744 font.weight(),
745 )
746 .width;
747
748 let char_str = &line_text[current_byte_offset..current_byte_offset + c.len_utf8()];
749 let char_width = measure_text_impl(
750 char_str,
751 style,
752 font_size,
753 glyph_font_size,
754 &font.font,
755 font.style(),
756 font.weight(),
757 )
758 .width
759 .max(font_size * 0.5);
760
761 let left_dist = (x - glyph_x).abs();
762 if left_dist < best_distance {
763 best_distance = left_dist;
764 best_offset = current_byte_offset;
765 }
766
767 let right_x = glyph_x + char_width;
768 let right_dist = (x - right_x).abs();
769 if right_dist < best_distance {
770 best_distance = right_dist;
771 best_offset = current_byte_offset + c.len_utf8();
772 }
773
774 current_byte_offset += c.len_utf8();
775 }
776
777 let total_width = measure_text_impl(
778 line_text,
779 style,
780 font_size,
781 glyph_font_size,
782 &font.font,
783 font.style(),
784 font.weight(),
785 )
786 .width;
787 let end_dist = (x - total_width).abs();
788 if end_dist < best_distance {
789 best_offset = line_text.len();
790 }
791
792 line_start_byte + best_offset.min(line_text.len())
793}
794
795pub fn cursor_x_for_offset_with_font(
796 text: &str,
797 style: &TextStyle,
798 offset: usize,
799 font: &SoftwareTextFont,
800) -> f32 {
801 let clamped_offset = clamp_to_char_boundary(text, offset.min(text.len()));
802 if clamped_offset == 0 {
803 return 0.0;
804 }
805
806 let font_size = resolve_font_size(style);
807 measure_text_impl(
808 &text[..clamped_offset],
809 style,
810 font_size,
811 font.ab_glyph_px_size(font_size),
812 &font.font,
813 font.style(),
814 font.weight(),
815 )
816 .width
817}
818
819pub fn layout_text_with_font(
820 text: &str,
821 style: &TextStyle,
822 font: &SoftwareTextFont,
823) -> TextLayoutResult {
824 let font_size = resolve_font_size(style);
825 let glyph_font_size = font.ab_glyph_px_size(font_size);
826 let resolved_weight = font.weight();
827 let resolved_style = font.style();
828 let weight_synthesis = TextWeightSynthesis::for_style(style, resolved_weight, font_size, 1.0);
829 let font = &font.font;
830 let line_height = resolve_line_height(style, font_size * 1.4);
831 let letter_spacing = resolve_letter_spacing(style, font_size);
832 let scaled_font = font.as_scaled(PxScale::from(glyph_font_size));
833
834 let mut glyph_x_positions = Vec::new();
835 let mut char_to_byte = Vec::new();
836 let mut glyph_layouts = Vec::new();
837 let mut lines = Vec::new();
838 let mut current_x = 0.0f32;
839 let mut line_start = 0;
840 let mut y = 0.0f32;
841
842 let mut iter = text.char_indices().peekable();
843 while let Some((byte_offset, c)) = iter.next() {
844 glyph_x_positions.push(current_x);
845 char_to_byte.push(byte_offset);
846
847 if c == '\n' {
848 lines.push(LineLayout {
849 start_offset: line_start,
850 end_offset: byte_offset,
851 y,
852 height: line_height,
853 });
854 line_start = byte_offset + 1;
855 y += line_height;
856 current_x = 0.0;
857 } else {
858 let glyph_id = scaled_font.glyph_id(c);
859 let glyph_width =
860 weight_synthesis.apply_width(scaled_font.h_advance(glyph_id).max(0.0));
861 let glyph_end = byte_offset + c.len_utf8();
862 if glyph_end > byte_offset {
863 glyph_layouts.push(GlyphLayout {
864 line_index: lines.len(),
865 start_offset: byte_offset,
866 end_offset: glyph_end,
867 x: current_x,
868 y,
869 width: glyph_width,
870 height: line_height,
871 });
872 }
873 current_x += glyph_width;
874 if let Some((_, next)) = iter.peek() {
875 if *next != '\n' {
876 current_x += letter_spacing;
877 }
878 }
879 }
880 }
881
882 glyph_x_positions.push(current_x);
883 char_to_byte.push(text.len());
884
885 lines.push(LineLayout {
886 start_offset: line_start,
887 end_offset: text.len(),
888 y,
889 height: line_height,
890 });
891
892 let metrics = measure_text_impl(
893 text,
894 style,
895 font_size,
896 glyph_font_size,
897 font,
898 resolved_style,
899 resolved_weight,
900 );
901 TextLayoutResult::new(
902 text,
903 TextLayoutData {
904 width: metrics.width,
905 height: metrics.height,
906 line_height,
907 glyph_x_positions,
908 char_to_byte,
909 lines,
910 glyph_layouts,
911 },
912 )
913}
914
915pub fn rasterize_text_to_image_with_font(
916 text: &str,
917 rect: Rect,
918 style: &TextStyle,
919 fallback_color: Color,
920 font_size: f32,
921 scale: f32,
922 font: &impl Font,
923) -> Option<ImageBitmap> {
924 rasterize_text_to_image_impl(
925 text,
926 rect,
927 style,
928 fallback_color,
929 font_size,
930 scale,
931 RasterFontRef {
932 font,
933 ab_glyph_scale_factor: 1.0,
934 weight: FontWeight::NORMAL,
935 style: FontStyle::Normal,
936 },
937 )
938}
939
940fn rasterize_text_to_image_impl(
941 text: &str,
942 rect: Rect,
943 style: &TextStyle,
944 fallback_color: Color,
945 font_size: f32,
946 scale: f32,
947 font_ref: RasterFontRef<'_, impl Font>,
948) -> Option<ImageBitmap> {
949 if text.is_empty()
950 || rect.width <= 0.0
951 || rect.height <= 0.0
952 || !font_size.is_finite()
953 || font_size <= 0.0
954 || !scale.is_finite()
955 || scale <= 0.0
956 {
957 return None;
958 }
959
960 let width = rect.width.ceil().max(1.0) as u32;
961 let height = rect.height.ceil().max(1.0) as u32;
962 let mut canvas = vec![[0.0f32; 4]; (width * height) as usize];
963
964 let fallback_brush = Brush::solid(fallback_color);
965 let (brush, brush_alpha_multiplier) = match style.span_style.brush.as_ref() {
966 Some(brush) => (brush, style.span_style.alpha.unwrap_or(1.0).clamp(0.0, 1.0)),
967 None => (&fallback_brush, 1.0),
968 };
969 let raster_style = match style.span_style.draw_style.unwrap_or(TextDrawStyle::Fill) {
970 TextDrawStyle::Fill => GlyphRasterStyle::Fill,
971 TextDrawStyle::Stroke { width } => {
972 if width.is_finite() && width > 0.0 {
973 GlyphRasterStyle::Stroke {
974 width_px: width * scale,
975 }
976 } else {
977 GlyphRasterStyle::Fill
978 }
979 }
980 };
981 let shadow = style
982 .span_style
983 .shadow
984 .filter(|shadow| shadow.color.3 > 0.0);
985 let static_text_motion = style
986 .paragraph_style
987 .text_motion
988 .unwrap_or(TextMotion::Static)
989 == TextMotion::Static;
990
991 let origin_x = if static_text_motion {
992 0.0
993 } else {
994 rect.x.fract()
995 };
996 let origin_y = if static_text_motion {
997 0.0
998 } else {
999 rect.y.fract()
1000 };
1001
1002 let font = font_ref.font;
1003 let font_px_size = font_size * scale * font_ref.ab_glyph_scale_factor;
1004 let weight_synthesis = TextWeightSynthesis::for_style(style, font_ref.weight, font_size, scale);
1005 let style_synthesis = TextStyleSynthesis::for_style(style, font_ref.style, font_size, scale);
1006 let metrics = vertical_metrics(font, font_px_size);
1007 let line_height = (style.resolve_line_height(14.0, font_size * 1.4) * scale).max(1.0);
1008 let first_baseline_y = baseline_y_for_line_box(metrics, line_height);
1009
1010 for (line_idx, line) in text.split('\n').enumerate() {
1011 let baseline_y = first_baseline_y + line_idx as f32 * line_height + origin_y;
1012 let offset = point(origin_x, baseline_y);
1013
1014 for glyph in layout_line_glyphs(font, line, font_px_size, offset) {
1015 let glyph = align_glyph_for_text_motion(glyph, static_text_motion);
1016 let Some((outlined, bounds)) = outline_glyph_with_bounds(font, &glyph) else {
1017 continue;
1018 };
1019 let Some(mask) = build_glyph_mask(font, &glyph, &outlined, bounds, raster_style) else {
1020 continue;
1021 };
1022 let mask = synthesize_glyph_weight(mask, weight_synthesis);
1023 let mask = synthesize_glyph_style(mask, style_synthesis);
1024
1025 if let Some(shadow) = shadow {
1026 draw_shadow_mask(
1027 &mut canvas,
1028 width,
1029 height,
1030 &mask,
1031 shadow,
1032 scale,
1033 static_text_motion,
1034 );
1035 }
1036
1037 draw_mask_glyph(
1038 &mut canvas,
1039 width,
1040 height,
1041 &mask,
1042 brush,
1043 brush_alpha_multiplier,
1044 rect,
1045 );
1046 }
1047 }
1048
1049 let mut rgba = vec![0u8; canvas.len() * 4];
1050 for (index, pixel) in canvas.iter().enumerate() {
1051 let base = index * 4;
1052 rgba[base] = (pixel[0].clamp(0.0, 1.0) * 255.0).round() as u8;
1053 rgba[base + 1] = (pixel[1].clamp(0.0, 1.0) * 255.0).round() as u8;
1054 rgba[base + 2] = (pixel[2].clamp(0.0, 1.0) * 255.0).round() as u8;
1055 rgba[base + 3] = (pixel[3].clamp(0.0, 1.0) * 255.0).round() as u8;
1056 }
1057
1058 ImageBitmap::from_rgba8(width, height, rgba).ok()
1059}
1060
1061fn resolve_font_size(style: &TextStyle) -> f32 {
1062 style.resolve_font_size(14.0)
1063}
1064
1065fn baseline_y_for_line_box(
1066 metrics: crate::font_layout::FontVerticalMetrics,
1067 line_height: f32,
1068) -> f32 {
1069 metrics.ascent + (line_height - metrics.natural_line_height) * 0.5
1070}
1071
1072fn resolve_line_height(style: &TextStyle, font_size: f32) -> f32 {
1073 style.resolve_line_height(14.0, font_size)
1074}
1075
1076fn resolve_letter_spacing(style: &TextStyle, font_size: f32) -> f32 {
1077 let _ = font_size;
1078 style.resolve_letter_spacing(14.0)
1079}
1080
1081fn fallback_char_width(font_size: f32) -> f32 {
1082 font_size.max(1.0) * 0.55
1083}
1084
1085fn fallback_line_height(style: &TextStyle, font_size: f32) -> f32 {
1086 resolve_line_height(style, font_size.max(1.0) * 1.2)
1087}
1088
1089fn fallback_line_heights(text: &str, style: &TextStyle, font_size: f32) -> Vec<f32> {
1090 let line_count = text.split('\n').count().max(1);
1091 vec![fallback_line_height(style, font_size); line_count]
1092}
1093
1094fn fallback_text_metrics(text: &str, style: &TextStyle, font_size: f32) -> TextMetrics {
1095 let line_height = fallback_line_height(style, font_size);
1096 let char_width = fallback_char_width(font_size);
1097 let letter_spacing = resolve_letter_spacing(style, font_size);
1098 let mut line_count = 0usize;
1099 let mut max_width = 0.0f32;
1100
1101 for line in text.split('\n') {
1102 line_count += 1;
1103 let char_count = line.chars().count();
1104 let spacing = char_count.saturating_sub(1) as f32 * letter_spacing;
1105 max_width = max_width.max(char_count as f32 * char_width + spacing);
1106 }
1107
1108 let line_count = line_count.max(1);
1109 TextMetrics {
1110 width: max_width,
1111 height: line_count as f32 * line_height,
1112 line_height,
1113 line_count,
1114 }
1115}
1116
1117fn fallback_cursor_x_for_offset(text: &str, style: &TextStyle, offset: usize) -> f32 {
1118 let font_size = resolve_font_size(style);
1119 let clamped = clamp_to_char_boundary(text, offset.min(text.len()));
1120 let line_start = text[..clamped].rfind('\n').map_or(0, |index| index + 1);
1121 let char_count = text[line_start..clamped].chars().count();
1122 let spacing = char_count.saturating_sub(1) as f32 * resolve_letter_spacing(style, font_size);
1123 char_count as f32 * fallback_char_width(font_size) + spacing
1124}
1125
1126fn fallback_text_offset_for_position(text: &str, style: &TextStyle, x: f32, y: f32) -> usize {
1127 if text.is_empty() {
1128 return 0;
1129 }
1130
1131 let font_size = resolve_font_size(style);
1132 let line_height = fallback_line_height(style, font_size);
1133 let line_index = (y / line_height).floor().max(0.0) as usize;
1134 let lines: Vec<&str> = text.split('\n').collect();
1135 let target_line = line_index.min(lines.len().saturating_sub(1));
1136
1137 let mut line_start_byte = 0;
1138 for line in lines.iter().take(target_line) {
1139 line_start_byte += line.len() + 1;
1140 }
1141
1142 let line_text = lines.get(target_line).copied().unwrap_or("");
1143 if line_text.is_empty() {
1144 return line_start_byte;
1145 }
1146
1147 let advance =
1148 (fallback_char_width(font_size) + resolve_letter_spacing(style, font_size)).max(1.0);
1149 let target_char = (x / advance).round().max(0.0) as usize;
1150 line_start_byte + byte_offset_for_char_index(line_text, target_char)
1151}
1152
1153fn fallback_layout_text(text: &str, style: &TextStyle) -> TextLayoutResult {
1154 let font_size = resolve_font_size(style);
1155 let line_height = fallback_line_height(style, font_size);
1156 let char_width = fallback_char_width(font_size);
1157 let letter_spacing = resolve_letter_spacing(style, font_size);
1158
1159 let mut glyph_x_positions = Vec::new();
1160 let mut char_to_byte = Vec::new();
1161 let mut glyph_layouts = Vec::new();
1162 let mut lines = Vec::new();
1163 let mut current_x = 0.0f32;
1164 let mut line_start = 0;
1165 let mut y = 0.0f32;
1166
1167 let mut iter = text.char_indices().peekable();
1168 while let Some((byte_offset, ch)) = iter.next() {
1169 glyph_x_positions.push(current_x);
1170 char_to_byte.push(byte_offset);
1171
1172 if ch == '\n' {
1173 lines.push(LineLayout {
1174 start_offset: line_start,
1175 end_offset: byte_offset,
1176 y,
1177 height: line_height,
1178 });
1179 line_start = byte_offset + 1;
1180 y += line_height;
1181 current_x = 0.0;
1182 } else {
1183 glyph_layouts.push(GlyphLayout {
1184 line_index: lines.len(),
1185 start_offset: byte_offset,
1186 end_offset: byte_offset + ch.len_utf8(),
1187 x: current_x,
1188 y,
1189 width: char_width,
1190 height: line_height,
1191 });
1192 current_x += char_width;
1193 if let Some((_, next)) = iter.peek() {
1194 if *next != '\n' {
1195 current_x += letter_spacing;
1196 }
1197 }
1198 }
1199 }
1200
1201 glyph_x_positions.push(current_x);
1202 char_to_byte.push(text.len());
1203 lines.push(LineLayout {
1204 start_offset: line_start,
1205 end_offset: text.len(),
1206 y,
1207 height: line_height,
1208 });
1209
1210 let metrics = fallback_text_metrics(text, style, font_size);
1211 TextLayoutResult::new(
1212 text,
1213 TextLayoutData {
1214 width: metrics.width,
1215 height: metrics.height,
1216 line_height,
1217 glyph_x_positions,
1218 char_to_byte,
1219 glyph_layouts,
1220 lines,
1221 },
1222 )
1223}
1224
1225fn byte_offset_for_char_index(text: &str, char_index: usize) -> usize {
1226 text.char_indices()
1227 .map(|(index, _)| index)
1228 .nth(char_index)
1229 .unwrap_or(text.len())
1230}
1231
1232fn measure_text_impl(
1233 text: &str,
1234 style: &TextStyle,
1235 font_size: f32,
1236 glyph_font_size: f32,
1237 font: &impl Font,
1238 resolved_style: FontStyle,
1239 resolved_weight: FontWeight,
1240) -> TextMetrics {
1241 let line_height = resolve_line_height(style, font_size * 1.4);
1242 let letter_spacing = resolve_letter_spacing(style, font_size);
1243 let weight_synthesis = TextWeightSynthesis::for_style(style, resolved_weight, font_size, 1.0);
1244 let style_synthesis = TextStyleSynthesis::for_style(style, resolved_style, font_size, 1.0);
1245
1246 let lines: Vec<&str> = text.split('\n').collect();
1247 let line_count = lines.len().max(1);
1248
1249 let mut max_width: f32 = 0.0;
1250 for line in &lines {
1251 let line_width = line_advance_width(font, line, glyph_font_size);
1252 let char_spacing = (line.chars().count().saturating_sub(1) as f32) * letter_spacing;
1253 let line_width = (weight_synthesis.apply_width(line_width) + char_spacing).max(0.0);
1254 let line_width = if line.is_empty() {
1255 line_width
1256 } else {
1257 line_width + style_synthesis.visual_overhang_px()
1258 };
1259 max_width = max_width.max(line_width);
1260 }
1261
1262 TextMetrics {
1263 width: max_width,
1264 height: line_count as f32 * line_height,
1265 line_height,
1266 line_count,
1267 }
1268}
1269
1270fn measure_annotated_text_with_resolver(
1271 text: &AnnotatedString,
1272 style: &TextStyle,
1273 font_size: f32,
1274 fonts: &SoftwareTextFontSet,
1275) -> TextMetrics {
1276 let Some(base_font) = fonts.resolve(style) else {
1277 return fallback_text_metrics(text.text.as_str(), style, font_size);
1278 };
1279 let base_line_height = line_height_for_style(style, font_size, &base_font.font);
1280 let mut boundaries = text.span_boundaries();
1281 for (offset, ch) in text.text.char_indices() {
1282 if ch == '\n' {
1283 boundaries.push(offset);
1284 boundaries.push(offset + ch.len_utf8());
1285 }
1286 }
1287 boundaries.sort_unstable();
1288 boundaries.dedup();
1289 boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
1290
1291 let mut line_count = 1usize;
1292 let mut max_width = 0.0f32;
1293 let mut current_line_width = 0.0f32;
1294
1295 for range in boundaries.windows(2) {
1296 let start = range[0];
1297 let end = range[1];
1298 if start == end {
1299 continue;
1300 }
1301 let segment = &text.text[start..end];
1302 let segment_style = effective_style_for_range(text, style, start, end);
1303 let segment_font_size = resolve_font_size(&segment_style);
1304 let Some(segment_font) = fonts.resolve(&segment_style) else {
1305 let mut remaining = segment;
1306 loop {
1307 if let Some(newline_offset) = remaining.find('\n') {
1308 let before_newline = &remaining[..newline_offset];
1309 if !before_newline.is_empty() {
1310 current_line_width += fallback_text_metrics(
1311 before_newline,
1312 &segment_style,
1313 segment_font_size,
1314 )
1315 .width;
1316 }
1317 max_width = max_width.max(current_line_width);
1318 current_line_width = 0.0;
1319 line_count += 1;
1320 remaining = &remaining[newline_offset + 1..];
1321 if remaining.is_empty() {
1322 break;
1323 }
1324 } else {
1325 if !remaining.is_empty() {
1326 current_line_width +=
1327 fallback_text_metrics(remaining, &segment_style, segment_font_size)
1328 .width;
1329 }
1330 break;
1331 }
1332 }
1333 continue;
1334 };
1335
1336 let mut remaining = segment;
1337 loop {
1338 if let Some(newline_offset) = remaining.find('\n') {
1339 let before_newline = &remaining[..newline_offset];
1340 if !before_newline.is_empty() {
1341 let metrics = measure_text_impl(
1342 before_newline,
1343 &segment_style,
1344 segment_font_size,
1345 segment_font.ab_glyph_px_size(segment_font_size),
1346 &segment_font.font,
1347 segment_font.style(),
1348 segment_font.weight(),
1349 );
1350 current_line_width += metrics.width;
1351 }
1352 max_width = max_width.max(current_line_width);
1353 current_line_width = 0.0;
1354 line_count += 1;
1355 remaining = &remaining[newline_offset + 1..];
1356 if remaining.is_empty() {
1357 break;
1358 }
1359 } else {
1360 if !remaining.is_empty() {
1361 let metrics = measure_text_impl(
1362 remaining,
1363 &segment_style,
1364 segment_font_size,
1365 segment_font.ab_glyph_px_size(segment_font_size),
1366 &segment_font.font,
1367 segment_font.style(),
1368 segment_font.weight(),
1369 );
1370 current_line_width += metrics.width;
1371 }
1372 break;
1373 }
1374 }
1375 }
1376
1377 max_width = max_width.max(current_line_width);
1378
1379 let line_heights = annotated_line_heights_with_resolver(text, style, font_size, fonts);
1380 let total_height = line_heights.iter().sum();
1381 let max_line_height = line_heights.into_iter().fold(base_line_height, f32::max);
1382
1383 TextMetrics {
1384 width: max_width,
1385 height: total_height,
1386 line_height: max_line_height,
1387 line_count,
1388 }
1389}
1390
1391fn annotated_line_heights_with_resolver(
1392 text: &AnnotatedString,
1393 style: &TextStyle,
1394 font_size: f32,
1395 fonts: &SoftwareTextFontSet,
1396) -> Vec<f32> {
1397 let Some(base_font) = fonts.resolve(style) else {
1398 return fallback_line_heights(text.text.as_str(), style, font_size);
1399 };
1400 let base_line_height = line_height_for_style(style, font_size, &base_font.font);
1401 let mut line_heights = vec![base_line_height];
1402 let mut boundaries = text.span_boundaries();
1403 for (offset, ch) in text.text.char_indices() {
1404 if ch == '\n' {
1405 boundaries.push(offset);
1406 boundaries.push(offset + ch.len_utf8());
1407 }
1408 }
1409 boundaries.sort_unstable();
1410 boundaries.dedup();
1411 boundaries.retain(|offset| *offset <= text.text.len() && text.text.is_char_boundary(*offset));
1412
1413 let mut line_index = 0usize;
1414 for range in boundaries.windows(2) {
1415 let start = range[0];
1416 let end = range[1];
1417 if start == end {
1418 continue;
1419 }
1420 let segment = &text.text[start..end];
1421 let segment_style = effective_style_for_range(text, style, start, end);
1422 let segment_font_size = resolve_font_size(&segment_style);
1423 let segment_line_height = if let Some(segment_font) = fonts.resolve(&segment_style) {
1424 line_height_for_style(&segment_style, segment_font_size, &segment_font.font)
1425 } else {
1426 fallback_line_height(&segment_style, segment_font_size)
1427 };
1428 for ch in segment.chars() {
1429 line_heights[line_index] = line_heights[line_index].max(segment_line_height);
1430 if ch == '\n' {
1431 line_index += 1;
1432 if line_heights.len() <= line_index {
1433 line_heights.push(base_line_height);
1434 }
1435 }
1436 }
1437 }
1438
1439 line_heights
1440}
1441
1442fn effective_style_for_range(
1443 text: &AnnotatedString,
1444 style: &TextStyle,
1445 start: usize,
1446 end: usize,
1447) -> TextStyle {
1448 let mut effective = style.clone();
1449 for span in &text.span_styles {
1450 if span.range.start < end && span.range.end > start {
1451 effective.span_style = effective.span_style.merge(&span.item);
1452 }
1453 }
1454 effective
1455}
1456
1457fn line_height_for_style(style: &TextStyle, font_size: f32, font: &impl Font) -> f32 {
1458 let _ = font;
1459 resolve_line_height(style, font_size * 1.4)
1460}
1461
1462fn clamp_to_char_boundary(text: &str, mut offset: usize) -> usize {
1463 offset = offset.min(text.len());
1464 while offset > 0 && !text.is_char_boundary(offset) {
1465 offset -= 1;
1466 }
1467 offset
1468}
1469
1470fn align_glyph_for_text_motion(glyph: Glyph, static_text_motion: bool) -> Glyph {
1471 align_glyph_to_pixel_grid(glyph, static_text_motion)
1472}
1473
1474fn blend_src_over(dst: &mut [f32; 4], src: [f32; 4]) {
1475 let src_alpha = src[3].clamp(0.0, 1.0);
1476 if src_alpha <= 0.0 {
1477 return;
1478 }
1479
1480 let dst_alpha = dst[3].clamp(0.0, 1.0);
1481 let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
1482
1483 if out_alpha <= f32::EPSILON {
1484 *dst = [0.0, 0.0, 0.0, 0.0];
1485 return;
1486 }
1487
1488 for channel in 0..3 {
1489 let src_premult = src[channel].clamp(0.0, 1.0) * src_alpha;
1490 let dst_premult = dst[channel].clamp(0.0, 1.0) * dst_alpha;
1491 dst[channel] =
1492 ((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0);
1493 }
1494 dst[3] = out_alpha;
1495}
1496
1497fn draw_mask_glyph(
1498 canvas: &mut [[f32; 4]],
1499 width: u32,
1500 height: u32,
1501 mask: &GlyphMask,
1502 brush: &Brush,
1503 brush_alpha_multiplier: f32,
1504 brush_rect: Rect,
1505) {
1506 for y in 0..mask.height {
1507 let py = mask.origin_y + y as i32;
1508 if py < 0 || py >= height as i32 {
1509 continue;
1510 }
1511
1512 for x in 0..mask.width {
1513 let px = mask.origin_x + x as i32;
1514 if px < 0 || px >= width as i32 {
1515 continue;
1516 }
1517
1518 let coverage = mask.alpha[y * mask.width + x];
1519 if coverage <= 0.0 {
1520 continue;
1521 }
1522
1523 let sample = sample_brush_rgba(
1524 brush,
1525 brush_rect,
1526 brush_rect.x + px as f32 + 0.5,
1527 brush_rect.y + py as f32 + 0.5,
1528 );
1529 let alpha = coverage * sample[3] * brush_alpha_multiplier;
1530 if alpha <= 0.0 {
1531 continue;
1532 }
1533 let idx = (py as u32 * width + px as u32) as usize;
1534 blend_src_over(
1535 &mut canvas[idx],
1536 [sample[0], sample[1], sample[2], alpha.clamp(0.0, 1.0)],
1537 );
1538 }
1539 }
1540}
1541
1542fn draw_shadow_mask(
1543 canvas: &mut [[f32; 4]],
1544 width: u32,
1545 height: u32,
1546 mask: &GlyphMask,
1547 shadow: Shadow,
1548 text_scale: f32,
1549 static_text_motion: bool,
1550) {
1551 if mask.width == 0 || mask.height == 0 {
1552 return;
1553 }
1554
1555 let shadow_dx = shadow.offset.x * text_scale;
1556 let shadow_dy = shadow.offset.y * text_scale;
1557 let blur_radius = (shadow.blur_radius * text_scale).max(0.0);
1558 let sigma = shadow_blur_sigma(blur_radius);
1559 let blur_margin = if sigma > 0.0 {
1560 (sigma * 3.0).ceil() as i32
1561 } else {
1562 0
1563 };
1564
1565 let padded_width = mask.width + (blur_margin as usize) * 2;
1566 let padded_height = mask.height + (blur_margin as usize) * 2;
1567 let mut padded_mask = vec![0.0f32; padded_width * padded_height];
1568
1569 for y in 0..mask.height {
1570 let src_offset = y * mask.width;
1571 let dst_offset = (y + blur_margin as usize) * padded_width + blur_margin as usize;
1572 padded_mask[dst_offset..dst_offset + mask.width]
1573 .copy_from_slice(&mask.alpha[src_offset..src_offset + mask.width]);
1574 }
1575
1576 let blurred = if sigma > 0.0 {
1577 gaussian_blur_alpha(&padded_mask, padded_width, padded_height, sigma)
1578 } else {
1579 padded_mask
1580 };
1581
1582 let shadow_rgba = color_to_rgba(shadow.color);
1583 let shadow_origin_x = mask.origin_x - blur_margin;
1584 let shadow_origin_y = mask.origin_y - blur_margin;
1585
1586 for y in 0..padded_height {
1587 for x in 0..padded_width {
1588 let alpha = blurred[y * padded_width + x] * shadow_rgba[3];
1589 if alpha <= 0.0 {
1590 continue;
1591 }
1592
1593 let target_x = shadow_origin_x as f32 + x as f32 + shadow_dx;
1594 let target_y = shadow_origin_y as f32 + y as f32 + shadow_dy;
1595 if static_text_motion {
1596 blend_shadow_pixel(
1597 canvas,
1598 width,
1599 height,
1600 target_x.round() as i32,
1601 target_y.round() as i32,
1602 shadow_rgba,
1603 alpha.clamp(0.0, 1.0),
1604 );
1605 } else {
1606 blend_shadow_pixel_subpixel(
1607 canvas,
1608 width,
1609 height,
1610 target_x,
1611 target_y,
1612 shadow_rgba,
1613 alpha.clamp(0.0, 1.0),
1614 );
1615 }
1616 }
1617 }
1618}
1619
1620fn blend_shadow_pixel(
1621 canvas: &mut [[f32; 4]],
1622 width: u32,
1623 height: u32,
1624 px: i32,
1625 py: i32,
1626 color: [f32; 4],
1627 alpha: f32,
1628) {
1629 if px < 0 || py < 0 || px >= width as i32 || py >= height as i32 || alpha <= 0.0 {
1630 return;
1631 }
1632 let idx = (py as u32 * width + px as u32) as usize;
1633 blend_src_over(
1634 &mut canvas[idx],
1635 [color[0], color[1], color[2], alpha.clamp(0.0, 1.0)],
1636 );
1637}
1638
1639fn blend_shadow_pixel_subpixel(
1640 canvas: &mut [[f32; 4]],
1641 width: u32,
1642 height: u32,
1643 x: f32,
1644 y: f32,
1645 color: [f32; 4],
1646 alpha: f32,
1647) {
1648 if alpha <= 0.0 {
1649 return;
1650 }
1651
1652 let base_x = x.floor();
1653 let base_y = y.floor();
1654 let frac_x = x - base_x;
1655 let frac_y = y - base_y;
1656 let base_x_i32 = base_x as i32;
1657 let base_y_i32 = base_y as i32;
1658 let weights = [
1659 ((1.0 - frac_x) * (1.0 - frac_y), 0i32, 0i32),
1660 (frac_x * (1.0 - frac_y), 1, 0),
1661 ((1.0 - frac_x) * frac_y, 0, 1),
1662 (frac_x * frac_y, 1, 1),
1663 ];
1664
1665 for (weight, dx, dy) in weights {
1666 if weight <= 0.0 {
1667 continue;
1668 }
1669 blend_shadow_pixel(
1670 canvas,
1671 width,
1672 height,
1673 base_x_i32 + dx,
1674 base_y_i32 + dy,
1675 color,
1676 alpha * weight,
1677 );
1678 }
1679}
1680
1681fn shadow_blur_sigma(blur_radius: f32) -> f32 {
1682 if blur_radius <= 0.0 {
1683 0.0
1684 } else {
1685 (blur_radius * SHADOW_SIGMA_SCALE + SHADOW_SIGMA_BIAS).max(0.5)
1686 }
1687}
1688
1689fn gaussian_blur_alpha(src: &[f32], width: usize, height: usize, sigma: f32) -> Vec<f32> {
1690 let kernel = gaussian_kernel_1d(sigma);
1691 if kernel.len() == 1 {
1692 return src.to_vec();
1693 }
1694 let half = (kernel.len() / 2) as i32;
1695
1696 let mut horizontal = vec![0.0f32; src.len()];
1697 for y in 0..height {
1698 for x in 0..width {
1699 let mut sum = 0.0f32;
1700 for (index, weight) in kernel.iter().enumerate() {
1701 let offset = index as i32 - half;
1702 let sample_x = (x as i32 + offset).clamp(0, width as i32 - 1) as usize;
1703 sum += src[y * width + sample_x] * *weight;
1704 }
1705 horizontal[y * width + x] = sum;
1706 }
1707 }
1708
1709 let mut output = vec![0.0f32; src.len()];
1710 for y in 0..height {
1711 for x in 0..width {
1712 let mut sum = 0.0f32;
1713 for (index, weight) in kernel.iter().enumerate() {
1714 let offset = index as i32 - half;
1715 let sample_y = (y as i32 + offset).clamp(0, height as i32 - 1) as usize;
1716 sum += horizontal[sample_y * width + x] * *weight;
1717 }
1718 output[y * width + x] = sum;
1719 }
1720 }
1721
1722 output
1723}
1724
1725fn gaussian_kernel_1d(sigma: f32) -> Vec<f32> {
1726 let half = ((sigma * 3.0).ceil() as i32).clamp(1, MAX_GAUSSIAN_KERNEL_HALF);
1727 if half <= 0 {
1728 return vec![1.0];
1729 }
1730
1731 let mut kernel = Vec::with_capacity((half * 2 + 1) as usize);
1732 let mut sum = 0.0f32;
1733 for offset in -half..=half {
1734 let distance = offset as f32;
1735 let weight = (-0.5 * (distance / sigma).powi(2)).exp();
1736 kernel.push(weight);
1737 sum += weight;
1738 }
1739
1740 if sum > f32::EPSILON {
1741 for weight in &mut kernel {
1742 *weight /= sum;
1743 }
1744 }
1745
1746 kernel
1747}
1748
1749fn outline_glyph_with_bounds(
1750 font: &impl Font,
1751 glyph: &Glyph,
1752) -> Option<(OutlinedGlyph, GlyphPixelBounds)> {
1753 let outlined = font.outline_glyph(glyph.clone())?;
1754 let bounds = pixel_bounds_from_outlined(&outlined);
1755 Some((outlined, bounds))
1756}
1757
1758fn build_glyph_mask(
1759 font: &impl Font,
1760 glyph: &Glyph,
1761 outlined: &OutlinedGlyph,
1762 bounds: GlyphPixelBounds,
1763 style: GlyphRasterStyle,
1764) -> Option<GlyphMask> {
1765 match style {
1766 GlyphRasterStyle::Fill => build_fill_mask(outlined, bounds),
1767 GlyphRasterStyle::Stroke { width_px } => {
1768 build_stroke_mask(font, glyph, outlined, bounds, width_px)
1769 }
1770 }
1771}
1772
1773fn build_fill_mask(outlined: &OutlinedGlyph, bounds: GlyphPixelBounds) -> Option<GlyphMask> {
1774 let mask_width = bounds.width();
1775 let mask_height = bounds.height();
1776 if mask_width == 0 || mask_height == 0 {
1777 return None;
1778 }
1779
1780 let mut alpha = vec![0.0f32; mask_width * mask_height];
1781 outlined.draw(|gx, gy, value| {
1782 let idx = gy as usize * mask_width + gx as usize;
1783 alpha[idx] = value;
1784 });
1785
1786 Some(GlyphMask {
1787 alpha,
1788 width: mask_width,
1789 height: mask_height,
1790 origin_x: bounds.min_x,
1791 origin_y: bounds.min_y,
1792 })
1793}
1794
1795fn build_stroke_mask(
1796 font: &impl Font,
1797 glyph: &Glyph,
1798 outlined: &OutlinedGlyph,
1799 bounds: GlyphPixelBounds,
1800 stroke_width_px: f32,
1801) -> Option<GlyphMask> {
1802 if !stroke_width_px.is_finite() || stroke_width_px <= 0.0 {
1803 return build_fill_mask(outlined, bounds);
1804 }
1805
1806 let mask_width = bounds.max_x - bounds.min_x;
1807 let mask_height = bounds.max_y - bounds.min_y;
1808 if mask_width <= 0 || mask_height <= 0 {
1809 return None;
1810 }
1811
1812 let half_width = stroke_width_px * 0.5;
1813 let miter_pad = (half_width * COMPOSE_STROKE_MITER_LIMIT).ceil();
1814 let pad = miter_pad.max(1.0) as i32 + 1;
1815 let path = build_outline_path(font, glyph, bounds, pad)?;
1816 let raster_width = mask_width + pad * 2;
1817 let raster_height = mask_height + pad * 2;
1818 if raster_width <= 0 || raster_height <= 0 {
1819 return None;
1820 }
1821
1822 let mut pixmap = Pixmap::new(raster_width as u32, raster_height as u32)?;
1823 let mut paint = Paint::default();
1824 paint.set_color_rgba8(255, 255, 255, 255);
1825 paint.anti_alias = true;
1826
1827 let stroke = Stroke {
1828 width: stroke_width_px,
1829 line_cap: LineCap::Butt,
1830 line_join: LineJoin::Miter,
1831 miter_limit: COMPOSE_STROKE_MITER_LIMIT,
1832 ..Stroke::default()
1833 };
1834
1835 pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
1836
1837 let alpha = pixmap
1838 .data()
1839 .chunks_exact(4)
1840 .map(|pixel| pixel[3] as f32 / 255.0)
1841 .collect();
1842
1843 Some(GlyphMask {
1844 alpha,
1845 width: raster_width as usize,
1846 height: raster_height as usize,
1847 origin_x: bounds.min_x - pad,
1848 origin_y: bounds.min_y - pad,
1849 })
1850}
1851
1852fn synthesize_glyph_weight(mask: GlyphMask, synthesis: TextWeightSynthesis) -> GlyphMask {
1853 let horizontal_shift = synthetic_weight_shift_px(synthesis.embolden_px);
1854 if horizontal_shift == 0 || mask.width == 0 || mask.height == 0 {
1855 return mask;
1856 }
1857
1858 let vertical_shift = (horizontal_shift / 2).min(1);
1859 let output_width = mask.width + horizontal_shift;
1860 let output_height = mask.height + vertical_shift * 2;
1861 let mut alpha = vec![0.0f32; output_width * output_height];
1862 for y in 0..mask.height {
1863 for x in 0..mask.width {
1864 let coverage = mask.alpha[y * mask.width + x];
1865 if coverage <= 0.0 {
1866 continue;
1867 }
1868 for dy in 0..=(vertical_shift * 2) {
1869 let output_y = y + dy;
1870 for dx in 0..=horizontal_shift {
1871 let output_x = x + dx;
1872 let output_index = output_y * output_width + output_x;
1873 if coverage > alpha[output_index] {
1874 alpha[output_index] = coverage;
1875 }
1876 }
1877 }
1878 }
1879 }
1880
1881 GlyphMask {
1882 alpha,
1883 width: output_width,
1884 height: output_height,
1885 origin_x: mask.origin_x,
1886 origin_y: mask.origin_y - vertical_shift as i32,
1887 }
1888}
1889
1890fn synthesize_glyph_style(mask: GlyphMask, synthesis: TextStyleSynthesis) -> GlyphMask {
1891 if synthesis.slant <= 0.0 || mask.width == 0 || mask.height == 0 {
1892 return mask;
1893 }
1894
1895 let max_shift = ((mask.height.saturating_sub(1)) as f32 * synthesis.slant).ceil() as usize;
1896 if max_shift == 0 {
1897 return mask;
1898 }
1899
1900 let output_width = mask.width + max_shift + 1;
1901 let mut alpha = vec![0.0f32; output_width * mask.height];
1902 for y in 0..mask.height {
1903 let shift = (mask.height.saturating_sub(1) - y) as f32 * synthesis.slant;
1904 let shift_floor = shift.floor() as usize;
1905 let shift_fraction = shift - shift.floor();
1906 for x in 0..mask.width {
1907 let coverage = mask.alpha[y * mask.width + x];
1908 if coverage <= 0.0 {
1909 continue;
1910 }
1911
1912 let output_x = x + shift_floor;
1913 let left_index = y * output_width + output_x;
1914 let left_coverage = coverage * (1.0 - shift_fraction);
1915 if left_coverage > alpha[left_index] {
1916 alpha[left_index] = left_coverage;
1917 }
1918
1919 if shift_fraction > 0.0 {
1920 let right_index = left_index + 1;
1921 let right_coverage = coverage * shift_fraction;
1922 if right_coverage > alpha[right_index] {
1923 alpha[right_index] = right_coverage;
1924 }
1925 }
1926 }
1927 }
1928
1929 GlyphMask {
1930 alpha,
1931 width: output_width,
1932 height: mask.height,
1933 origin_x: mask.origin_x,
1934 origin_y: mask.origin_y,
1935 }
1936}
1937
1938fn synthetic_weight_shift_px(embolden_px: f32) -> usize {
1939 if !embolden_px.is_finite() || embolden_px < 0.35 {
1940 return 0;
1941 }
1942 embolden_px.ceil().max(1.0) as usize
1943}
1944
1945fn build_outline_path(
1946 font: &impl Font,
1947 glyph: &Glyph,
1948 bounds: GlyphPixelBounds,
1949 pad: i32,
1950) -> Option<Path> {
1951 let outline = font.outline(glyph.id)?;
1952 let scale_factor = font.as_scaled(glyph.scale).scale_factor();
1953 let mut builder = PathBuilder::new();
1954 let mut has_segments = false;
1955 let mut current_end = None;
1956 let mut subpath_start = None;
1957
1958 for curve in outline.curves {
1959 match curve {
1960 ab_glyph::OutlineCurve::Line(p0, p1) => {
1961 let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
1962 let end = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
1963 if current_end != Some(start) {
1964 if current_end.is_some() {
1965 builder.close();
1966 }
1967 builder.move_to(start.0, start.1);
1968 subpath_start = Some(start);
1969 }
1970 builder.line_to(end.0, end.1);
1971 if subpath_start == Some(end) {
1972 builder.close();
1973 current_end = None;
1974 subpath_start = None;
1975 } else {
1976 current_end = Some(end);
1977 }
1978 }
1979 ab_glyph::OutlineCurve::Quad(p0, p1, p2) => {
1980 let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
1981 let control = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
1982 let end = transform_outline_point(p2, scale_factor, glyph, bounds, pad);
1983 if current_end != Some(start) {
1984 if current_end.is_some() {
1985 builder.close();
1986 }
1987 builder.move_to(start.0, start.1);
1988 subpath_start = Some(start);
1989 }
1990 builder.quad_to(control.0, control.1, end.0, end.1);
1991 if subpath_start == Some(end) {
1992 builder.close();
1993 current_end = None;
1994 subpath_start = None;
1995 } else {
1996 current_end = Some(end);
1997 }
1998 }
1999 ab_glyph::OutlineCurve::Cubic(p0, p1, p2, p3) => {
2000 let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
2001 let control1 = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
2002 let control2 = transform_outline_point(p2, scale_factor, glyph, bounds, pad);
2003 let end = transform_outline_point(p3, scale_factor, glyph, bounds, pad);
2004 if current_end != Some(start) {
2005 if current_end.is_some() {
2006 builder.close();
2007 }
2008 builder.move_to(start.0, start.1);
2009 subpath_start = Some(start);
2010 }
2011 builder.cubic_to(control1.0, control1.1, control2.0, control2.1, end.0, end.1);
2012 if subpath_start == Some(end) {
2013 builder.close();
2014 current_end = None;
2015 subpath_start = None;
2016 } else {
2017 current_end = Some(end);
2018 }
2019 }
2020 }
2021 has_segments = true;
2022 }
2023
2024 if !has_segments {
2025 return None;
2026 }
2027
2028 if current_end.is_some() {
2029 builder.close();
2030 }
2031
2032 builder.finish()
2033}
2034
2035fn transform_outline_point(
2036 point: ab_glyph::Point,
2037 scale_factor: ab_glyph::PxScaleFactor,
2038 glyph: &Glyph,
2039 bounds: GlyphPixelBounds,
2040 pad: i32,
2041) -> (f32, f32) {
2042 (
2043 point.x * scale_factor.horizontal + glyph.position.x - bounds.min_x as f32 + pad as f32,
2044 point.y * -scale_factor.vertical + glyph.position.y - bounds.min_y as f32 + pad as f32,
2045 )
2046}
2047
2048#[cfg(test)]
2049mod tests {
2050 use super::*;
2051 use cranpose_ui::text::SpanStyle;
2052 use cranpose_ui_graphics::Point;
2053
2054 fn count_ink_pixels(image: &ImageBitmap) -> usize {
2055 image
2056 .pixels()
2057 .chunks_exact(4)
2058 .filter(|px| px[3] > 0)
2059 .count()
2060 }
2061
2062 fn average_ink_rgb(
2063 image: &ImageBitmap,
2064 x_start: u32,
2065 x_end: u32,
2066 y_start: u32,
2067 y_end: u32,
2068 ) -> Option<[f32; 3]> {
2069 let width = image.width();
2070 let height = image.height();
2071 let mut sums = [0.0f32; 3];
2072 let mut count = 0usize;
2073 let pixels = image.pixels();
2074
2075 let x_end = x_end.min(width);
2076 let y_end = y_end.min(height);
2077 for y in y_start.min(height)..y_end {
2078 for x in x_start.min(width)..x_end {
2079 let idx = ((y * width + x) * 4) as usize;
2080 let alpha = pixels[idx + 3];
2081 if alpha == 0 {
2082 continue;
2083 }
2084 sums[0] += pixels[idx] as f32 / 255.0;
2085 sums[1] += pixels[idx + 1] as f32 / 255.0;
2086 sums[2] += pixels[idx + 2] as f32 / 255.0;
2087 count += 1;
2088 }
2089 }
2090
2091 if count == 0 {
2092 return None;
2093 }
2094 Some([
2095 sums[0] / count as f32,
2096 sums[1] / count as f32,
2097 sums[2] / count as f32,
2098 ])
2099 }
2100
2101 fn ink_x_range(image: &ImageBitmap) -> Option<(u32, u32)> {
2102 let width = image.width();
2103 let height = image.height();
2104 let pixels = image.pixels();
2105 let mut min_x = u32::MAX;
2106 let mut max_x = 0u32;
2107 let mut found = false;
2108 for y in 0..height {
2109 for x in 0..width {
2110 let idx = ((y * width + x) * 4) as usize;
2111 if pixels[idx + 3] > 0 {
2112 min_x = min_x.min(x);
2113 max_x = max_x.max(x + 1);
2114 found = true;
2115 }
2116 }
2117 }
2118 found.then_some((min_x, max_x))
2119 }
2120
2121 fn ink_y_range(image: &ImageBitmap) -> Option<(u32, u32)> {
2122 let width = image.width();
2123 let height = image.height();
2124 let pixels = image.pixels();
2125 let mut min_y = u32::MAX;
2126 let mut max_y = 0u32;
2127 let mut found = false;
2128 for y in 0..height {
2129 for x in 0..width {
2130 let idx = ((y * width + x) * 4) as usize;
2131 if pixels[idx + 3] > 0 {
2132 min_y = min_y.min(y);
2133 max_y = max_y.max(y + 1);
2134 found = true;
2135 }
2136 }
2137 }
2138 found.then_some((min_y, max_y))
2139 }
2140
2141 fn ink_centroid_x(image: &ImageBitmap, y_start: u32, y_end: u32) -> Option<f32> {
2142 let width = image.width();
2143 let height = image.height();
2144 let pixels = image.pixels();
2145 let mut weighted_x = 0.0f32;
2146 let mut total_alpha = 0.0f32;
2147
2148 for y in y_start.min(height)..y_end.min(height) {
2149 for x in 0..width {
2150 let idx = ((y * width + x) * 4) as usize;
2151 let alpha = pixels[idx + 3] as f32 / 255.0;
2152 if alpha <= 0.0 {
2153 continue;
2154 }
2155 weighted_x += x as f32 * alpha;
2156 total_alpha += alpha;
2157 }
2158 }
2159
2160 (total_alpha > 0.0).then_some(weighted_x / total_alpha)
2161 }
2162
2163 fn vertical_slant_delta(image: &ImageBitmap) -> f32 {
2164 let (top, bottom) = ink_y_range(image).expect("image should contain ink");
2165 let mid = top + (bottom - top).max(1) / 2;
2166 let top_x = ink_centroid_x(image, top, mid).expect("top ink centroid");
2167 let bottom_x = ink_centroid_x(image, mid, bottom).expect("bottom ink centroid");
2168 top_x - bottom_x
2169 }
2170
2171 fn top_ink_row(image: &ImageBitmap) -> Option<u32> {
2172 let width = image.width();
2173 let height = image.height();
2174 let pixels = image.pixels();
2175 for y in 0..height {
2176 for x in 0..width {
2177 let idx = ((y * width + x) * 4) as usize;
2178 if pixels[idx + 3] > 0 {
2179 return Some(y);
2180 }
2181 }
2182 }
2183 None
2184 }
2185
2186 fn reference_dilation_offsets(radius: i32) -> Vec<(i32, i32)> {
2187 let mut offsets = Vec::new();
2188 let squared_radius = radius * radius;
2189 for dy in -radius..=radius {
2190 for dx in -radius..=radius {
2191 if dx * dx + dy * dy <= squared_radius {
2192 offsets.push((dx, dy));
2193 }
2194 }
2195 }
2196 if offsets.is_empty() {
2197 offsets.push((0, 0));
2198 }
2199 offsets
2200 }
2201
2202 fn reference_dilation_stroke_mask(fill: &GlyphMask, stroke_width: f32) -> GlyphMask {
2203 let radius = (stroke_width * 0.5).ceil() as i32;
2204 let offsets = reference_dilation_offsets(radius);
2205 let out_width = fill.width as i32 + radius * 2;
2206 let out_height = fill.height as i32 + radius * 2;
2207 let fill_width_i32 = fill.width as i32;
2208 let fill_height_i32 = fill.height as i32;
2209 let mut alpha = vec![0.0f32; (out_width * out_height) as usize];
2210
2211 for out_y in 0..out_height {
2212 let oy = out_y - radius;
2213 for out_x in 0..out_width {
2214 let ox = out_x - radius;
2215 let base_alpha =
2216 if ox >= 0 && oy >= 0 && ox < fill_width_i32 && oy < fill_height_i32 {
2217 fill.alpha[oy as usize * fill.width + ox as usize]
2218 } else {
2219 0.0
2220 };
2221
2222 let mut dilated_alpha = 0.0f32;
2223 for (dx, dy) in &offsets {
2224 let sx = ox + dx;
2225 let sy = oy + dy;
2226 if sx < 0 || sy < 0 || sx >= fill_width_i32 || sy >= fill_height_i32 {
2227 continue;
2228 }
2229 let sample = fill.alpha[sy as usize * fill.width + sx as usize];
2230 if sample > dilated_alpha {
2231 dilated_alpha = sample;
2232 if dilated_alpha >= 0.999 {
2233 break;
2234 }
2235 }
2236 }
2237 alpha[out_y as usize * out_width as usize + out_x as usize] =
2238 (dilated_alpha - base_alpha).max(0.0);
2239 }
2240 }
2241
2242 GlyphMask {
2243 alpha,
2244 width: out_width as usize,
2245 height: out_height as usize,
2246 origin_x: fill.origin_x - radius,
2247 origin_y: fill.origin_y - radius,
2248 }
2249 }
2250
2251 fn rasterize_reference_dilation_stroke(
2252 text: &str,
2253 rect: Rect,
2254 font_size: f32,
2255 stroke_width: f32,
2256 font: &impl Font,
2257 ) -> ImageBitmap {
2258 let width = rect.width.ceil().max(1.0) as u32;
2259 let height = rect.height.ceil().max(1.0) as u32;
2260 let mut canvas = vec![[0.0f32; 4]; (width * height) as usize];
2261
2262 let metrics = vertical_metrics(font, font_size);
2263 let baseline = baseline_y_for_line_box(metrics, font_size * 1.4);
2264 for glyph in layout_line_glyphs(font, text, font_size, point(0.0, baseline)) {
2265 let Some((outlined, bounds)) = outline_glyph_with_bounds(font, &glyph) else {
2266 continue;
2267 };
2268 let Some(fill) = build_fill_mask(&outlined, bounds) else {
2269 continue;
2270 };
2271 let reference = reference_dilation_stroke_mask(&fill, stroke_width);
2272 draw_mask_glyph(
2273 &mut canvas,
2274 width,
2275 height,
2276 &reference,
2277 &Brush::solid(Color::WHITE),
2278 1.0,
2279 rect,
2280 );
2281 }
2282
2283 let mut rgba = vec![0u8; canvas.len() * 4];
2284 for (index, pixel) in canvas.iter().enumerate() {
2285 let base = index * 4;
2286 rgba[base] = (pixel[0].clamp(0.0, 1.0) * 255.0).round() as u8;
2287 rgba[base + 1] = (pixel[1].clamp(0.0, 1.0) * 255.0).round() as u8;
2288 rgba[base + 2] = (pixel[2].clamp(0.0, 1.0) * 255.0).round() as u8;
2289 rgba[base + 3] = (pixel[3].clamp(0.0, 1.0) * 255.0).round() as u8;
2290 }
2291 ImageBitmap::from_rgba8(width, height, rgba).expect("reference dilation image")
2292 }
2293
2294 fn test_font() -> ab_glyph::FontRef<'static> {
2295 ab_glyph::FontRef::try_from_slice(include_bytes!("../assets/NotoSansMerged.ttf"))
2296 .expect("font")
2297 }
2298
2299 fn test_software_font() -> SoftwareTextFont {
2300 SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansMerged.ttf").to_vec())
2301 .expect("font")
2302 }
2303
2304 #[test]
2305 fn software_text_font_rejects_invalid_bytes() {
2306 assert!(SoftwareTextFont::from_bytes(vec![0, 1, 2, 3]).is_err());
2307 }
2308
2309 #[test]
2310 fn default_software_text_font_has_no_process_global_cache() {
2311 let source = include_str!("software_text_raster.rs");
2312 let once_lock = ["Once", "Lock"].concat();
2313 let cached_default = ["static ", "FONT"].concat();
2314 let default_font_fn = ["fn ", "default_font()"].concat();
2315
2316 assert!(
2317 !source.contains(&cached_default)
2318 && !source.contains(&default_font_fn)
2319 && !source.contains(&once_lock),
2320 "default software text font construction must be explicit renderer/app-owned state, not a process-global cache"
2321 );
2322 }
2323
2324 #[test]
2325 fn software_text_measurer_empty_font_set_uses_deterministic_fallback_without_panicking() {
2326 let measurer = SoftwareTextMeasurer::from_font_set(SoftwareTextFontSet::empty(), 4);
2327 let style = TextStyle {
2328 span_style: SpanStyle {
2329 font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2330 ..Default::default()
2331 },
2332 ..Default::default()
2333 };
2334 let text = AnnotatedString::from("ab\nc");
2335
2336 let metrics = measurer.measure(&text, &style);
2337 assert_eq!(metrics.line_count, 2);
2338 assert!(metrics.width > 0.0);
2339 assert!(metrics.height >= metrics.line_height * 2.0);
2340
2341 let cursor_x = measurer.get_cursor_x_for_offset(&text, &style, 2);
2342 assert!(cursor_x > 0.0);
2343 let second_line_offset =
2344 measurer.get_offset_for_position(&text, &style, 0.0, metrics.line_height);
2345 assert!(
2346 second_line_offset >= "ab\n".len(),
2347 "fallback hit testing should resolve into the second line: {second_line_offset}"
2348 );
2349
2350 let layout = measurer.layout(&text, &style);
2351 assert_eq!(layout.lines.len(), 2);
2352 assert_eq!(layout.glyph_layouts().len(), 3);
2353 }
2354
2355 #[test]
2356 fn software_text_metrics_layout_and_cursor_share_font_backend() {
2357 let font = test_software_font();
2358 let style = TextStyle {
2359 span_style: SpanStyle {
2360 font_size: cranpose_ui::text::TextUnit::Sp(18.0),
2361 ..Default::default()
2362 },
2363 ..Default::default()
2364 };
2365 let text = "Text\nBackend";
2366
2367 let metrics = measure_text_with_font(text, &style, 18.0, &font);
2368 let layout = layout_text_with_font(text, &style, &font);
2369
2370 assert!(metrics.width > 0.0);
2371 assert_eq!(metrics.line_count, 2);
2372 assert_eq!(layout.lines.len(), 2);
2373 assert_eq!(layout.height, metrics.height);
2374 assert!(layout.glyph_layouts().len() >= "TextBackend".len());
2375
2376 let offset =
2377 text_offset_for_position_with_font(text, &style, 0.0, metrics.line_height, &font);
2378 assert!(
2379 offset >= "Text\n".len(),
2380 "second-line hit testing should return a byte offset on the second line: {offset}"
2381 );
2382 let cursor_x = cursor_x_for_offset_with_font(text, &style, "Text".len(), &font);
2383 assert!(cursor_x > 0.0);
2384 }
2385
2386 #[test]
2387 fn software_text_metrics_keep_requested_font_size_for_default_font() {
2388 let font = default_software_text_font().expect("bundled default test font");
2389 let style = TextStyle {
2390 span_style: SpanStyle {
2391 font_size: cranpose_ui::text::TextUnit::Sp(14.0),
2392 ..Default::default()
2393 },
2394 ..Default::default()
2395 };
2396
2397 let metrics = measure_text_with_font("Counter App", &style, 14.0, &font);
2398 assert!(
2399 (metrics.width - 83.16).abs() < 0.05 && (metrics.height - 19.6).abs() < 0.05,
2400 "14sp demo text must use font em metrics, not ab_glyph height units: {metrics:?}"
2401 );
2402 }
2403
2404 #[test]
2405 fn software_text_synthesizes_missing_bold_weight() {
2406 let font = test_software_font();
2407 let normal_style = TextStyle {
2408 span_style: SpanStyle {
2409 font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2410 ..Default::default()
2411 },
2412 ..Default::default()
2413 };
2414 let bold_style = TextStyle {
2415 span_style: SpanStyle {
2416 font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2417 font_weight: Some(FontWeight::BOLD),
2418 ..Default::default()
2419 },
2420 ..Default::default()
2421 };
2422 let no_synthesis_style = TextStyle {
2423 span_style: SpanStyle {
2424 font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2425 font_weight: Some(FontWeight::BOLD),
2426 font_synthesis: Some(FontSynthesis::None),
2427 ..Default::default()
2428 },
2429 ..Default::default()
2430 };
2431
2432 let normal = measure_text_with_font("Save Raster WebP", &normal_style, 20.0, &font);
2433 let synthesized = measure_text_with_font("Save Raster WebP", &bold_style, 20.0, &font);
2434 let disabled = measure_text_with_font("Save Raster WebP", &no_synthesis_style, 20.0, &font);
2435
2436 assert!(
2437 synthesized.width > normal.width * 1.04,
2438 "bold fallback should synthesize heavier advances: normal={normal:?} synthesized={synthesized:?}"
2439 );
2440 assert!(
2441 (disabled.width - normal.width).abs() < 0.01,
2442 "explicit FontSynthesis::None should preserve regular metrics: normal={normal:?} disabled={disabled:?}"
2443 );
2444 }
2445
2446 #[test]
2447 fn rasterized_synthetic_bold_adds_ink_without_changing_line_box() {
2448 let font = test_software_font();
2449 let normal_style = TextStyle {
2450 span_style: SpanStyle {
2451 font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2452 ..Default::default()
2453 },
2454 ..Default::default()
2455 };
2456 let bold_style = TextStyle {
2457 span_style: SpanStyle {
2458 font_size: cranpose_ui::text::TextUnit::Sp(20.0),
2459 font_weight: Some(FontWeight::BOLD),
2460 ..Default::default()
2461 },
2462 ..Default::default()
2463 };
2464 let normal_metrics = measure_text_with_font("Composer", &normal_style, 20.0, &font);
2465 let bold_metrics = measure_text_with_font("Composer", &bold_style, 20.0, &font);
2466
2467 let normal = rasterize_text_to_image(
2468 "Composer",
2469 Rect {
2470 x: 0.0,
2471 y: 0.0,
2472 width: normal_metrics.width.ceil(),
2473 height: normal_metrics.height.ceil(),
2474 },
2475 &normal_style,
2476 Color::WHITE,
2477 20.0,
2478 1.0,
2479 &font,
2480 )
2481 .expect("normal text image");
2482 let bold = rasterize_text_to_image(
2483 "Composer",
2484 Rect {
2485 x: 0.0,
2486 y: 0.0,
2487 width: bold_metrics.width.ceil(),
2488 height: bold_metrics.height.ceil(),
2489 },
2490 &bold_style,
2491 Color::WHITE,
2492 20.0,
2493 1.0,
2494 &font,
2495 )
2496 .expect("bold text image");
2497
2498 assert_eq!(bold.height(), normal.height());
2499 assert!(
2500 count_ink_pixels(&bold) > count_ink_pixels(&normal),
2501 "synthetic bold should increase rasterized ink coverage"
2502 );
2503 }
2504
2505 #[test]
2506 fn software_text_synthesizes_missing_italic_style() {
2507 let font = test_software_font();
2508 let normal_style = TextStyle {
2509 span_style: SpanStyle {
2510 font_size: cranpose_ui::text::TextUnit::Sp(36.0),
2511 ..Default::default()
2512 },
2513 ..Default::default()
2514 };
2515 let italic_style = TextStyle {
2516 span_style: SpanStyle {
2517 font_size: cranpose_ui::text::TextUnit::Sp(36.0),
2518 font_style: Some(FontStyle::Italic),
2519 ..Default::default()
2520 },
2521 ..Default::default()
2522 };
2523 let no_synthesis_style = TextStyle {
2524 span_style: SpanStyle {
2525 font_size: cranpose_ui::text::TextUnit::Sp(36.0),
2526 font_style: Some(FontStyle::Italic),
2527 font_synthesis: Some(FontSynthesis::None),
2528 ..Default::default()
2529 },
2530 ..Default::default()
2531 };
2532
2533 let normal_metrics = measure_text_with_font("Italic", &normal_style, 36.0, &font);
2534 let italic_metrics = measure_text_with_font("Italic", &italic_style, 36.0, &font);
2535 let disabled_metrics = measure_text_with_font("Italic", &no_synthesis_style, 36.0, &font);
2536
2537 assert!(
2538 italic_metrics.width > normal_metrics.width + 6.0,
2539 "italic fallback should reserve slanted visual overhang: normal={normal_metrics:?} italic={italic_metrics:?}"
2540 );
2541 assert!(
2542 (disabled_metrics.width - normal_metrics.width).abs() < 0.01,
2543 "explicit FontSynthesis::None should preserve regular metrics: normal={normal_metrics:?} disabled={disabled_metrics:?}"
2544 );
2545
2546 let normal = rasterize_text_to_image(
2547 "Italic",
2548 Rect {
2549 x: 0.0,
2550 y: 0.0,
2551 width: normal_metrics.width.ceil(),
2552 height: normal_metrics.height.ceil(),
2553 },
2554 &normal_style,
2555 Color::WHITE,
2556 36.0,
2557 1.0,
2558 &font,
2559 )
2560 .expect("normal text image");
2561 let italic = rasterize_text_to_image(
2562 "Italic",
2563 Rect {
2564 x: 0.0,
2565 y: 0.0,
2566 width: italic_metrics.width.ceil(),
2567 height: italic_metrics.height.ceil(),
2568 },
2569 &italic_style,
2570 Color::WHITE,
2571 36.0,
2572 1.0,
2573 &font,
2574 )
2575 .expect("italic text image");
2576 let disabled = rasterize_text_to_image(
2577 "Italic",
2578 Rect {
2579 x: 0.0,
2580 y: 0.0,
2581 width: disabled_metrics.width.ceil(),
2582 height: disabled_metrics.height.ceil(),
2583 },
2584 &no_synthesis_style,
2585 Color::WHITE,
2586 36.0,
2587 1.0,
2588 &font,
2589 )
2590 .expect("disabled italic text image");
2591
2592 assert_eq!(
2593 normal.pixels(),
2594 disabled.pixels(),
2595 "FontSynthesis::None must not synthesize oblique glyphs"
2596 );
2597 assert!(
2598 vertical_slant_delta(&italic) > vertical_slant_delta(&normal) + 2.0,
2599 "synthetic italic should visibly lean top ink to the right"
2600 );
2601 }
2602
2603 #[test]
2604 fn rasterized_default_text_fills_expected_visual_height() {
2605 let font = default_software_text_font().expect("bundled default test font");
2606 let style = TextStyle {
2607 span_style: SpanStyle {
2608 font_size: cranpose_ui::text::TextUnit::Sp(14.0),
2609 ..Default::default()
2610 },
2611 ..Default::default()
2612 };
2613 let metrics = measure_text_with_font("Counter App", &style, 14.0, &font);
2614 let image = rasterize_text_to_image(
2615 "Counter App",
2616 Rect {
2617 x: 0.0,
2618 y: 0.0,
2619 width: metrics.width.ceil(),
2620 height: metrics.height.ceil(),
2621 },
2622 &style,
2623 Color::WHITE,
2624 14.0,
2625 1.0,
2626 &font,
2627 )
2628 .expect("text image");
2629 let (top, bottom) = ink_y_range(&image).expect("text should contain ink");
2630 let ink_height = bottom - top;
2631
2632 assert!(
2633 ink_height >= 13,
2634 "14sp default text ink should keep visual height parity with the WGPU baseline: top={top} bottom={bottom} image={}x{}",
2635 image.width(),
2636 image.height()
2637 );
2638 }
2639
2640 #[test]
2641 fn software_text_font_selection_preserves_first_complete_default_face() {
2642 let regular =
2643 SoftwareTextFont::from_bytes(include_bytes!("../assets/NotoSansMerged.ttf").to_vec())
2644 .expect("regular test font should load");
2645 let font = software_text_font_from_fonts_or_default(&[
2646 include_bytes!("../assets/NotoSansMerged.ttf"),
2647 include_bytes!("../assets/NotoSansBold.ttf"),
2648 include_bytes!("../assets/TwemojiMozilla.ttf"),
2649 ])
2650 .expect("font selection should resolve a test font");
2651 let style = TextStyle {
2652 span_style: SpanStyle {
2653 font_size: cranpose_ui::text::TextUnit::Sp(18.0),
2654 ..Default::default()
2655 },
2656 ..Default::default()
2657 };
2658
2659 let regular_metrics = measure_text_with_font("UNDER", &style, 18.0, ®ular);
2660 let metrics = measure_text_with_font("UNDER", &style, 18.0, &font);
2661 assert!(
2662 (metrics.width - regular_metrics.width).abs() < 0.01,
2663 "font selection should keep the declared regular face for default text: selected={metrics:?}, regular={regular_metrics:?}"
2664 );
2665 }
2666
2667 #[test]
2668 fn software_text_font_resolution_reuses_cached_font_score() {
2669 let font = test_software_font();
2670 assert!(
2671 font.score.is_complete_default_face(),
2672 "test font should cache complete Latin coverage at load time: supported={} width={}",
2673 font.score.supported_latin_chars,
2674 font.score.latin_sample_width
2675 );
2676
2677 let fonts = SoftwareTextFontSet::from_font(font.clone());
2678 let resolved = fonts
2679 .resolve(&TextStyle {
2680 span_style: SpanStyle {
2681 font_weight: Some(FontWeight::BOLD),
2682 ..Default::default()
2683 },
2684 ..Default::default()
2685 })
2686 .expect("font set should resolve a test font");
2687
2688 assert_eq!(
2689 resolved.score.supported_latin_chars,
2690 font.score.supported_latin_chars
2691 );
2692 assert_eq!(
2693 resolved.score.latin_sample_width,
2694 font.score.latin_sample_width
2695 );
2696 }
2697
2698 #[test]
2699 fn software_text_font_set_resolves_requested_weight() {
2700 let fonts = software_text_font_set_from_fonts_or_default(&[
2701 include_bytes!("../assets/NotoSansMerged.ttf"),
2702 include_bytes!("../assets/NotoSansBold.ttf"),
2703 include_bytes!("../assets/TwemojiMozilla.ttf"),
2704 ]);
2705 let regular = fonts
2706 .resolve(&TextStyle::default())
2707 .expect("font set should resolve regular test font");
2708 let bold_style = TextStyle {
2709 span_style: SpanStyle {
2710 font_weight: Some(FontWeight::BOLD),
2711 ..Default::default()
2712 },
2713 ..Default::default()
2714 };
2715 let bold = fonts
2716 .resolve(&bold_style)
2717 .expect("font set should resolve bold test font");
2718
2719 assert_eq!(regular.weight(), FontWeight::NORMAL);
2720 assert_eq!(bold.weight(), FontWeight::BOLD);
2721
2722 let regular_metrics =
2723 measure_text_with_font("Counter App", &TextStyle::default(), 18.0, regular);
2724 let bold_metrics = measure_text_with_font("Counter App", &bold_style, 18.0, bold);
2725 assert!(
2726 bold_metrics.width > regular_metrics.width,
2727 "bold face resolution should affect real text metrics: regular={regular_metrics:?} bold={bold_metrics:?}"
2728 );
2729 }
2730
2731 #[test]
2732 fn software_text_metrics_use_largest_annotated_span_font_size() {
2733 let font = default_software_text_font().expect("bundled default test font");
2734 let text = AnnotatedString::builder()
2735 .push_style(SpanStyle {
2736 font_size: cranpose_ui::text::TextUnit::Sp(30.0),
2737 ..Default::default()
2738 })
2739 .append("BIG ")
2740 .pop()
2741 .push_style(SpanStyle {
2742 font_size: cranpose_ui::text::TextUnit::Sp(10.0),
2743 ..Default::default()
2744 })
2745 .append("small")
2746 .pop()
2747 .to_annotated_string();
2748
2749 let metrics = measure_annotated_text_with_font(&text, &TextStyle::default(), 14.0, &font);
2750
2751 assert!(
2752 metrics.height >= 30.0,
2753 "rich text metrics must include the largest span height: {metrics:?}"
2754 );
2755 assert!(
2756 metrics.width > 48.0,
2757 "rich text metrics should measure run widths at their span sizes: {metrics:?}"
2758 );
2759 }
2760
2761 #[test]
2762 fn software_text_metrics_cache_keys_include_span_styles() {
2763 let measurer = SoftwareTextMeasurer::new(
2764 default_software_text_font().expect("bundled default test font"),
2765 8,
2766 );
2767 let plain = AnnotatedString::from("BIG small");
2768 let rich = AnnotatedString::builder()
2769 .push_style(SpanStyle {
2770 font_size: cranpose_ui::text::TextUnit::Sp(30.0),
2771 ..Default::default()
2772 })
2773 .append("BIG ")
2774 .pop()
2775 .append("small")
2776 .to_annotated_string();
2777
2778 let plain_metrics = measurer.measure(&plain, &TextStyle::default());
2779 let rich_metrics = measurer.measure(&rich, &TextStyle::default());
2780
2781 assert!(
2782 rich_metrics.height > plain_metrics.height,
2783 "cached plain text metrics must not be reused for styled text: plain={plain_metrics:?} rich={rich_metrics:?}"
2784 );
2785 }
2786
2787 #[test]
2788 fn software_text_metrics_cache_recovers_after_poison() {
2789 let measurer = SoftwareTextMeasurer::new(
2790 default_software_text_font().expect("bundled default test font"),
2791 8,
2792 );
2793 let text = AnnotatedString::from("Recovered text metrics");
2794
2795 let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2796 let _guard = measurer
2797 .cache
2798 .lock()
2799 .unwrap_or_else(|poisoned| poisoned.into_inner());
2800 panic!("poison software text metrics cache for recovery test");
2801 }));
2802
2803 assert!(poison_result.is_err());
2804
2805 let metrics = measurer.measure(&text, &TextStyle::default());
2806 assert!(metrics.width > 0.0);
2807 assert!(metrics.height > 0.0);
2808
2809 let subset =
2810 measurer.measure_subsequence(&text, 0.."Recovered".len(), &TextStyle::default());
2811 assert!(subset.width > 0.0);
2812 assert!(subset.width < metrics.width);
2813 }
2814
2815 #[test]
2816 fn rasterized_gradient_text_shows_color_transition() {
2817 let font = test_font();
2818 let plain_style = TextStyle::default();
2821 let probe = rasterize_text_to_image_with_font(
2822 "MMMMMMMM",
2823 Rect {
2824 x: 0.0,
2825 y: 0.0,
2826 width: 320.0,
2827 height: 96.0,
2828 },
2829 &plain_style,
2830 Color::WHITE,
2831 48.0,
2832 1.0,
2833 &font,
2834 )
2835 .expect("probe image");
2836 let (ink_x_min, ink_x_max) = ink_x_range(&probe).expect("probe must contain ink");
2837 let gradient_end = ink_x_max as f32;
2838
2839 let style = TextStyle {
2840 span_style: SpanStyle {
2841 brush: Some(Brush::linear_gradient_range(
2842 vec![Color::RED, Color::BLUE],
2843 Point::new(0.0, 0.0),
2844 Point::new(gradient_end, 0.0),
2845 )),
2846 ..Default::default()
2847 },
2848 ..Default::default()
2849 };
2850
2851 let image = rasterize_text_to_image_with_font(
2852 "MMMMMMMM",
2853 Rect {
2854 x: 0.0,
2855 y: 0.0,
2856 width: 320.0,
2857 height: 96.0,
2858 },
2859 &style,
2860 Color::WHITE,
2861 48.0,
2862 1.0,
2863 &font,
2864 )
2865 .expect("rasterized image");
2866
2867 let ink_span = ink_x_max.saturating_sub(ink_x_min).max(1);
2868 let left_end = ink_x_min + ink_span * 3 / 10;
2869 let right_start = ink_x_max.saturating_sub(ink_span * 3 / 10);
2870 let left = average_ink_rgb(&image, ink_x_min, left_end, 8, 90).expect("left ink");
2871 let right = average_ink_rgb(&image, right_start, ink_x_max, 8, 90).expect("right ink");
2872 assert!(
2873 left[0] > left[2] * 1.1,
2874 "left region should be red dominant, got {left:?}"
2875 );
2876 assert!(
2877 right[2] > right[0] * 1.1,
2878 "right region should be blue dominant, got {right:?}"
2879 );
2880 }
2881
2882 #[test]
2883 fn rasterized_stroke_and_fill_ink_coverage_differs() {
2884 let font = test_font();
2885 let fill_style = TextStyle::default();
2886 let stroke_style = TextStyle {
2887 span_style: SpanStyle {
2888 draw_style: Some(TextDrawStyle::Stroke { width: 6.0 }),
2889 ..Default::default()
2890 },
2891 ..Default::default()
2892 };
2893 let rect = Rect {
2894 x: 0.0,
2895 y: 0.0,
2896 width: 320.0,
2897 height: 96.0,
2898 };
2899
2900 let fill = rasterize_text_to_image_with_font(
2901 "MMMMMMMM",
2902 rect,
2903 &fill_style,
2904 Color::WHITE,
2905 48.0,
2906 1.0,
2907 &font,
2908 )
2909 .expect("fill image");
2910 let stroke = rasterize_text_to_image_with_font(
2911 "MMMMMMMM",
2912 rect,
2913 &stroke_style,
2914 Color::WHITE,
2915 48.0,
2916 1.0,
2917 &font,
2918 )
2919 .expect("stroke image");
2920
2921 let fill_ink = count_ink_pixels(&fill);
2922 let stroke_ink = count_ink_pixels(&stroke);
2923 assert_ne!(fill.pixels(), stroke.pixels());
2924 assert!(
2925 fill_ink.abs_diff(stroke_ink) > 300,
2926 "fill/stroke ink coverage should differ; fill={fill_ink}, stroke={stroke_ink}"
2927 );
2928 }
2929
2930 #[test]
2931 fn stroke_path_uses_miter_join_for_acute_apexes() {
2932 let font = test_font();
2933 let fill_style = TextStyle::default();
2934 let stroke_width = 12.0;
2935 let stroke_style = TextStyle {
2936 span_style: SpanStyle {
2937 draw_style: Some(TextDrawStyle::Stroke {
2938 width: stroke_width,
2939 }),
2940 ..Default::default()
2941 },
2942 ..Default::default()
2943 };
2944 let rect = Rect {
2945 x: 0.0,
2946 y: 0.0,
2947 width: 180.0,
2948 height: 140.0,
2949 };
2950
2951 let fill = rasterize_text_to_image_with_font(
2952 "A",
2953 rect,
2954 &fill_style,
2955 Color::WHITE,
2956 110.0,
2957 1.0,
2958 &font,
2959 )
2960 .expect("fill image");
2961 let stroke = rasterize_text_to_image_with_font(
2962 "A",
2963 rect,
2964 &stroke_style,
2965 Color::WHITE,
2966 110.0,
2967 1.0,
2968 &font,
2969 )
2970 .expect("stroke image");
2971
2972 let fill_top = top_ink_row(&fill).expect("fill top row");
2973 let stroke_top = top_ink_row(&stroke).expect("stroke top row");
2974 let reference_dilation =
2975 rasterize_reference_dilation_stroke("A", rect, 110.0, stroke_width, &font);
2976 let reference_top = top_ink_row(&reference_dilation).expect("reference top row");
2977 let extra_extension = fill_top.saturating_sub(stroke_top) as f32;
2978 let half_stroke = stroke_width * 0.5;
2979 assert!(
2980 extra_extension >= half_stroke - 0.25,
2981 "stroke apex should extend by roughly at least half stroke width; fill_top={fill_top}, stroke_top={stroke_top}, half_stroke={half_stroke:.2}"
2982 );
2983 assert!(
2984 stroke.pixels() != reference_dilation.pixels(),
2985 "path stroke should diverge from mask-dilation reference output"
2986 );
2987 assert!(
2988 stroke_top <= reference_top,
2989 "miter stroke should keep acute apex at least as extended as mask-dilation reference; stroke_top={stroke_top}, reference_top={reference_top}"
2990 );
2991 }
2992
2993 #[test]
2994 fn shadow_blur_radius_changes_spread_for_shared_raster_path() {
2995 let font = test_font();
2996 let base_shadow = Shadow {
2997 color: Color(0.0, 0.0, 0.0, 0.9),
2998 offset: Point::new(5.5, 4.25),
2999 blur_radius: 0.0,
3000 };
3001 let hard_shadow_style = TextStyle {
3002 span_style: SpanStyle {
3003 shadow: Some(base_shadow),
3004 ..Default::default()
3005 },
3006 ..Default::default()
3007 };
3008 let blurred_shadow_style = TextStyle {
3009 span_style: SpanStyle {
3010 shadow: Some(Shadow {
3011 blur_radius: 9.0,
3012 ..base_shadow
3013 }),
3014 ..Default::default()
3015 },
3016 ..Default::default()
3017 };
3018 let rect = Rect {
3019 x: 0.0,
3020 y: 0.0,
3021 width: 320.0,
3022 height: 120.0,
3023 };
3024
3025 let hard_shadow = rasterize_text_to_image_with_font(
3026 "Shared shadow",
3027 rect,
3028 &hard_shadow_style,
3029 Color::TRANSPARENT,
3030 48.0,
3031 1.0,
3032 &font,
3033 )
3034 .expect("hard shadow image");
3035 let blurred_shadow = rasterize_text_to_image_with_font(
3036 "Shared shadow",
3037 rect,
3038 &blurred_shadow_style,
3039 Color::TRANSPARENT,
3040 48.0,
3041 1.0,
3042 &font,
3043 )
3044 .expect("blurred shadow image");
3045
3046 let hard_ink = count_ink_pixels(&hard_shadow);
3047 let blurred_ink = count_ink_pixels(&blurred_shadow);
3048 assert_ne!(
3049 hard_shadow.pixels(),
3050 blurred_shadow.pixels(),
3051 "blur radius should change rasterized shadow output"
3052 );
3053 assert!(
3054 blurred_ink > hard_ink,
3055 "blurred shadow should spread to more pixels; hard={hard_ink}, blurred={blurred_ink}"
3056 );
3057 }
3058
3059 #[test]
3060 fn text_motion_changes_fractional_shadow_sampling() {
3061 let font = test_font();
3062 let base_shadow = Shadow {
3063 color: Color(0.0, 0.0, 0.0, 0.9),
3064 offset: Point::new(3.35, 2.65),
3065 blur_radius: 6.0,
3066 };
3067 let static_style = TextStyle {
3068 span_style: SpanStyle {
3069 shadow: Some(base_shadow),
3070 ..Default::default()
3071 },
3072 paragraph_style: cranpose_ui::text::ParagraphStyle {
3073 text_motion: Some(TextMotion::Static),
3074 ..Default::default()
3075 },
3076 };
3077 let animated_style = TextStyle {
3078 span_style: SpanStyle {
3079 shadow: Some(base_shadow),
3080 ..Default::default()
3081 },
3082 paragraph_style: cranpose_ui::text::ParagraphStyle {
3083 text_motion: Some(TextMotion::Animated),
3084 ..Default::default()
3085 },
3086 };
3087 let rect = Rect {
3088 x: 11.35,
3089 y: 7.65,
3090 width: 280.0,
3091 height: 120.0,
3092 };
3093
3094 let static_image = rasterize_text_to_image_with_font(
3095 "Motion shadow",
3096 rect,
3097 &static_style,
3098 Color::TRANSPARENT,
3099 42.0,
3100 1.0,
3101 &font,
3102 )
3103 .expect("static image");
3104 let animated_image = rasterize_text_to_image_with_font(
3105 "Motion shadow",
3106 rect,
3107 &animated_style,
3108 Color::TRANSPARENT,
3109 42.0,
3110 1.0,
3111 &font,
3112 )
3113 .expect("animated image");
3114
3115 assert_ne!(
3116 static_image.pixels(),
3117 animated_image.pixels(),
3118 "TextMotion::Static should quantize shadow placement while Animated keeps fractional sampling"
3119 );
3120 }
3121
3122 #[test]
3123 fn static_text_motion_aligns_glyph_positions_to_pixel_grid() {
3124 let font = test_font();
3125 let base_glyph = layout_line_glyphs(&font, "A", 17.0, point(0.0, 13.37))
3126 .into_iter()
3127 .next()
3128 .expect("glyph");
3129 let static_aligned = align_glyph_for_text_motion(base_glyph, true);
3130 let static_position = static_aligned.position;
3131 assert!(
3132 (static_position.x - static_position.x.round()).abs() < f32::EPSILON,
3133 "static text should snap glyph x to pixel grid"
3134 );
3135 assert!(
3136 (static_position.y - static_position.y.round()).abs() < f32::EPSILON,
3137 "static text should snap glyph y to pixel grid"
3138 );
3139
3140 let animated_source = layout_line_glyphs(&font, "A", 17.0, point(0.0, 13.37))
3141 .into_iter()
3142 .next()
3143 .expect("glyph");
3144 let animated_aligned = align_glyph_for_text_motion(animated_source, false);
3145 let animated_position = animated_aligned.position;
3146 assert!(
3147 (animated_position.y - 13.37).abs() < 1e-3,
3148 "animated text should preserve fractional glyph position"
3149 );
3150 }
3151}