1use fret_core::text::TextLeadingDistribution;
2use fret_core::{
3 FontId, TextInputRef, TextLineHeightPolicy, TextShapingStyle, TextSlant, TextSpan, TextStyle,
4};
5use parley::FontContext;
6use parley::Layout;
7use parley::LayoutContext;
8use parley::fontique::{FamilyId, GenericFamily};
9use parley::style::{
10 FontFeature, FontSettings, FontStyle, FontVariation, FontWeight as ParleyFontWeight,
11 OverflowWrap, StyleProperty, TextStyle as ParleyTextStyle, TextWrapMode, WordBreakStrength,
12};
13use std::borrow::Cow;
14use std::ops::Range;
15use std::sync::Arc;
16
17use crate::FontCatalogEntryMetadata;
18use crate::parley_font_db::ParleyFontDbState;
19pub use crate::parley_font_db::ParleyShaperFontDbDiagnosticsSnapshot;
20
21#[derive(Debug, Clone, Copy)]
22pub struct FontEnvironmentBlobRef<'a> {
23 hash: u64,
24 bytes: &'a [u8],
25}
26
27impl<'a> FontEnvironmentBlobRef<'a> {
28 pub(crate) fn new(hash: u64, bytes: &'a [u8]) -> Self {
29 Self { hash, bytes }
30 }
31
32 pub fn hash(&self) -> u64 {
33 self.hash
34 }
35
36 pub fn len(&self) -> u64 {
37 self.bytes.len() as u64
38 }
39
40 pub fn is_empty(&self) -> bool {
41 self.bytes.is_empty()
42 }
43
44 pub fn bytes(&self) -> &'a [u8] {
45 self.bytes
46 }
47}
48
49fn env_disables_system_fonts() -> bool {
50 let Ok(raw) = std::env::var("FRET_TEXT_SYSTEM_FONTS") else {
51 return false;
52 };
53 let v = raw.trim().to_ascii_lowercase();
54 matches!(v.as_str(), "0" | "false" | "no" | "off")
55}
56
57fn min_line_height_for_metrics(ascent: f32, descent: f32) -> f32 {
58 let ascent = normalize_ascent(ascent);
59 let descent_mag = if descent.is_sign_negative() {
60 (-descent).max(0.0)
61 } else {
62 descent.max(0.0)
63 };
64 ascent + descent_mag
65}
66
67fn normalize_ascent(ascent: f32) -> f32 {
68 if ascent.is_sign_negative() {
69 (-ascent).max(0.0)
70 } else {
71 ascent.max(0.0)
72 }
73}
74
75fn normalize_descent(descent: f32) -> f32 {
76 if descent.is_sign_negative() {
77 (-descent).max(0.0)
78 } else {
79 descent.max(0.0)
80 }
81}
82
83fn requested_line_height_logical_px(style: &TextStyle) -> Option<f32> {
84 if let Some(px) = style.line_height {
85 return Some(px.0.max(0.0));
86 }
87 let em = style.line_height_em?;
88 if !em.is_finite() || em <= 0.0 {
89 return None;
90 }
91 Some((style.size.0 * em).max(0.0))
92}
93
94fn requested_line_height_logical_px_with_strut(style: &TextStyle) -> Option<f32> {
95 if let Some(px) = requested_line_height_logical_px(style) {
96 return Some(px);
97 }
98
99 let strut = style.strut_style.as_ref()?;
100
101 if let Some(px) = strut.line_height {
102 return Some(px.0.max(0.0));
103 }
104
105 let em = strut.line_height_em?;
106 if !em.is_finite() || em <= 0.0 {
107 return None;
108 }
109
110 let size = strut.size.unwrap_or(style.size).0;
111 Some((size * em).max(0.0))
112}
113
114fn leading_distribution_top_factor(
115 dist: TextLeadingDistribution,
116 ascent_px: f32,
117 descent_px: f32,
118) -> f32 {
119 match dist {
120 TextLeadingDistribution::Even => 0.5,
121 TextLeadingDistribution::Proportional => {
122 let ascent_px = normalize_ascent(ascent_px);
123 let descent_px = normalize_descent(descent_px);
124 let total = ascent_px + descent_px;
125 if total > 0.0 {
126 (ascent_px / total).clamp(0.0, 1.0)
127 } else {
128 0.5
129 }
130 }
131 }
132}
133
134fn baseline_for_fixed_line_box(
135 ascent_px: f32,
136 descent_px: f32,
137 line_height_px: f32,
138 dist: TextLeadingDistribution,
139) -> f32 {
140 let ascent_px = normalize_ascent(ascent_px);
141 let descent_px = normalize_descent(descent_px);
142 let line_height_px = line_height_px.max(0.0);
143 let extra_leading_px = (line_height_px - ascent_px - descent_px).max(0.0);
144 let padding_top_px =
145 extra_leading_px * leading_distribution_top_factor(dist, ascent_px, descent_px);
146 (padding_top_px + ascent_px).clamp(0.0, line_height_px.max(0.0))
147}
148
149fn effective_leading_distribution(style: &TextStyle) -> TextLeadingDistribution {
150 style
151 .strut_style
152 .as_ref()
153 .and_then(|s| s.leading_distribution)
154 .unwrap_or(style.leading_distribution)
155}
156
157fn style_for_strut_metrics(style: &TextStyle) -> Option<TextStyle> {
158 let strut = style.strut_style.as_ref()?;
159 if strut.font.is_none() && strut.size.is_none() {
160 return None;
161 }
162
163 let mut out = style.clone();
164 if let Some(font) = strut.font.clone() {
165 out.font = font;
166 }
167 if let Some(size) = strut.size {
168 out.size = size;
169 }
170 Some(out)
171}
172
173#[derive(Debug, Clone, PartialEq)]
174pub struct ParleyGlyph {
175 id: u32,
176 x: f32,
177 y: f32,
178 advance: f32,
179 font: GlyphFontData,
180 font_size: f32,
181 normalized_coords: Arc<[i16]>,
182 synthesis: FontSynthesis,
183 text_range: Range<usize>,
184 is_rtl: bool,
185}
186
187impl ParleyGlyph {
188 pub fn id(&self) -> u32 {
189 self.id
190 }
191
192 pub fn advance(&self) -> f32 {
193 self.advance
194 }
195
196 pub fn x(&self) -> f32 {
197 self.x
198 }
199
200 pub fn y(&self) -> f32 {
201 self.y
202 }
203
204 pub fn font(&self) -> &GlyphFontData {
205 &self.font
206 }
207
208 pub fn font_size(&self) -> f32 {
209 self.font_size
210 }
211
212 pub fn normalized_coords(&self) -> &Arc<[i16]> {
213 &self.normalized_coords
214 }
215
216 pub fn synthesis(&self) -> FontSynthesis {
217 self.synthesis
218 }
219
220 pub fn text_range(&self) -> Range<usize> {
221 self.text_range.clone()
222 }
223
224 pub fn is_rtl(&self) -> bool {
225 self.is_rtl
226 }
227
228 pub(crate) fn set_is_rtl(&mut self, is_rtl: bool) {
229 self.is_rtl = is_rtl;
230 }
231
232 pub(crate) fn set_text_range(&mut self, text_range: Range<usize>) {
233 self.text_range = text_range;
234 }
235
236 pub(crate) fn set_x(&mut self, x: f32) {
237 self.x = x;
238 }
239
240 pub(crate) fn set_y(&mut self, y: f32) {
241 self.y = y;
242 }
243}
244
245#[derive(Debug, Clone, PartialEq)]
246pub struct GlyphFontData {
247 inner: parley::FontData,
248}
249
250impl GlyphFontData {
251 fn from_parley(inner: parley::FontData) -> Self {
252 Self { inner }
253 }
254
255 pub fn data_id(&self) -> u64 {
256 self.inner.data.id()
257 }
258
259 pub fn face_index(&self) -> u32 {
260 self.inner.index
261 }
262
263 pub fn bytes(&self) -> &[u8] {
264 self.inner.data.data()
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
269pub struct FontSynthesis {
270 embolden: bool,
271 skew_degrees: i8,
273}
274
275impl FontSynthesis {
276 pub fn new(embolden: bool, skew_degrees: i8) -> Self {
277 Self {
278 embolden,
279 skew_degrees,
280 }
281 }
282
283 pub fn embolden(&self) -> bool {
284 self.embolden
285 }
286
287 pub fn skew_degrees(&self) -> i8 {
288 self.skew_degrees
289 }
290
291 fn from_parley(synthesis: parley::fontique::Synthesis) -> Self {
292 Self::new(
293 synthesis.embolden(),
294 synthesis
295 .skew()
296 .unwrap_or(0.0)
297 .clamp(i8::MIN as f32, i8::MAX as f32) as i8,
298 )
299 }
300}
301
302#[derive(Debug, Clone, PartialEq)]
303pub struct ShapedCluster {
304 text_range: Range<usize>,
305 x0: f32,
306 x1: f32,
307 is_rtl: bool,
308}
309
310impl ShapedCluster {
311 pub(crate) fn new(text_range: Range<usize>, x0: f32, x1: f32, is_rtl: bool) -> Self {
312 Self {
313 text_range,
314 x0,
315 x1,
316 is_rtl,
317 }
318 }
319
320 pub fn text_range(&self) -> Range<usize> {
321 self.text_range.clone()
322 }
323
324 pub fn x0(&self) -> f32 {
325 self.x0
326 }
327
328 pub fn x1(&self) -> f32 {
329 self.x1
330 }
331
332 pub fn is_rtl(&self) -> bool {
333 self.is_rtl
334 }
335}
336
337#[derive(Debug, Clone, PartialEq)]
338pub struct ShapedLineLayout {
339 width: f32,
340 ascent: f32,
341 descent: f32,
342 ink_ascent: f32,
343 ink_descent: f32,
344 baseline: f32,
345 line_height: f32,
346 glyphs: Vec<ParleyGlyph>,
347 clusters: Vec<ShapedCluster>,
348}
349
350impl ShapedLineLayout {
351 #[allow(clippy::too_many_arguments)]
352 pub(crate) fn new(
353 width: f32,
354 ascent: f32,
355 descent: f32,
356 ink_ascent: f32,
357 ink_descent: f32,
358 baseline: f32,
359 line_height: f32,
360 glyphs: Vec<ParleyGlyph>,
361 clusters: Vec<ShapedCluster>,
362 ) -> Self {
363 Self {
364 width,
365 ascent,
366 descent,
367 ink_ascent,
368 ink_descent,
369 baseline,
370 line_height,
371 glyphs,
372 clusters,
373 }
374 }
375
376 pub fn clusters(&self) -> &[ShapedCluster] {
377 &self.clusters
378 }
379
380 pub fn take_clusters(&mut self) -> Vec<ShapedCluster> {
381 std::mem::take(&mut self.clusters)
382 }
383
384 pub fn width(&self) -> f32 {
385 self.width
386 }
387
388 pub(crate) fn set_width(&mut self, width: f32) {
389 self.width = width;
390 }
391
392 pub fn ascent(&self) -> f32 {
393 self.ascent
394 }
395
396 pub fn descent(&self) -> f32 {
397 self.descent
398 }
399
400 pub fn ink_ascent(&self) -> f32 {
401 self.ink_ascent
402 }
403
404 pub fn ink_descent(&self) -> f32 {
405 self.ink_descent
406 }
407
408 pub fn baseline(&self) -> f32 {
409 self.baseline
410 }
411
412 pub fn line_height(&self) -> f32 {
413 self.line_height
414 }
415
416 pub fn glyphs(&self) -> &[ParleyGlyph] {
417 &self.glyphs
418 }
419
420 pub fn glyphs_mut(&mut self) -> &mut [ParleyGlyph] {
421 &mut self.glyphs
422 }
423
424 pub fn take_glyphs(&mut self) -> Vec<ParleyGlyph> {
425 std::mem::take(&mut self.glyphs)
426 }
427}
428
429#[derive(Default)]
430pub struct ParleyShaper {
431 fcx: FontContext,
432 lcx: LayoutContext<[u8; 4]>,
433 layout: Layout<[u8; 4]>,
434 default_locale: Option<String>,
435 common_fallback_stack_suffix: String,
436 font_db: ParleyFontDbState,
437}
438
439impl ParleyShaper {
440 pub fn font_db_diagnostics_snapshot(&self) -> ParleyShaperFontDbDiagnosticsSnapshot {
441 self.font_db.diagnostics_snapshot()
442 }
443}
444
445impl ParleyShaper {
446 pub fn new() -> Self {
447 let mut out = Self::default();
448 if env_disables_system_fonts() {
449 out.disable_system_fonts();
450 }
451 out
452 }
453
454 #[cfg(test)]
455 fn record_registered_font_blob_bytes_for_tests(&mut self, bytes: Vec<u8>) {
456 self.font_db
457 .record_registered_font_blob_bytes_for_tests(bytes);
458 }
459
460 #[cfg(test)]
461 fn registered_font_blob_lengths_for_tests(&self) -> Vec<usize> {
462 self.font_db.registered_font_blob_lengths_for_tests()
463 }
464
465 #[cfg(test)]
466 fn registered_font_blob_total_bytes_for_tests(&self) -> usize {
467 self.font_db.registered_font_blob_total_bytes_for_tests()
468 }
469
470 pub fn system_fonts_enabled(&self) -> bool {
471 self.font_db.system_fonts_enabled()
472 }
473
474 pub fn set_default_locale(&mut self, locale_bcp47: Option<String>) -> bool {
475 if self.default_locale == locale_bcp47 {
476 return false;
477 }
478 self.default_locale = locale_bcp47;
479 true
480 }
481
482 pub fn set_common_fallback_stack_suffix(&mut self, suffix: String) -> bool {
483 if self.common_fallback_stack_suffix == suffix {
484 return false;
485 }
486 self.common_fallback_stack_suffix = suffix;
487 true
488 }
489
490 pub fn common_fallback_stack_suffix(&self) -> &str {
491 &self.common_fallback_stack_suffix
492 }
493
494 fn disable_system_fonts(&mut self) {
495 self.fcx.collection =
496 parley::fontique::Collection::new(parley::fontique::CollectionOptions {
497 shared: false,
498 system_fonts: false,
499 });
500 self.fcx.source_cache = parley::fontique::SourceCache::default();
501 self.font_db.disable_system_fonts();
502 }
503
504 #[doc(hidden)]
505 pub fn new_without_system_fonts() -> Self {
506 let mut out = Self::default();
507 out.disable_system_fonts();
508 out
509 }
510
511 pub fn all_font_names(&mut self) -> Vec<String> {
512 self.font_db.all_font_names(&mut self.fcx)
513 }
514
515 pub fn all_font_catalog_entries(&mut self) -> Vec<FontCatalogEntryMetadata> {
516 self.font_db.all_font_catalog_entries(&mut self.fcx)
517 }
518
519 pub fn resolve_family_id(&mut self, name: &str) -> Option<FamilyId> {
520 self.font_db.resolve_family_id(&mut self.fcx, name)
521 }
522
523 pub fn family_name_for_id(&mut self, id: FamilyId) -> Option<String> {
524 self.font_db.family_name_for_id(&mut self.fcx, id)
525 }
526
527 pub fn generic_family_ids(&mut self, generic: GenericFamily) -> Vec<FamilyId> {
528 self.font_db.generic_family_ids(&mut self.fcx, generic)
529 }
530
531 pub fn set_generic_family_ids(&mut self, generic: GenericFamily, ids: &[FamilyId]) -> bool {
532 self.font_db
533 .set_generic_family_ids(&mut self.fcx, generic, ids)
534 }
535
536 pub fn add_fonts(&mut self, fonts: impl IntoIterator<Item = Vec<u8>>) -> usize {
537 self.font_db.add_fonts(&mut self.fcx, fonts)
538 }
539
540 pub fn for_each_font_environment_blob(&mut self, f: impl FnMut(FontEnvironmentBlobRef<'_>)) {
541 self.font_db
542 .for_each_font_environment_blob(&mut self.fcx, f);
543 }
544
545 pub fn system_font_rescan_seed(&self) -> Option<crate::SystemFontRescanSeed> {
546 self.font_db.system_font_rescan_seed()
547 }
548
549 pub fn apply_system_font_rescan_result(
550 &mut self,
551 result: crate::SystemFontRescanResult,
552 ) -> bool {
553 self.font_db
554 .apply_system_font_rescan_result(&mut self.fcx, result)
555 }
556
557 #[cfg(test)]
558 fn current_font_environment_fingerprint(&mut self) -> u64 {
559 self.font_db
560 .current_font_environment_fingerprint(&mut self.fcx)
561 }
562
563 fn base_line_metrics_cache_key(&self, style: &TextStyle, scale: f32) -> u64 {
564 self.font_db.base_line_metrics_cache_key(
565 self.default_locale.as_deref(),
566 &self.common_fallback_stack_suffix,
567 style,
568 scale,
569 )
570 }
571
572 fn base_ascent_descent_px_for_style(
573 &mut self,
574 style: &TextStyle,
575 scale: f32,
576 ) -> Option<(f32, f32)> {
577 let mut metrics_style = style.clone();
578 metrics_style.line_height = None;
579 metrics_style.line_height_em = None;
580 metrics_style.line_height_policy = TextLineHeightPolicy::ExpandToFit;
581 metrics_style.leading_distribution = TextLeadingDistribution::Even;
582 metrics_style.strut_style = None;
583
584 let key = self.base_line_metrics_cache_key(&metrics_style, scale);
585 if let Some(hit) = self.font_db.base_line_metrics(key) {
586 return Some(hit);
587 }
588
589 let line = self.shape_single_line_metrics(TextInputRef::plain("Hg", &metrics_style), scale);
590 let ascent = normalize_ascent(line.ascent);
591 let descent = normalize_descent(line.descent);
592 self.font_db
593 .insert_base_line_metrics(key, (ascent, descent));
594 Some((ascent, descent))
595 }
596
597 pub fn shape_single_line(&mut self, input: TextInputRef<'_>, scale: f32) -> ShapedLineLayout {
598 let (text, base_style, spans) = match input {
599 TextInputRef::Plain { text, style } => (text, style, &[][..]),
600 TextInputRef::Attributed { text, base, spans } => (text, base, spans),
601 };
602
603 let requested_line_height_px =
604 requested_line_height_logical_px_with_strut(base_style).map(|v| (v * scale).max(0.0));
605 let strut_forces_fixed = base_style.strut_style.as_ref().is_some_and(|s| s.force);
606 let fixed_line_box = strut_forces_fixed
607 || (base_style.line_height_policy == TextLineHeightPolicy::FixedFromStyle
608 && requested_line_height_px.is_some());
609 let fixed_ascent_descent = if fixed_line_box {
610 let style_for_metrics = style_for_strut_metrics(base_style);
611 let style_for_metrics = style_for_metrics.as_ref().unwrap_or(base_style);
612 self.base_ascent_descent_px_for_style(style_for_metrics, scale)
613 } else {
614 None
615 };
616
617 if text.is_empty() {
618 let fallback = self.shape_single_line(TextInputRef::plain(" ", base_style), scale);
619 return ShapedLineLayout {
620 width: 0.0,
621 ascent: fallback.ascent,
622 descent: fallback.descent,
623 ink_ascent: fallback.ink_ascent,
624 ink_descent: fallback.ink_descent,
625 baseline: fallback.baseline,
626 line_height: fallback.line_height,
627 glyphs: Vec::new(),
628 clusters: Vec::new(),
629 };
630 }
631
632 let root_style = ParleyTextStyle::default();
633 let mut builder = self
634 .lcx
635 .tree_builder(&mut self.fcx, scale, true, &root_style);
636
637 builder.push_style_span(base_parley_style(
638 base_style,
639 self.default_locale.as_deref(),
640 &self.common_fallback_stack_suffix,
641 ));
642
643 if let Some(span_ranges) = resolve_span_ranges(text, spans) {
644 for (range, span) in span_ranges {
645 let chunk = &text[range.clone()];
646 if let Some(props) = shaping_properties_for_span(
647 base_style,
648 span,
649 &self.common_fallback_stack_suffix,
650 ) {
651 builder.push_style_modification_span(props.iter());
652 builder.push_text(chunk);
653 builder.pop_style_span();
654 } else {
655 builder.push_text(chunk);
656 }
657 }
658 } else {
659 builder.push_text(text);
660 }
661
662 builder.pop_style_span();
663 let _built_text = builder.build_into(&mut self.layout);
664 self.layout.break_all_lines(None);
665
666 let Some(line) = self.layout.lines().next() else {
667 return ShapedLineLayout {
668 width: 0.0,
669 ascent: 0.0,
670 descent: 0.0,
671 ink_ascent: 0.0,
672 ink_descent: 0.0,
673 baseline: 0.0,
674 line_height: 0.0,
675 glyphs: Vec::new(),
676 clusters: Vec::new(),
677 };
678 };
679
680 let metrics = *line.metrics();
681 let ink_ascent = normalize_ascent(metrics.ascent);
682 let ink_descent = normalize_descent(metrics.descent);
683 let leading_distribution = effective_leading_distribution(base_style);
684 let (ascent, descent, line_height, baseline) = if base_style.line_height_policy
685 == TextLineHeightPolicy::FixedFromStyle
686 || strut_forces_fixed
687 {
688 let (ascent, descent) = fixed_ascent_descent.unwrap_or((
689 normalize_ascent(metrics.ascent),
690 normalize_descent(metrics.descent),
691 ));
692 let fixed_line_height_px = requested_line_height_px
693 .unwrap_or_else(|| min_line_height_for_metrics(ascent, descent));
694 (
695 ascent,
696 descent,
697 fixed_line_height_px,
698 baseline_for_fixed_line_box(
699 ascent,
700 descent,
701 fixed_line_height_px,
702 leading_distribution,
703 ),
704 )
705 } else {
706 let ascent = normalize_ascent(metrics.ascent);
707 let descent = normalize_descent(metrics.descent);
708 let base_line_height = metrics.line_height.max(0.0);
709 let mut line_height = metrics.line_height.max(0.0);
710 line_height = line_height.max(min_line_height_for_metrics(ascent, descent));
711 if let Some(requested) = requested_line_height_px {
712 line_height = line_height.max(requested.max(0.0));
713 }
714 let extra = (line_height - base_line_height).max(0.0);
715 let top_factor = leading_distribution_top_factor(leading_distribution, ascent, descent);
716 let baseline =
717 (metrics.baseline.max(0.0) + (extra * top_factor)).clamp(0.0, line_height.max(0.0));
718 (ascent, descent, line_height, baseline)
719 };
720
721 let mut glyphs: Vec<ParleyGlyph> = Vec::new();
722 let mut clusters: Vec<ShapedCluster> = Vec::new();
723
724 let mut run_x = metrics.offset;
726 for run in line.runs() {
727 let font = run.font();
728 let font_data = GlyphFontData::from_parley(font.clone());
729 let font_size = run.font_size();
730 let normalized_coords: Arc<[i16]> = Arc::from(run.normalized_coords());
731 let synthesis = FontSynthesis::from_parley(run.synthesis());
732
733 for cluster in run.visual_clusters() {
734 let cluster_range = cluster.text_range();
735 let cluster_x0 = run_x;
736
737 let mut glyph_x = cluster_x0;
738 for mut g in cluster.glyphs() {
739 g.x += glyph_x;
740 glyph_x += g.advance;
741
742 glyphs.push(ParleyGlyph {
743 id: g.id,
744 x: g.x,
745 y: g.y,
746 advance: g.advance,
747 font: font_data.clone(),
748 font_size,
749 normalized_coords: normalized_coords.clone(),
750 synthesis,
751 text_range: cluster_range.clone(),
752 is_rtl: cluster.is_rtl(),
753 });
754 }
755
756 run_x = cluster_x0 + cluster.advance();
757 clusters.push(ShapedCluster::new(
758 cluster_range,
759 cluster_x0,
760 run_x,
761 cluster.is_rtl(),
762 ));
763 }
764 }
765
766 ShapedLineLayout {
767 width: metrics.advance,
768 ascent,
769 descent,
770 ink_ascent,
771 ink_descent,
772 baseline,
773 line_height,
774 glyphs,
775 clusters,
776 }
777 }
778
779 pub fn shape_paragraph_word_wrap(
780 &mut self,
781 input: TextInputRef<'_>,
782 max_width_px: f32,
783 scale: f32,
784 ) -> Vec<(Range<usize>, ShapedLineLayout)> {
785 self.shape_paragraph_with_wrap(
786 input,
787 Some(max_width_px),
788 WordBreakStrength::Normal,
789 OverflowWrap::Normal,
793 TextWrapMode::Wrap,
794 scale,
795 false,
796 )
797 }
798
799 pub fn shape_paragraph_word_break_wrap(
800 &mut self,
801 input: TextInputRef<'_>,
802 max_width_px: f32,
803 scale: f32,
804 ) -> Vec<(Range<usize>, ShapedLineLayout)> {
805 self.shape_paragraph_with_wrap(
806 input,
807 Some(max_width_px),
808 WordBreakStrength::Normal,
809 OverflowWrap::BreakWord,
810 TextWrapMode::Wrap,
811 scale,
812 false,
813 )
814 }
815
816 pub fn shape_paragraph_word_wrap_metrics(
817 &mut self,
818 input: TextInputRef<'_>,
819 max_width_px: f32,
820 scale: f32,
821 ) -> Vec<(Range<usize>, ShapedLineLayout)> {
822 self.shape_paragraph_with_wrap(
823 input,
824 Some(max_width_px),
825 WordBreakStrength::Normal,
826 OverflowWrap::Normal,
828 TextWrapMode::Wrap,
829 scale,
830 true,
831 )
832 }
833
834 pub fn shape_paragraph_word_break_wrap_metrics(
835 &mut self,
836 input: TextInputRef<'_>,
837 max_width_px: f32,
838 scale: f32,
839 ) -> Vec<(Range<usize>, ShapedLineLayout)> {
840 self.shape_paragraph_with_wrap(
841 input,
842 Some(max_width_px),
843 WordBreakStrength::Normal,
844 OverflowWrap::BreakWord,
845 TextWrapMode::Wrap,
846 scale,
847 true,
848 )
849 }
850
851 pub fn shape_single_line_metrics(
852 &mut self,
853 input: TextInputRef<'_>,
854 scale: f32,
855 ) -> ShapedLineLayout {
856 let (text, base_style, spans) = match input {
857 TextInputRef::Plain { text, style } => (text, style, &[][..]),
858 TextInputRef::Attributed { text, base, spans } => (text, base, spans),
859 };
860
861 let requested_line_height_px =
862 requested_line_height_logical_px_with_strut(base_style).map(|v| (v * scale).max(0.0));
863 let strut_forces_fixed = base_style.strut_style.as_ref().is_some_and(|s| s.force);
864 let fixed_line_box = strut_forces_fixed
865 || (base_style.line_height_policy == TextLineHeightPolicy::FixedFromStyle
866 && requested_line_height_px.is_some());
867 let fixed_ascent_descent = if fixed_line_box {
868 let style_for_metrics = style_for_strut_metrics(base_style);
869 let style_for_metrics = style_for_metrics.as_ref().unwrap_or(base_style);
870 self.base_ascent_descent_px_for_style(style_for_metrics, scale)
871 } else {
872 None
873 };
874
875 if text.is_empty() {
876 let fallback =
877 self.shape_single_line_metrics(TextInputRef::plain(" ", base_style), scale);
878 return ShapedLineLayout {
879 width: 0.0,
880 ascent: fallback.ascent,
881 descent: fallback.descent,
882 ink_ascent: fallback.ink_ascent,
883 ink_descent: fallback.ink_descent,
884 baseline: fallback.baseline,
885 line_height: fallback.line_height,
886 glyphs: Vec::new(),
887 clusters: Vec::new(),
888 };
889 }
890
891 let root_style = ParleyTextStyle::default();
892 let mut builder = self
893 .lcx
894 .tree_builder(&mut self.fcx, scale, true, &root_style);
895
896 builder.push_style_span(base_parley_style(
897 base_style,
898 self.default_locale.as_deref(),
899 &self.common_fallback_stack_suffix,
900 ));
901
902 if let Some(span_ranges) = resolve_span_ranges(text, spans) {
903 for (range, span) in span_ranges {
904 let chunk = &text[range.clone()];
905 if let Some(props) = shaping_properties_for_span(
906 base_style,
907 span,
908 &self.common_fallback_stack_suffix,
909 ) {
910 builder.push_style_modification_span(props.iter());
911 builder.push_text(chunk);
912 builder.pop_style_span();
913 } else {
914 builder.push_text(chunk);
915 }
916 }
917 } else {
918 builder.push_text(text);
919 }
920
921 builder.pop_style_span();
922 let _built_text = builder.build_into(&mut self.layout);
923 self.layout.break_all_lines(None);
924
925 let Some(line) = self.layout.lines().next() else {
926 return ShapedLineLayout {
927 width: 0.0,
928 ascent: 0.0,
929 descent: 0.0,
930 ink_ascent: 0.0,
931 ink_descent: 0.0,
932 baseline: 0.0,
933 line_height: 0.0,
934 glyphs: Vec::new(),
935 clusters: Vec::new(),
936 };
937 };
938
939 let metrics = *line.metrics();
940 let ink_ascent = normalize_ascent(metrics.ascent);
941 let ink_descent = normalize_descent(metrics.descent);
942 let leading_distribution = effective_leading_distribution(base_style);
943 let (ascent, descent, line_height, baseline) = if base_style.line_height_policy
944 == TextLineHeightPolicy::FixedFromStyle
945 || strut_forces_fixed
946 {
947 let (ascent, descent) = fixed_ascent_descent.unwrap_or((
948 normalize_ascent(metrics.ascent),
949 normalize_descent(metrics.descent),
950 ));
951 let fixed_line_height_px = requested_line_height_px
952 .unwrap_or_else(|| min_line_height_for_metrics(ascent, descent));
953 (
954 ascent,
955 descent,
956 fixed_line_height_px,
957 baseline_for_fixed_line_box(
958 ascent,
959 descent,
960 fixed_line_height_px,
961 leading_distribution,
962 ),
963 )
964 } else {
965 let ascent = normalize_ascent(metrics.ascent);
966 let descent = normalize_descent(metrics.descent);
967 let base_line_height = metrics.line_height.max(0.0);
968 let mut line_height = metrics.line_height.max(0.0);
969 line_height = line_height.max(min_line_height_for_metrics(ascent, descent));
970 if let Some(requested) = requested_line_height_px {
971 line_height = line_height.max(requested.max(0.0));
972 }
973 let extra = (line_height - base_line_height).max(0.0);
974 let top_factor = leading_distribution_top_factor(leading_distribution, ascent, descent);
975 let baseline =
976 (metrics.baseline.max(0.0) + (extra * top_factor)).clamp(0.0, line_height.max(0.0));
977 (ascent, descent, line_height, baseline)
978 };
979
980 let mut clusters: Vec<ShapedCluster> = Vec::new();
981
982 let mut run_x = metrics.offset;
983 for run in line.runs() {
984 for cluster in run.visual_clusters() {
985 let cluster_range = cluster.text_range();
986 let cluster_x0 = run_x;
987 run_x = cluster_x0 + cluster.advance();
988 clusters.push(ShapedCluster::new(
989 cluster_range,
990 cluster_x0,
991 run_x,
992 cluster.is_rtl(),
993 ));
994 }
995 }
996
997 ShapedLineLayout {
998 width: metrics.advance,
999 ascent,
1000 descent,
1001 ink_ascent,
1002 ink_descent,
1003 baseline,
1004 line_height,
1005 glyphs: Vec::new(),
1006 clusters,
1007 }
1008 }
1009
1010 #[allow(clippy::too_many_arguments)]
1011 fn shape_paragraph_with_wrap(
1012 &mut self,
1013 input: TextInputRef<'_>,
1014 max_width_px: Option<f32>,
1015 word_break: WordBreakStrength,
1016 overflow_wrap: OverflowWrap,
1017 text_wrap_mode: TextWrapMode,
1018 scale: f32,
1019 metrics_only: bool,
1020 ) -> Vec<(Range<usize>, ShapedLineLayout)> {
1021 let (text, base_style, spans) = match input {
1022 TextInputRef::Plain { text, style } => (text, style, &[][..]),
1023 TextInputRef::Attributed { text, base, spans } => (text, base, spans),
1024 };
1025
1026 if text.is_empty() {
1027 let fallback = if metrics_only {
1028 self.shape_single_line_metrics(TextInputRef::plain(" ", base_style), scale)
1029 } else {
1030 self.shape_single_line(TextInputRef::plain(" ", base_style), scale)
1031 };
1032 return vec![(
1033 0..0,
1034 ShapedLineLayout {
1035 width: 0.0,
1036 ascent: fallback.ascent,
1037 descent: fallback.descent,
1038 ink_ascent: fallback.ink_ascent,
1039 ink_descent: fallback.ink_descent,
1040 baseline: fallback.baseline,
1041 line_height: fallback.line_height,
1042 glyphs: Vec::new(),
1043 clusters: Vec::new(),
1044 },
1045 )];
1046 }
1047
1048 let root_style = ParleyTextStyle {
1049 word_break,
1050 overflow_wrap,
1051 text_wrap_mode,
1052 ..Default::default()
1053 };
1054
1055 let mut builder = self
1056 .lcx
1057 .tree_builder(&mut self.fcx, scale, true, &root_style);
1058
1059 let mut base = base_parley_style(
1060 base_style,
1061 self.default_locale.as_deref(),
1062 &self.common_fallback_stack_suffix,
1063 );
1064 base.word_break = word_break;
1065 base.overflow_wrap = overflow_wrap;
1066 base.text_wrap_mode = text_wrap_mode;
1067 builder.push_style_span(base);
1068
1069 let base_mods = shaping_properties_for_base_style(base_style);
1070 if let Some(props) = base_mods.as_ref() {
1071 builder.push_style_modification_span(props.iter());
1072 }
1073
1074 if let Some(span_ranges) = resolve_span_ranges(text, spans) {
1075 for (range, span) in span_ranges {
1076 let chunk = &text[range.clone()];
1077 if let Some(props) = shaping_properties_for_span(
1078 base_style,
1079 span,
1080 &self.common_fallback_stack_suffix,
1081 ) {
1082 builder.push_style_modification_span(props.iter());
1083 builder.push_text(chunk);
1084 builder.pop_style_span();
1085 } else {
1086 builder.push_text(chunk);
1087 }
1088 }
1089 } else {
1090 builder.push_text(text);
1091 }
1092
1093 if base_mods.is_some() {
1094 builder.pop_style_span();
1095 }
1096 builder.pop_style_span();
1097 let _built_text = builder.build_into(&mut self.layout);
1098 self.layout.break_all_lines(max_width_px);
1099
1100 let mut out: Vec<(Range<usize>, ShapedLineLayout)> = Vec::new();
1101
1102 let requested_line_height_px =
1103 requested_line_height_logical_px_with_strut(base_style).map(|v| (v * scale).max(0.0));
1104 let strut_forces_fixed = base_style.strut_style.as_ref().is_some_and(|s| s.force);
1105 let fixed_line_box = strut_forces_fixed
1106 || (base_style.line_height_policy == TextLineHeightPolicy::FixedFromStyle
1107 && requested_line_height_px.is_some());
1108 let fixed_ascent_descent = if fixed_line_box {
1109 let style_for_metrics = style_for_strut_metrics(base_style);
1110 let style_for_metrics = style_for_metrics.as_ref().unwrap_or(base_style);
1111 self.base_ascent_descent_px_for_style(style_for_metrics, scale)
1112 } else {
1113 None
1114 };
1115
1116 for line in self.layout.lines() {
1117 let line_range = line.text_range();
1118 let line_start = line_range.start;
1119 let metrics = *line.metrics();
1120
1121 let ink_ascent = normalize_ascent(metrics.ascent);
1122 let ink_descent = normalize_descent(metrics.descent);
1123 let leading_distribution = effective_leading_distribution(base_style);
1124 let (ascent, descent, line_height, baseline) = if fixed_line_box {
1125 let (ascent, descent) = fixed_ascent_descent.unwrap_or((
1126 normalize_ascent(metrics.ascent),
1127 normalize_descent(metrics.descent),
1128 ));
1129 let fixed_line_height_px = requested_line_height_px
1130 .unwrap_or_else(|| min_line_height_for_metrics(ascent, descent));
1131 (
1132 ascent,
1133 descent,
1134 fixed_line_height_px,
1135 baseline_for_fixed_line_box(
1136 ascent,
1137 descent,
1138 fixed_line_height_px,
1139 leading_distribution,
1140 ),
1141 )
1142 } else {
1143 let ascent = normalize_ascent(metrics.ascent);
1144 let descent = normalize_descent(metrics.descent);
1145 let base_line_height = metrics.line_height.max(0.0);
1146 let mut line_height = metrics.line_height.max(0.0);
1147 line_height = line_height.max(min_line_height_for_metrics(ascent, descent));
1148 if let Some(requested) = requested_line_height_px {
1149 line_height = line_height.max(requested.max(0.0));
1150 }
1151 let extra = (line_height - base_line_height).max(0.0);
1152 let top_factor =
1153 leading_distribution_top_factor(leading_distribution, ascent, descent);
1154 let baseline = (metrics.baseline.max(0.0) + (extra * top_factor))
1155 .clamp(0.0, line_height.max(0.0));
1156 (ascent, descent, line_height, baseline)
1157 };
1158
1159 let mut glyphs: Vec<ParleyGlyph> = Vec::new();
1160 let mut clusters: Vec<ShapedCluster> = Vec::new();
1161
1162 let mut run_x = metrics.offset;
1163 for run in line.runs() {
1164 let font = run.font();
1165 let font_data = GlyphFontData::from_parley(font.clone());
1166 let font_size = run.font_size();
1167 let normalized_coords: Arc<[i16]> = Arc::from(run.normalized_coords());
1168 let synthesis = FontSynthesis::from_parley(run.synthesis());
1169
1170 for cluster in run.visual_clusters() {
1171 let cluster_range = cluster.text_range();
1172 let cluster_x0 = run_x;
1173
1174 let adjusted_range = (cluster_range.start.saturating_sub(line_start))
1175 ..(cluster_range.end.saturating_sub(line_start));
1176
1177 if !metrics_only {
1178 let mut glyph_x = cluster_x0;
1179 for mut g in cluster.glyphs() {
1180 g.x += glyph_x;
1181 glyph_x += g.advance;
1182
1183 glyphs.push(ParleyGlyph {
1184 id: g.id,
1185 x: g.x,
1186 y: g.y,
1187 advance: g.advance,
1188 font: font_data.clone(),
1189 font_size,
1190 normalized_coords: normalized_coords.clone(),
1191 synthesis,
1192 text_range: adjusted_range.clone(),
1193 is_rtl: cluster.is_rtl(),
1194 });
1195 }
1196 }
1197
1198 run_x = cluster_x0 + cluster.advance();
1199 clusters.push(ShapedCluster::new(
1200 adjusted_range,
1201 cluster_x0,
1202 run_x,
1203 cluster.is_rtl(),
1204 ));
1205 }
1206 }
1207
1208 out.push((
1209 line_range.clone(),
1210 ShapedLineLayout {
1211 width: metrics.advance,
1212 ascent,
1213 descent,
1214 ink_ascent,
1215 ink_descent,
1216 baseline,
1217 line_height,
1218 glyphs,
1219 clusters,
1220 },
1221 ));
1222 }
1223
1224 if out.is_empty() {
1225 out.push((
1226 0..text.len(),
1227 if metrics_only {
1228 self.shape_single_line_metrics(input, scale)
1229 } else {
1230 self.shape_single_line(input, scale)
1231 },
1232 ));
1233 }
1234
1235 out
1236 }
1237}
1238
1239fn resolve_span_ranges<'a>(
1240 text: &'a str,
1241 spans: &'a [TextSpan],
1242) -> Option<Vec<(Range<usize>, &'a TextSpan)>> {
1243 if spans.is_empty() {
1244 return None;
1245 }
1246
1247 let mut out: Vec<(Range<usize>, &'a TextSpan)> = Vec::with_capacity(spans.len());
1248 let mut offset: usize = 0;
1249
1250 for span in spans {
1251 let end = offset.saturating_add(span.len);
1252 if end > text.len() {
1253 return None;
1254 }
1255 if !text.is_char_boundary(offset) || !text.is_char_boundary(end) {
1256 return None;
1257 }
1258 if span.len != 0 {
1259 out.push((offset..end, span));
1260 }
1261 offset = end;
1262 }
1263
1264 if offset != text.len() {
1265 return None;
1266 }
1267
1268 Some(out)
1269}
1270
1271fn base_parley_style<'a>(
1272 style: &TextStyle,
1273 locale: Option<&'a str>,
1274 common_fallback_stack_suffix: &str,
1275) -> ParleyTextStyle<'a, [u8; 4]> {
1276 let stack = font_stack_for_font_id(&style.font, common_fallback_stack_suffix);
1277 ParleyTextStyle {
1278 font_size: style.size.0,
1279 font_weight: ParleyFontWeight::new(style.weight.0 as f32),
1280 font_style: font_style_for_slant(style.slant),
1281 letter_spacing: style.letter_spacing_em.unwrap_or(0.0).clamp(-4.0, 4.0) * style.size.0,
1282 locale,
1283 font_stack: parley::style::FontStack::Source(Cow::Owned(stack)),
1284 ..Default::default()
1285 }
1286}
1287
1288fn font_stack_for_font_id(font: &FontId, common_fallback_stack_suffix: &str) -> String {
1289 match font {
1290 FontId::Ui => "sans-serif".to_string(),
1291 FontId::Serif => "serif".to_string(),
1292 FontId::Monospace => "monospace".to_string(),
1293 FontId::Family(name) => {
1294 if common_fallback_stack_suffix.is_empty() {
1295 return name.clone();
1296 }
1297 format!("{name}, {common_fallback_stack_suffix}")
1298 }
1299 }
1300}
1301
1302fn font_style_for_slant(slant: TextSlant) -> FontStyle {
1303 match slant {
1304 TextSlant::Normal => FontStyle::Normal,
1305 TextSlant::Italic => FontStyle::Italic,
1306 TextSlant::Oblique => FontStyle::Oblique(None),
1307 }
1308}
1309
1310fn shaping_properties_for_span(
1311 base: &TextStyle,
1312 span: &TextSpan,
1313 common_fallback_stack_suffix: &str,
1314) -> Option<Vec<StyleProperty<'static, [u8; 4]>>> {
1315 let TextShapingStyle {
1316 font,
1317 weight,
1318 slant,
1319 letter_spacing_em,
1320 features,
1321 axes,
1322 } = &span.shaping;
1323
1324 let mut out: Vec<StyleProperty<'static, [u8; 4]>> = Vec::new();
1325
1326 if let Some(font) = font {
1327 let stack = font_stack_for_font_id(font, common_fallback_stack_suffix);
1328 out.push(StyleProperty::FontStack(parley::style::FontStack::Source(
1329 Cow::Owned(stack),
1330 )));
1331 }
1332
1333 let mut effective_weight = *weight;
1334 let mut axes_for_variations: Vec<fret_core::TextFontAxisSetting> = Vec::new();
1335 if !axes.is_empty() {
1336 let mut wght_axis_override: Option<f32> = None;
1340 for axis in axes {
1341 if axis.tag.trim().eq_ignore_ascii_case("wght") && axis.value.is_finite() {
1342 wght_axis_override = Some(axis.value);
1343 continue;
1344 }
1345 axes_for_variations.push(axis.clone());
1346 }
1347 if effective_weight.is_none()
1348 && let Some(wght) = wght_axis_override
1349 {
1350 let wght = wght.clamp(1.0, 1000.0).round() as u16;
1351 effective_weight = Some(fret_core::FontWeight(wght));
1352 }
1353 }
1354
1355 if !axes_for_variations.is_empty() {
1356 let variations = font_variations_for_axes(&axes_for_variations);
1357 if !variations.is_empty() {
1358 out.push(StyleProperty::FontVariations(FontSettings::List(
1359 Cow::Owned(variations),
1360 )));
1361 }
1362 }
1363 if !features.is_empty() {
1364 let features = font_features_for_settings(features);
1365 if !features.is_empty() {
1366 out.push(StyleProperty::FontFeatures(FontSettings::List(Cow::Owned(
1367 features,
1368 ))));
1369 }
1370 }
1371 if let Some(weight) = effective_weight {
1372 out.push(StyleProperty::FontWeight(ParleyFontWeight::new(
1373 weight.0 as f32,
1374 )));
1375 }
1376 if let Some(slant) = slant {
1377 out.push(StyleProperty::FontStyle(font_style_for_slant(*slant)));
1378 }
1379 if let Some(letter_spacing_em) = letter_spacing_em {
1380 out.push(StyleProperty::LetterSpacing(
1381 letter_spacing_em.clamp(-4.0, 4.0) * base.size.0,
1382 ));
1383 }
1384
1385 (!out.is_empty()).then_some(out)
1386}
1387
1388fn shaping_properties_for_base_style(
1389 style: &TextStyle,
1390) -> Option<Vec<StyleProperty<'static, [u8; 4]>>> {
1391 let mut out: Vec<StyleProperty<'static, [u8; 4]>> = Vec::new();
1392
1393 if !style.axes.is_empty() {
1394 let mut wght_axis_override: Option<f32> = None;
1398 let axes_for_variations = style
1399 .axes
1400 .iter()
1401 .filter_map(|a| {
1402 if a.tag.trim().eq_ignore_ascii_case("wght") && a.value.is_finite() {
1403 wght_axis_override = Some(a.value);
1404 return None;
1405 }
1406 Some(a.clone())
1407 })
1408 .collect::<Vec<_>>();
1409 if !axes_for_variations.is_empty() {
1410 let variations = font_variations_for_axes(&axes_for_variations);
1411 if !variations.is_empty() {
1412 out.push(StyleProperty::FontVariations(FontSettings::List(
1413 Cow::Owned(variations),
1414 )));
1415 }
1416 }
1417
1418 if let Some(wght) = wght_axis_override {
1419 let wght = wght.clamp(1.0, 1000.0).round() as u16;
1420 out.push(StyleProperty::FontWeight(ParleyFontWeight::new(
1421 wght as f32,
1422 )));
1423 }
1424 }
1425
1426 if !style.features.is_empty() {
1427 let features = font_features_for_settings(&style.features);
1428 if !features.is_empty() {
1429 out.push(StyleProperty::FontFeatures(FontSettings::List(Cow::Owned(
1430 features,
1431 ))));
1432 }
1433 }
1434
1435 (!out.is_empty()).then_some(out)
1436}
1437
1438fn font_variations_for_axes(axes: &[fret_core::TextFontAxisSetting]) -> Vec<FontVariation> {
1439 use std::collections::BTreeMap;
1440
1441 let mut by_tag: BTreeMap<u32, FontVariation> = BTreeMap::new();
1442 for axis in axes {
1443 let tag = axis.tag.trim();
1444 if tag.is_empty() {
1445 continue;
1446 }
1447 let bytes = tag.as_bytes();
1448 if bytes.len() != 4 {
1449 continue;
1450 }
1451 if !axis.value.is_finite() {
1452 continue;
1453 }
1454
1455 let mut tag_bytes = [0u8; 4];
1456 tag_bytes.copy_from_slice(bytes);
1457 let tuple = (tag_bytes, axis.value);
1458 let setting = FontVariation::from(&tuple);
1459 by_tag.insert(setting.tag, setting);
1460 }
1461
1462 by_tag.into_values().collect::<Vec<_>>()
1463}
1464
1465fn font_features_for_settings(features: &[fret_core::TextFontFeatureSetting]) -> Vec<FontFeature> {
1466 use std::collections::BTreeMap;
1467
1468 let mut by_tag: BTreeMap<u32, FontFeature> = BTreeMap::new();
1469 for feature in features {
1470 let tag = feature.tag.trim();
1471 if tag.is_empty() {
1472 continue;
1473 }
1474 let bytes = tag.as_bytes();
1475 if bytes.len() != 4 || !bytes.iter().all(u8::is_ascii) {
1476 continue;
1477 }
1478
1479 let value = feature.value.min(u32::from(u16::MAX)) as u16;
1480 let mut tag_bytes = [0u8; 4];
1481 tag_bytes.copy_from_slice(bytes);
1482 let tuple = (tag_bytes, value);
1483 let setting = FontFeature::from(&tuple);
1484 by_tag.insert(setting.tag, setting);
1485 }
1486
1487 by_tag.into_values().collect::<Vec<_>>()
1488}
1489
1490pub fn run_system_font_rescan(seed: crate::SystemFontRescanSeed) -> crate::SystemFontRescanResult {
1491 crate::parley_font_db::run_system_font_rescan(seed)
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496 use super::*;
1497 use fret_core::{FontId, FontWeight, Px, TextFontFeatureSetting, TextSpan, TextStyle};
1498 use std::sync::{Mutex, OnceLock};
1499
1500 fn env_lock() -> &'static Mutex<()> {
1501 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1502 LOCK.get_or_init(|| Mutex::new(()))
1503 }
1504
1505 fn shaper_with_bundled_fonts() -> ParleyShaper {
1506 let mut shaper = ParleyShaper::new_without_system_fonts();
1507 let added = shaper.add_fonts(fret_fonts::test_support::face_blobs(
1508 fret_fonts::bootstrap_profile()
1509 .faces
1510 .iter()
1511 .chain(fret_fonts_emoji::default_profile().faces.iter())
1512 .chain(fret_fonts_cjk::default_profile().faces.iter()),
1513 ));
1514 assert!(added > 0, "expected bundled fonts to load");
1515 shaper
1516 }
1517
1518 #[test]
1519 fn shaping_properties_map_wght_axis_to_font_weight() {
1520 let base = TextStyle {
1521 font: FontId::family("Roboto Flex"),
1522 size: Px(16.0),
1523 weight: FontWeight(400),
1524 ..Default::default()
1525 };
1526
1527 let span = TextSpan {
1528 len: 1,
1529 shaping: TextShapingStyle::default().with_axis("wght", 900.0),
1530 paint: Default::default(),
1531 };
1532
1533 let props =
1534 shaping_properties_for_span(&base, &span, "").expect("expected shaping properties");
1535
1536 assert!(
1537 props
1538 .iter()
1539 .any(|p| matches!(p, StyleProperty::FontWeight(_))),
1540 "expected `wght` axis to map to FontWeight"
1541 );
1542 assert!(
1543 !props
1544 .iter()
1545 .any(|p| matches!(p, StyleProperty::FontVariations(_))),
1546 "expected `wght` axis to be removed from FontVariations"
1547 );
1548 }
1549
1550 #[test]
1551 fn base_style_maps_wght_axis_to_font_weight() {
1552 let base = TextStyle {
1553 font: FontId::family("Roboto Flex"),
1554 size: Px(16.0),
1555 weight: FontWeight(400),
1556 axes: vec![fret_core::TextFontAxisSetting {
1557 tag: "wght".into(),
1558 value: 900.0,
1559 }],
1560 ..Default::default()
1561 };
1562
1563 let props =
1564 shaping_properties_for_base_style(&base).expect("expected base shaping properties");
1565
1566 assert!(
1567 props
1568 .iter()
1569 .any(|p| matches!(p, StyleProperty::FontWeight(_))),
1570 "expected base `wght` axis to map to FontWeight"
1571 );
1572 assert!(
1573 !props
1574 .iter()
1575 .any(|p| matches!(p, StyleProperty::FontVariations(_))),
1576 "expected base `wght` axis to be removed from FontVariations"
1577 );
1578 }
1579
1580 #[test]
1581 fn shaping_properties_emit_font_features_when_present() {
1582 let base = TextStyle {
1583 font: FontId::family("Roboto Flex"),
1584 size: Px(16.0),
1585 weight: FontWeight(400),
1586 ..Default::default()
1587 };
1588
1589 let span = TextSpan {
1590 len: 1,
1591 shaping: TextShapingStyle::default()
1592 .with_feature("liga", 0)
1593 .with_feature("liga", 1)
1594 .with_feature(" lig ", 42)
1595 .with_feature("", 1)
1596 .with_feature("calt", 0),
1597 paint: Default::default(),
1598 };
1599
1600 let props =
1601 shaping_properties_for_span(&base, &span, "").expect("expected shaping properties");
1602
1603 fn tag_u32(tag: &[u8; 4]) -> u32 {
1604 (tag[0] as u32) << 24 | (tag[1] as u32) << 16 | (tag[2] as u32) << 8 | tag[3] as u32
1605 }
1606
1607 let mut features: Vec<FontFeature> = Vec::new();
1608 for p in &props {
1609 if let StyleProperty::FontFeatures(FontSettings::List(settings)) = p {
1610 features.extend(settings.iter().copied());
1611 }
1612 }
1613
1614 assert!(!features.is_empty(), "expected FontFeatures to be emitted");
1615
1616 let liga_tag = tag_u32(b"liga");
1617 let calt_tag = tag_u32(b"calt");
1618
1619 let tags = features
1620 .iter()
1621 .map(|f| f.tag)
1622 .collect::<std::collections::BTreeSet<_>>();
1623 assert_eq!(
1624 tags,
1625 std::collections::BTreeSet::from([calt_tag, liga_tag]),
1626 "expected invalid tags to be ignored and duplicates to be coalesced"
1627 );
1628
1629 let liga: Vec<FontFeature> = features
1630 .iter()
1631 .cloned()
1632 .filter(|f| f.tag == liga_tag)
1633 .collect();
1634 assert_eq!(liga.len(), 1, "expected duplicate tags to be coalesced");
1635 assert_eq!(liga[0].value, 1, "expected last-writer-wins for `liga`");
1636
1637 let calt: Vec<FontFeature> = features
1638 .iter()
1639 .cloned()
1640 .filter(|f| f.tag == calt_tag)
1641 .collect();
1642 assert_eq!(calt.len(), 1);
1643 assert_eq!(calt[0].value, 0);
1644 }
1645
1646 #[test]
1647 fn base_text_style_font_features_are_emitted_for_plain_text_builder() {
1648 fn tag_u32(tag: &[u8; 4]) -> u32 {
1649 (tag[0] as u32) << 24 | (tag[1] as u32) << 16 | (tag[2] as u32) << 8 | tag[3] as u32
1650 }
1651
1652 let style = TextStyle {
1653 font: FontId::family("Inter"),
1654 size: Px(16.0),
1655 ..Default::default()
1656 };
1657
1658 let mut tnum_style = style.clone();
1659 tnum_style.features.push(TextFontFeatureSetting {
1660 tag: "tnum".into(),
1661 value: 1,
1662 });
1663
1664 let props = shaping_properties_for_base_style(&tnum_style).expect("expected base props");
1665
1666 let mut features: Vec<FontFeature> = Vec::new();
1667 for p in &props {
1668 if let StyleProperty::FontFeatures(FontSettings::List(settings)) = p {
1669 features.extend(settings.iter().copied());
1670 }
1671 }
1672
1673 let tnum_tag = tag_u32(b"tnum");
1674 assert!(
1675 features.iter().any(|f| f.tag == tnum_tag && f.value == 1),
1676 "expected `tnum=1` to be emitted via base-style shaping properties"
1677 );
1678 }
1679
1680 #[test]
1681 fn font_catalog_caches_invalidate_after_add_fonts() {
1682 let mut shaper = ParleyShaper::new_without_system_fonts();
1683
1684 let names0 = shaper.all_font_names();
1685 assert!(
1686 !names0.iter().any(|n| n.eq_ignore_ascii_case("Inter")),
1687 "expected Inter to be absent before adding bundled fonts"
1688 );
1689
1690 let entries0 = shaper.all_font_catalog_entries();
1691 assert!(
1692 !entries0
1693 .iter()
1694 .any(|e| e.family().eq_ignore_ascii_case("Inter")),
1695 "expected catalog entries to be empty of Inter before adding bundled fonts"
1696 );
1697
1698 let added = shaper.add_fonts(fret_fonts::test_support::face_blobs(
1699 fret_fonts::bootstrap_profile().faces.iter(),
1700 ));
1701 assert!(added > 0, "expected bundled fonts to load");
1702
1703 let names1 = shaper.all_font_names();
1704 assert!(
1705 names1.iter().any(|n| n.eq_ignore_ascii_case("Inter")),
1706 "expected Inter to be present after adding bundled fonts"
1707 );
1708 assert_eq!(
1709 names1,
1710 shaper.all_font_names(),
1711 "expected repeated catalog reads to be stable"
1712 );
1713
1714 let entries1 = shaper.all_font_catalog_entries();
1715 assert!(
1716 entries1
1717 .iter()
1718 .any(|e| e.family().eq_ignore_ascii_case("Inter")),
1719 "expected catalog entries to include Inter after adding bundled fonts"
1720 );
1721 assert_eq!(
1722 entries1,
1723 shaper.all_font_catalog_entries(),
1724 "expected repeated catalog reads to be stable"
1725 );
1726 }
1727
1728 #[test]
1729 fn font_catalog_cached_reads_do_not_rebuild_entries_until_invalidated() {
1730 let mut shaper = ParleyShaper::new_without_system_fonts();
1731 let snapshot0 = shaper.font_db_diagnostics_snapshot();
1732 assert_eq!(
1733 snapshot0.catalog_entries_build_count(),
1734 0,
1735 "expected no catalog builds before first enumeration"
1736 );
1737
1738 let entries0 = shaper.all_font_catalog_entries();
1739 assert!(
1740 entries0.is_empty(),
1741 "expected bundled-only empty catalog before adding fonts"
1742 );
1743 let snapshot1 = shaper.font_db_diagnostics_snapshot();
1744 assert_eq!(
1745 snapshot1.catalog_entries_build_count(),
1746 1,
1747 "expected first cold enumeration to build the catalog exactly once"
1748 );
1749 assert!(
1750 snapshot1.all_font_catalog_entries_cache_present(),
1751 "expected catalog cache to be populated after first enumeration"
1752 );
1753
1754 for _ in 0..16 {
1755 assert_eq!(
1756 entries0,
1757 shaper.all_font_catalog_entries(),
1758 "expected cached catalog enumeration to stay stable"
1759 );
1760 }
1761 let snapshot2 = shaper.font_db_diagnostics_snapshot();
1762 assert_eq!(
1763 snapshot2.catalog_entries_build_count(),
1764 1,
1765 "expected repeated cached enumerations not to rebuild the catalog"
1766 );
1767
1768 let added = shaper.add_fonts(fret_fonts::test_support::face_blobs(
1769 fret_fonts::bootstrap_profile().faces.iter(),
1770 ));
1771 assert!(added > 0, "expected bundled fonts to load");
1772 let snapshot3 = shaper.font_db_diagnostics_snapshot();
1773 assert!(
1774 !snapshot3.all_font_catalog_entries_cache_present(),
1775 "expected add_fonts to invalidate the catalog cache"
1776 );
1777 assert_eq!(
1778 snapshot3.catalog_entries_build_count(),
1779 1,
1780 "expected invalidation alone not to rebuild the catalog"
1781 );
1782
1783 let entries1 = shaper.all_font_catalog_entries();
1784 assert!(
1785 entries1
1786 .iter()
1787 .any(|e| e.family().eq_ignore_ascii_case("Inter")),
1788 "expected rebuilt catalog to include bundled fonts after invalidation"
1789 );
1790 let snapshot4 = shaper.font_db_diagnostics_snapshot();
1791 assert_eq!(
1792 snapshot4.catalog_entries_build_count(),
1793 2,
1794 "expected the first post-invalidation enumeration to rebuild exactly once"
1795 );
1796 }
1797
1798 #[test]
1799 fn registered_font_blobs_dedup_and_lru_eviction_by_count() {
1800 let _guard = env_lock().lock().unwrap();
1801 let prev_max_count = std::env::var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT").ok();
1802 let prev_max_bytes = std::env::var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES").ok();
1803 unsafe {
1804 std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT", "2");
1805 std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES", "1048576");
1806 }
1807
1808 let mut shaper = ParleyShaper::new_without_system_fonts();
1809 shaper.record_registered_font_blob_bytes_for_tests(vec![1u8; 1]); shaper.record_registered_font_blob_bytes_for_tests(vec![2u8; 2]); shaper.record_registered_font_blob_bytes_for_tests(vec![1u8; 1]); shaper.record_registered_font_blob_bytes_for_tests(vec![3u8; 3]); assert_eq!(shaper.registered_font_blob_lengths_for_tests(), vec![1, 3]);
1815 assert_eq!(shaper.registered_font_blob_total_bytes_for_tests(), 4);
1816
1817 unsafe {
1818 match prev_max_count {
1819 Some(v) => std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT", v),
1820 None => std::env::remove_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT"),
1821 }
1822 match prev_max_bytes {
1823 Some(v) => std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES", v),
1824 None => std::env::remove_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES"),
1825 }
1826 }
1827 }
1828
1829 #[test]
1830 fn registered_font_blobs_eviction_by_bytes_budget() {
1831 let _guard = env_lock().lock().unwrap();
1832 let prev_max_count = std::env::var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT").ok();
1833 let prev_max_bytes = std::env::var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES").ok();
1834 unsafe {
1835 std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT", "4096");
1836 std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES", "3");
1837 }
1838
1839 let mut shaper = ParleyShaper::new_without_system_fonts();
1840 shaper.record_registered_font_blob_bytes_for_tests(vec![1u8; 2]);
1841 shaper.record_registered_font_blob_bytes_for_tests(vec![2u8; 2]);
1842
1843 assert_eq!(shaper.registered_font_blob_lengths_for_tests(), vec![2]);
1844 assert_eq!(shaper.registered_font_blob_total_bytes_for_tests(), 2);
1845
1846 unsafe {
1847 match prev_max_count {
1848 Some(v) => std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT", v),
1849 None => std::env::remove_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_COUNT"),
1850 }
1851 match prev_max_bytes {
1852 Some(v) => std::env::set_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES", v),
1853 None => std::env::remove_var("FRET_TEXT_REGISTERED_FONT_BLOBS_MAX_BYTES"),
1854 }
1855 }
1856 }
1857
1858 #[test]
1859 fn rescan_is_noop_when_system_fonts_disabled() {
1860 let shaper = ParleyShaper::new_without_system_fonts();
1861 assert!(shaper.system_font_rescan_seed().is_none());
1862 }
1863
1864 #[cfg(not(target_arch = "wasm32"))]
1865 #[test]
1866 fn rescan_apply_returns_false_when_environment_is_unchanged() {
1867 let mut shaper = ParleyShaper::new();
1868 let fingerprint_before = shaper.current_font_environment_fingerprint();
1869 let seed = shaper
1870 .system_font_rescan_seed()
1871 .expect("expected system font rescan to be available");
1872 let result = seed.run();
1873
1874 assert_eq!(
1875 result.environment_fingerprint, fingerprint_before,
1876 "expected background rescan to observe the same environment before apply"
1877 );
1878 assert!(
1879 !shaper.apply_system_font_rescan_result(result),
1880 "expected apply to short-circuit when the environment is unchanged"
1881 );
1882 assert_eq!(
1883 shaper.current_font_environment_fingerprint(),
1884 fingerprint_before,
1885 "expected no-op apply to preserve the current font environment"
1886 );
1887 }
1888
1889 #[test]
1890 fn shapes_basic_single_line() {
1891 let mut shaper = ParleyShaper::new();
1892 let style = TextStyle {
1893 font: FontId::default(),
1894 size: Px(16.0),
1895 ..Default::default()
1896 };
1897 let input = TextInputRef::plain("hello", &style);
1898
1899 let layout = shaper.shape_single_line(input, 1.0);
1900 assert!(layout.width() >= 0.0);
1901 assert!(!layout.glyphs().is_empty());
1902 assert!(!layout.clusters().is_empty());
1903 }
1904
1905 #[test]
1906 fn clamps_line_height_to_font_extents() {
1907 let mut shaper = ParleyShaper::new_without_system_fonts();
1908 shaper.add_fonts(fret_fonts::test_support::face_blobs(
1909 fret_fonts::default_profile().faces.iter(),
1910 ));
1911
1912 let style = TextStyle {
1913 font: FontId::default(),
1914 size: Px(16.0),
1915 line_height: Some(Px(1.0)),
1916 ..Default::default()
1917 };
1918 let input = TextInputRef::plain("Hello", &style);
1919
1920 let layout = shaper.shape_single_line(input, 1.0);
1921 let min = min_line_height_for_metrics(layout.ascent, layout.descent);
1922 assert!(
1923 layout.line_height + 0.001 >= min,
1924 "line_height={} ascent={} descent={} min={}",
1925 layout.line_height,
1926 layout.ascent,
1927 layout.descent,
1928 min
1929 );
1930 }
1931
1932 #[test]
1933 fn normalizes_descent_to_positive_magnitude() {
1934 let mut shaper = ParleyShaper::new_without_system_fonts();
1935 shaper.add_fonts(fret_fonts::test_support::face_blobs(
1936 fret_fonts::default_profile().faces.iter(),
1937 ));
1938
1939 let style = TextStyle {
1940 font: FontId::default(),
1941 size: Px(16.0),
1942 ..Default::default()
1943 };
1944 let input = TextInputRef::plain("Hello", &style);
1945
1946 let layout = shaper.shape_single_line_metrics(input, 1.0);
1947 assert!(
1948 layout.descent >= -0.001,
1949 "expected descent to be non-negative; descent={}",
1950 layout.descent
1951 );
1952 assert!(
1953 layout.line_height + 0.001 >= layout.ascent + layout.descent,
1954 "expected line_height >= ascent+descent; line_height={} ascent={} descent={}",
1955 layout.line_height,
1956 layout.ascent,
1957 layout.descent
1958 );
1959 }
1960
1961 #[test]
1962 fn fixed_line_box_policy_keeps_line_height_stable_across_fallback_fonts() {
1963 let mut shaper = shaper_with_bundled_fonts();
1964
1965 let style = TextStyle {
1966 font: FontId::default(),
1967 size: Px(16.0),
1968 line_height: Some(Px(18.0)),
1969 line_height_policy: TextLineHeightPolicy::FixedFromStyle,
1970 ..Default::default()
1971 };
1972
1973 let latin = shaper.shape_single_line_metrics(TextInputRef::plain("Hello", &style), 1.0);
1974 let emoji = shaper.shape_single_line_metrics(TextInputRef::plain("😀", &style), 1.0);
1975 let cjk = shaper.shape_single_line_metrics(TextInputRef::plain("ä½ å¥½", &style), 1.0);
1976
1977 for (name, line) in [("latin", latin), ("emoji", emoji), ("cjk", cjk)] {
1978 assert!(
1979 (line.line_height - 18.0).abs() < 0.01,
1980 "expected fixed line_height=18px; {name} line_height={}",
1981 line.line_height
1982 );
1983 }
1984 }
1985
1986 #[test]
1987 fn respects_explicit_line_height_override() {
1988 let mut shaper = ParleyShaper::new_without_system_fonts();
1989 shaper.add_fonts(fret_fonts::test_support::face_blobs(
1990 fret_fonts::default_profile().faces.iter(),
1991 ));
1992
1993 let style = TextStyle {
1994 font: FontId::default(),
1995 size: Px(16.0),
1996 line_height: Some(Px(40.0)),
1997 ..Default::default()
1998 };
1999 let input = TextInputRef::plain("Hello", &style);
2000
2001 let layout = shaper.shape_single_line(input, 1.0);
2002 assert!(layout.line_height + 0.001 >= 40.0);
2003 }
2004
2005 #[test]
2006 fn explicit_line_height_increases_baseline_via_half_leading() {
2007 let mut shaper = ParleyShaper::new_without_system_fonts();
2008 shaper.add_fonts(fret_fonts::test_support::face_blobs(
2009 fret_fonts::default_profile().faces.iter(),
2010 ));
2011
2012 let base = TextStyle {
2013 font: FontId::default(),
2014 size: Px(14.0),
2015 line_height: None,
2016 ..Default::default()
2017 };
2018 let tall = TextStyle {
2019 line_height: Some(Px(20.0)),
2020 ..base.clone()
2021 };
2022
2023 let a = shaper.shape_single_line_metrics(TextInputRef::plain("Hello", &base), 1.0);
2024 let b = shaper.shape_single_line_metrics(TextInputRef::plain("Hello", &tall), 1.0);
2025
2026 assert!(
2027 b.baseline > a.baseline + 0.1,
2028 "expected baseline to increase when line_height expands (half-leading); a.baseline={} b.baseline={} a.line_height={} b.line_height={}",
2029 a.baseline,
2030 b.baseline,
2031 a.line_height,
2032 b.line_height
2033 );
2034 assert!(
2035 b.baseline <= b.line_height + 0.001,
2036 "expected baseline to remain within the line box; baseline={} line_height={}",
2037 b.baseline,
2038 b.line_height
2039 );
2040 }
2041
2042 #[test]
2043 fn proportional_leading_distribution_increases_baseline_shift() {
2044 let mut shaper = shaper_with_bundled_fonts();
2045
2046 let base = TextStyle {
2047 font: FontId::family("Inter"),
2048 size: Px(14.0),
2049 line_height: Some(Px(40.0)),
2050 line_height_policy: TextLineHeightPolicy::FixedFromStyle,
2051 ..Default::default()
2052 };
2053
2054 let even = shaper.shape_single_line_metrics(TextInputRef::plain("Hello", &base), 1.0);
2055
2056 let proportional_style = TextStyle {
2057 leading_distribution: TextLeadingDistribution::Proportional,
2058 ..base
2059 };
2060 let proportional = shaper
2061 .shape_single_line_metrics(TextInputRef::plain("Hello", &proportional_style), 1.0);
2062 let factor_even = leading_distribution_top_factor(
2063 TextLeadingDistribution::Even,
2064 even.ascent,
2065 even.descent,
2066 );
2067 let factor_prop = leading_distribution_top_factor(
2068 TextLeadingDistribution::Proportional,
2069 proportional.ascent,
2070 proportional.descent,
2071 );
2072
2073 assert!(
2074 proportional.baseline > even.baseline + 0.01,
2075 "expected proportional leading to bias extra leading upward; even.baseline={} proportional.baseline={} line_height={} even(ascent={},descent={},factor={}) proportional(ascent={},descent={},factor={})",
2076 even.baseline,
2077 proportional.baseline,
2078 proportional.line_height,
2079 even.ascent,
2080 even.descent,
2081 factor_even,
2082 proportional.ascent,
2083 proportional.descent,
2084 factor_prop
2085 );
2086 assert!(
2087 proportional.baseline <= proportional.line_height + 0.001,
2088 "expected baseline to remain within the line box; baseline={} line_height={}",
2089 proportional.baseline,
2090 proportional.line_height
2091 );
2092 }
2093
2094 #[test]
2095 fn fixed_line_box_baseline_normalizes_negative_descent() {
2096 let ascent = 9.0_f32;
2097 let descent = 3.0_f32;
2098 let line_height = 16.0_f32;
2099
2100 let baseline_pos = baseline_for_fixed_line_box(
2101 ascent,
2102 descent,
2103 line_height,
2104 TextLeadingDistribution::Even,
2105 );
2106 let baseline_neg = baseline_for_fixed_line_box(
2107 ascent,
2108 -descent,
2109 line_height,
2110 TextLeadingDistribution::Even,
2111 );
2112
2113 assert!(
2114 (baseline_pos - baseline_neg).abs() < 0.0001,
2115 "expected negative descent to be normalized; baseline_pos={baseline_pos} baseline_neg={baseline_neg}"
2116 );
2117 assert!(
2118 baseline_pos > 0.0 && baseline_pos < line_height,
2119 "expected baseline to remain within the line box; baseline={baseline_pos} line_height={line_height}"
2120 );
2121 }
2122
2123 #[test]
2124 fn strut_force_keeps_metrics_stable_without_explicit_line_height() {
2125 let mut shaper = shaper_with_bundled_fonts();
2126
2127 let style = TextStyle {
2128 font: FontId::family("Inter"),
2129 size: Px(16.0),
2130 strut_style: Some(fret_core::TextStrutStyle {
2131 force: true,
2132 ..Default::default()
2133 }),
2134 ..Default::default()
2135 };
2136
2137 let latin = shaper.shape_single_line_metrics(TextInputRef::plain("Hello", &style), 1.0);
2138 let latin_line_height = latin.line_height;
2139 let latin_baseline = latin.baseline;
2140 let emoji = shaper.shape_single_line_metrics(TextInputRef::plain("😀", &style), 1.0);
2141 let cjk = shaper.shape_single_line_metrics(TextInputRef::plain("ä½ å¥½", &style), 1.0);
2142
2143 for (name, line) in [("latin", latin), ("emoji", emoji), ("cjk", cjk)] {
2144 assert!(
2145 (line.line_height - latin_line_height).abs() < 0.01,
2146 "expected strut-forced line height to be stable; {name} line_height={} latin={}",
2147 line.line_height,
2148 latin_line_height
2149 );
2150 assert!(
2151 (line.baseline - latin_baseline).abs() < 0.01,
2152 "expected strut-forced baseline to be stable; {name} baseline={} latin={}",
2153 line.baseline,
2154 latin_baseline
2155 );
2156 }
2157 }
2158
2159 #[test]
2160 fn font_catalog_monospace_probe_can_be_disabled() {
2161 let lock = env_lock().lock().unwrap();
2162 unsafe {
2163 std::env::set_var("FRET_TEXT_FONT_CATALOG_MONOSPACE_PROBE", "0");
2164 }
2165
2166 let mut shaper = ParleyShaper::new();
2167 let entries = shaper.all_font_catalog_entries();
2168 assert!(
2169 entries.iter().all(|e| !e.is_monospace_candidate()),
2170 "expected monospace candidates to be suppressed when probe is disabled"
2171 );
2172
2173 unsafe {
2174 std::env::remove_var("FRET_TEXT_FONT_CATALOG_MONOSPACE_PROBE");
2175 }
2176 drop(lock);
2177 }
2178}