mos_fonts/shape.rs
1use crate::{
2 EmbeddedFontId, Font, ShapedGlyph, advance_units_to_pt, normalize::nfc_text, shape, text_width,
3};
4
5/// Shape `text` against `font` and return both the glyph stream and
6/// the advance widths in user-space points. Callers that need only the
7/// width can use [`text_width`]; callers that will also emit glyphs
8/// downstream should use this to avoid shaping twice.
9///
10/// Input is normalized through [`crate::nfc_text`] before shaping, so
11/// decomposed sequences are precomposed to Unicode NFC. Returned glyph
12/// cluster offsets are byte offsets into that normalized text, not
13/// necessarily the caller's original string.
14///
15/// For Base14 faces, `glyphs` is empty (Base14 runs go out as
16/// `WinAnsi`-byte strings, not glyph IDs); only the width is computed.
17///
18/// # Examples
19///
20/// ```
21/// use mos_fonts::{Base14Font, Font, shape_text};
22///
23/// let run = shape_text(Font::Base14(Base14Font::Helvetica), 10.0, "A");
24///
25/// assert!(run.glyphs.is_empty());
26/// assert_eq!(run.advance_pt, 6.67);
27/// ```
28#[must_use]
29pub fn shape_text(font: Font, size: f32, text: &str) -> ShapedRun {
30 let text = nfc_text(text);
31 let text = text.as_ref();
32 match font {
33 Font::Base14(_) => ShapedRun {
34 glyphs: Vec::new(),
35 advance_pt: text_width(font, size, text),
36 },
37 Font::Embedded(id) => {
38 let ef = id.data();
39 let glyphs = shape(ef, text);
40 let upem = f32::from(ef.units_per_em);
41 let advance_pt: f32 = glyphs
42 .iter()
43 .map(|g| advance_units_to_pt(g.advance_units, size, upem))
44 .sum();
45 ShapedRun { glyphs, advance_pt }
46 }
47 }
48}
49
50/// Output of [`shape_text`]: the shaped glyph stream and the total
51/// advance width at the requested point size.
52///
53/// # Examples
54///
55/// ```
56/// use mos_fonts::ShapedRun;
57///
58/// let run = ShapedRun {
59/// glyphs: Vec::new(),
60/// advance_pt: 12.0,
61/// };
62///
63/// assert_eq!(run.advance_pt, 12.0);
64/// ```
65#[derive(Debug, Clone)]
66pub struct ShapedRun {
67 /// Glyphs in visual order (LTR). Empty for Base14 runs.
68 pub glyphs: Vec<ShapedGlyph>,
69 /// Total horizontal advance of the run, in PDF user-space units.
70 pub advance_pt: f32,
71}
72
73/// One face's slice of a per-glyph-fallback shaping result. A word
74/// shaped through [`shape_with_fallback`] produces a `Vec<WordSubRun>`;
75/// each sub-run is self-contained: its `text` is the source UTF-8 slice
76/// covered by exactly this sub-run, its `glyphs`' `cluster` offsets are
77/// **rebased to the sub-run's local `text`** (so `plan_embedded` can
78/// build `/ToUnicode` without knowing about the parent word), and its
79/// `advance_pt` is the sum of per-glyph advances at the requested
80/// point size.
81///
82/// Caller emits **one PDF `TextRun` per `WordSubRun`**: same
83/// baseline, x-cursor advances by `advance_pt` between sub-runs: and
84/// PDF emit's existing `Tf` switch fires naturally on the font change.
85///
86/// # Examples
87///
88/// ```
89/// use mos_fonts::{Base14Font, Font, WordSubRun};
90///
91/// let subrun = WordSubRun {
92/// font: Font::Base14(Base14Font::Helvetica),
93/// text: "A".to_owned(),
94/// glyphs: Vec::new(),
95/// advance_pt: 6.67,
96/// };
97///
98/// assert_eq!(subrun.text, "A");
99/// ```
100#[derive(Debug, Clone)]
101pub struct WordSubRun {
102 /// Which face owns the glyphs in this slice. May be the primary
103 /// (no fallback was needed for this span) or a fallback face that
104 /// covered codepoints the primary lacked.
105 pub font: Font,
106 /// Source byte slice covered by this sub-run. `glyphs`' `cluster`
107 /// values are byte offsets into **this** field, not into the parent
108 /// word's text.
109 pub text: String,
110 /// Shaped glyphs in visual order (LTR). Cluster offsets are local
111 /// to `text` (rebased from the parent word's full text). Empty for
112 /// Base14 sub-runs (Base14 has no glyph stream: PDF emit goes via
113 /// `WinAnsi`-byte encoding instead).
114 pub glyphs: Vec<ShapedGlyph>,
115 /// Total horizontal advance of this sub-run, in PDF user-space
116 /// units. Sum of PDF-emittable `glyphs[i].advance_units` scaled by
117 /// `size_pt / units_per_em`.
118 pub advance_pt: f32,
119}
120
121/// Shape `text` against `primary` with per-glyph fallback. Walks each
122/// HarfBuzz cluster in the primary's shaped output; clusters that
123/// contain any `.notdef` (GID 0) glyph are re-shaped against each
124/// embedded face in `fallbacks` in order. The first fallback to produce a
125/// glyph stream with no `.notdef` wins the whole cluster (cluster-
126/// granular replacement, never partial: partial replacement would
127/// duplicate bases, drop marks, break ligatures).
128///
129/// Returns one [`WordSubRun`] per contiguous source span that shares
130/// a face. Each sub-run's `glyphs` `cluster` offsets are rebased to
131/// the sub-run's local `text`, so `mos-pdf::plan_embedded` reads
132/// `/ToUnicode` clusters with no awareness of the parent word.
133///
134/// Input is normalized through [`crate::nfc_text`] before fallback
135/// shaping. Each returned [`WordSubRun::text`] is therefore a slice of
136/// the normalized NFC string; decomposed caller input may not be
137/// byte-identical to returned text.
138///
139/// Base14 `primary`: returns a single sub-run with empty `glyphs`
140/// (Base14 has no glyph stream to inspect for `.notdef`; fallback
141/// isn't meaningful for that path). The advance comes from the AFM
142/// width sum via [`text_width`], same as the legacy `shape_text`
143/// path.
144///
145/// All-fallback-fails behaviour: if no face in `fallbacks` covers a
146/// `.notdef` cluster, the cluster stays in `primary` with `.notdef`
147/// glyphs. `plan_embedded` already skips GID 0 from `gid_to_text`,
148/// so copy-paste extraction is correct (empty for the un-renderable
149/// span); the PDF reader renders an empty box.
150///
151/// # Examples
152///
153/// ```
154/// use mos_fonts::{Base14Font, Font, shape_with_fallback};
155///
156/// let subruns = shape_with_fallback(Font::Base14(Base14Font::Helvetica), &[], 10.0, "A");
157///
158/// assert_eq!(subruns.len(), 1);
159/// assert_eq!(subruns[0].text, "A");
160/// ```
161#[must_use]
162pub fn shape_with_fallback(
163 primary: Font,
164 fallbacks: &[EmbeddedFontId],
165 size_pt: f32,
166 text: &str,
167) -> Vec<WordSubRun> {
168 let text = nfc_text(text);
169 let text = text.as_ref();
170 if text.is_empty() {
171 return Vec::new();
172 }
173
174 let primary_id = match primary {
175 Font::Base14(_) => {
176 // Base14: no glyph stream available, fallback doesn't apply.
177 return vec![WordSubRun {
178 font: primary,
179 text: text.to_owned(),
180 glyphs: Vec::new(),
181 advance_pt: text_width(primary, size_pt, text),
182 }];
183 }
184 Font::Embedded(id) => id,
185 };
186
187 let primary_ef = primary_id.data();
188 let primary_glyphs = shape(primary_ef, text);
189
190 if primary_glyphs.iter().all(|g| g.gid != 0) || fallbacks.is_empty() {
191 // No `.notdef`, OR no fallbacks configured. One sub-run.
192 return vec![into_subrun(
193 primary,
194 text.to_owned(),
195 primary_glyphs,
196 primary_ef.units_per_em,
197 size_pt,
198 )];
199 }
200
201 // Group primary glyphs by cluster. Each cluster covers source
202 // bytes `[c_n..c_{n+1})` (last cluster runs to `text.len()`).
203 let clusters = group_clusters(&primary_glyphs, text.len());
204
205 // Per-cluster resolution: which face owns it + which glyphs to use.
206 // `glyphs` here carry `cluster` offsets into the *parent* `text`;
207 // rebasing to sub-run-local offsets happens at the merge step.
208 let mut resolutions: Vec<ClusterResolution> = Vec::with_capacity(clusters.len());
209 for cluster in &clusters {
210 let has_notdef = cluster.glyphs.iter().any(|g| g.gid == 0);
211 if !has_notdef {
212 resolutions.push(ClusterResolution {
213 font: primary,
214 byte_range: cluster.byte_range.clone(),
215 glyphs: cluster.glyphs.clone(),
216 });
217 continue;
218 }
219 // Retry against each fallback. Cluster-granular: replace the
220 // entire cluster's glyph slice if a fallback covers it.
221 let cluster_text = &text[cluster.byte_range.clone()];
222 let mut accepted: Option<(Font, Vec<ShapedGlyph>)> = None;
223 for &fb_id in fallbacks {
224 let fb_font = Font::Embedded(fb_id);
225 let fb_ef = fb_id.data();
226 let fb_glyphs = shape(fb_ef, cluster_text);
227 if !fb_glyphs.is_empty() && fb_glyphs.iter().all(|g| g.gid != 0) {
228 // Shift fallback glyph clusters into the parent text's
229 // coordinate system; rebasing to the sub-run's local
230 // text happens in the merge step.
231 // `cluster.byte_range.start` is a byte offset into a `&str`
232 // that's at most `text.len()` bytes long. We pipe through
233 // `u32::try_from` for the lint, saturating to u32::MAX in the
234 // unreachable case of source strings ≥ 4 GiB.
235 let shift = u32::try_from(cluster.byte_range.start).unwrap_or(u32::MAX);
236 let shifted: Vec<_> = fb_glyphs
237 .into_iter()
238 .map(|g| ShapedGlyph {
239 cluster: g.cluster + shift,
240 ..g
241 })
242 .collect();
243 accepted = Some((fb_font, shifted));
244 break;
245 }
246 }
247 match accepted {
248 Some((fb_font, fb_glyphs)) => resolutions.push(ClusterResolution {
249 font: fb_font,
250 byte_range: cluster.byte_range.clone(),
251 glyphs: fb_glyphs,
252 }),
253 None => resolutions.push(ClusterResolution {
254 font: primary,
255 byte_range: cluster.byte_range.clone(),
256 glyphs: cluster.glyphs.clone(),
257 }),
258 }
259 }
260
261 // Merge adjacent same-font resolutions into one sub-run apiece.
262 let mut subruns: Vec<WordSubRun> = Vec::new();
263 let mut current: Option<(Font, std::ops::Range<usize>, Vec<ShapedGlyph>)> = None;
264 for res in resolutions {
265 match current.take() {
266 Some((font, range, mut glyphs)) if font == res.font => {
267 let new_range = range.start..res.byte_range.end;
268 glyphs.extend(res.glyphs);
269 current = Some((font, new_range, glyphs));
270 }
271 Some((font, range, glyphs)) => {
272 subruns.push(finalize_subrun(font, range, glyphs, text, size_pt));
273 current = Some((res.font, res.byte_range, res.glyphs));
274 }
275 None => current = Some((res.font, res.byte_range, res.glyphs)),
276 }
277 }
278 if let Some((font, range, glyphs)) = current {
279 subruns.push(finalize_subrun(font, range, glyphs, text, size_pt));
280 }
281 subruns
282}
283
284/// Internal: one HarfBuzz cluster's worth of primary-shaped glyphs
285/// plus the cluster's source byte range.
286struct ClusterGroup {
287 byte_range: std::ops::Range<usize>,
288 glyphs: Vec<ShapedGlyph>,
289}
290
291/// Internal: one cluster's resolution after fallback retry. `glyphs`
292/// carry `cluster` offsets into the parent word text.
293struct ClusterResolution {
294 font: Font,
295 byte_range: std::ops::Range<usize>,
296 glyphs: Vec<ShapedGlyph>,
297}
298
299/// Walk a `rustybuzz`-ordered LTR glyph stream and group consecutive
300/// glyphs sharing the same `cluster` value. Each group's byte range is
301/// `[c..c_next)` where `c_next` is the next cluster's start (or
302/// `text_len` for the last cluster). The shaper currently forces LTR;
303/// RTL support must revisit this monotonic-cluster assumption.
304fn group_clusters(glyphs: &[ShapedGlyph], text_len: usize) -> Vec<ClusterGroup> {
305 let mut groups: Vec<ClusterGroup> = Vec::new();
306 let mut i = 0;
307 while i < glyphs.len() {
308 let cluster = glyphs[i].cluster;
309 let mut j = i + 1;
310 while j < glyphs.len() && glyphs[j].cluster == cluster {
311 j += 1;
312 }
313 let end_byte = if j < glyphs.len() {
314 glyphs[j].cluster as usize
315 } else {
316 text_len
317 };
318 debug_assert!(end_byte >= cluster as usize);
319 groups.push(ClusterGroup {
320 byte_range: (cluster as usize)..end_byte,
321 glyphs: glyphs[i..j].to_vec(),
322 });
323 i = j;
324 }
325 groups
326}
327
328/// Convert a `(font, byte_range, parent-relative glyphs)` triple into
329/// a fully-baked [`WordSubRun`]: slices `parent_text` to the sub-run's
330/// local string, rebases glyph clusters to that local string, sums
331/// the advance.
332fn finalize_subrun(
333 font: Font,
334 byte_range: std::ops::Range<usize>,
335 glyphs: Vec<ShapedGlyph>,
336 parent_text: &str,
337 size_pt: f32,
338) -> WordSubRun {
339 let local_text = parent_text[byte_range.clone()].to_owned();
340 // Sub-run byte ranges are bounded by the parent word's `text.len()`,
341 // always well below u32::MAX in practice. Saturating cast keeps clippy
342 // happy without an `#[allow]` annotation; the saturation branch is
343 // unreachable for any realistic input.
344 let shift = u32::try_from(byte_range.start).unwrap_or(u32::MAX);
345 let rebased: Vec<_> = glyphs
346 .into_iter()
347 .map(|g| ShapedGlyph {
348 cluster: g.cluster.saturating_sub(shift),
349 ..g
350 })
351 .collect();
352 let advance_pt: f32 = match font {
353 Font::Embedded(id) => {
354 let upem = embedded_upem(id);
355 rebased
356 .iter()
357 .map(|g| advance_units_to_pt(g.advance_units, size_pt, upem))
358 .sum()
359 }
360 Font::Base14(_) => text_width(font, size_pt, &local_text),
361 };
362 WordSubRun {
363 font,
364 text: local_text,
365 glyphs: rebased,
366 advance_pt,
367 }
368}
369
370/// Single-sub-run packaging when no fallback retry was needed. Skips
371/// the rebasing branch: the input glyphs already have cluster offsets
372/// relative to `local_text` (which is the full parent text).
373fn into_subrun(
374 font: Font,
375 text: String,
376 glyphs: Vec<ShapedGlyph>,
377 upem: u16,
378 size_pt: f32,
379) -> WordSubRun {
380 let upem_f = f32::from(upem);
381 let advance_pt: f32 = glyphs
382 .iter()
383 .map(|g| advance_units_to_pt(g.advance_units, size_pt, upem_f))
384 .sum();
385 WordSubRun {
386 font,
387 text,
388 glyphs,
389 advance_pt,
390 }
391}
392
393/// `units_per_em` for an embedded face.
394fn embedded_upem(id: EmbeddedFontId) -> f32 {
395 f32::from(id.data().units_per_em)
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::Base14Font;
402
403 #[test]
404 fn embedded_shape_is_empty_for_empty_string() {
405 let ef = EmbeddedFontId::Regular.data();
406 let glyphs = shape(ef, "");
407 assert!(glyphs.is_empty());
408 }
409
410 #[test]
411 fn embedded_shape_returns_clusters_in_byte_order() {
412 let ef = EmbeddedFontId::Regular.data();
413 let glyphs = shape(ef, "Привет");
414 assert!(!glyphs.is_empty());
415 // Cluster values are byte offsets into the source string and
416 // must be monotonically non-decreasing for LTR text.
417 let mut prev: u32 = 0;
418 for g in &glyphs {
419 assert!(
420 g.cluster >= prev,
421 "cluster regression: {prev} -> {}",
422 g.cluster
423 );
424 prev = g.cluster;
425 }
426 }
427
428 #[test]
429 fn embedded_shape_preserves_gpos_kerning() {
430 let ef = EmbeddedFontId::Regular.data();
431 let glyphs = shape(ef, "AV");
432 assert!(!glyphs.is_empty());
433 let nominal: i32 = glyphs
434 .iter()
435 .map(|g| i32::from(ef.advance_units(g.gid)))
436 .sum();
437 let shaped: i32 = glyphs.iter().map(|g| g.advance_units).sum();
438
439 assert!(
440 shaped < nominal,
441 "expected AV kerning to tighten advance: shaped={shaped} nominal={nominal}"
442 );
443 }
444
445 #[test]
446 fn embedded_shape_preserves_combining_mark_offsets() {
447 let ef = EmbeddedFontId::Regular.data();
448 let glyphs = shape(ef, "q\u{0302}\u{0301}");
449 assert!(
450 glyphs.len() >= 3,
451 "expected base 'q' + 2 combining marks, got {glyphs:?}"
452 );
453 assert!(
454 glyphs[1..]
455 .iter()
456 .any(|g| g.x_offset_units != 0 || g.y_offset_units != 0),
457 "expected at least one combining mark offset, got {glyphs:?}"
458 );
459 }
460
461 #[test]
462 fn shape_text_normalizes_decomposed_romanian() {
463 let font = Font::Embedded(EmbeddedFontId::Regular);
464 let decomposed = shape_text(font, 12.0, "S\u{0326}");
465 let precomposed = shape_text(font, 12.0, "\u{0218}");
466
467 let decomposed_gids: Vec<u16> = decomposed.glyphs.iter().map(|g| g.gid).collect();
468 let precomposed_gids: Vec<u16> = precomposed.glyphs.iter().map(|g| g.gid).collect();
469 assert_eq!(decomposed_gids, precomposed_gids);
470 assert!((decomposed.advance_pt - precomposed.advance_pt).abs() < f32::EPSILON);
471 }
472
473 #[test]
474 fn embedded_fi_ligature_collapses_glyphs() {
475 // Noto Sans contains an `fi` ligature; rustybuzz returns one
476 // glyph for `fi` (not two). The substituted gid differs from
477 // both the standalone `f` and `i` gids. (Noto Sans's `fi`
478 // ligature has the same advance as f+i: purely visual,
479 // joining the dot of `i` with the terminal of `f`, so width
480 // is not a useful invariant for this font.)
481 let ef = EmbeddedFontId::Regular.data();
482 let fi = shape(ef, "fi");
483 let f = shape(ef, "f");
484 let i = shape(ef, "i");
485 assert_eq!(fi.len(), 1, "expected fi ligature, got glyphs {fi:?}");
486 assert_ne!(fi[0].gid, f[0].gid);
487 assert_ne!(fi[0].gid, i[0].gid);
488 }
489
490 #[test]
491 fn fallback_empty_text_returns_empty() {
492 let primary = Font::Embedded(EmbeddedFontId::Regular);
493 let fallbacks = &[EmbeddedFontId::Math];
494 assert!(shape_with_fallback(primary, fallbacks, 11.0, "").is_empty());
495 }
496
497 #[test]
498 fn fallback_pure_primary_returns_single_subrun() {
499 // Pure ASCII is fully covered by Noto Sans Regular: no
500 // fallback needed; one sub-run, primary-owned glyphs.
501 let primary = Font::Embedded(EmbeddedFontId::Regular);
502 let fallbacks = &[EmbeddedFontId::Math];
503 let subs = shape_with_fallback(primary, fallbacks, 11.0, "Hello");
504 assert_eq!(subs.len(), 1, "expected one sub-run, got {}", subs.len());
505 assert_eq!(subs[0].font, primary);
506 assert_eq!(subs[0].text, "Hello");
507 assert!(!subs[0].glyphs.is_empty());
508 assert!(subs[0].glyphs.iter().all(|g| g.gid != 0));
509 assert!(subs[0].advance_pt > 0.0);
510 }
511
512 #[test]
513 fn fallback_shape_normalizes_subrun_text() {
514 let primary = Font::Embedded(EmbeddedFontId::Regular);
515 let fallbacks = &[EmbeddedFontId::Math];
516 let subs = shape_with_fallback(primary, fallbacks, 11.0, "S\u{0326}");
517
518 assert_eq!(subs.len(), 1);
519 assert_eq!(subs[0].text, "\u{0218}");
520 }
521
522 #[test]
523 fn fallback_mixed_latin_and_math_produces_alternating_subruns() {
524 // `a≤b`: Latin `a` covered by Regular, math `≤` (U+2264) needs
525 // the Math fallback, Latin `b` covered by Regular again. Expect
526 // three sub-runs in source order. Each sub-run's text is its
527 // own slice; glyph clusters rebased to local text.
528 let primary = Font::Embedded(EmbeddedFontId::Regular);
529 let fallbacks = &[EmbeddedFontId::Math];
530 let subs = shape_with_fallback(primary, fallbacks, 11.0, "a\u{2264}b");
531 assert_eq!(subs.len(), 3, "expected 3 sub-runs, got {subs:?}");
532
533 assert_eq!(subs[0].font, primary);
534 assert_eq!(subs[0].text, "a");
535
536 assert_eq!(subs[1].font, Font::Embedded(EmbeddedFontId::Math));
537 assert_eq!(subs[1].text, "\u{2264}");
538 // Math sub-run glyph clusters must be local: cluster 0 points
539 // at the start of "≤", not at offset 1 in the parent word.
540 assert!(
541 subs[1]
542 .glyphs
543 .iter()
544 .all(|g| (g.cluster as usize) < subs[1].text.len()),
545 "math sub-run glyph clusters not rebased: {:?}",
546 subs[1].glyphs,
547 );
548
549 assert_eq!(subs[2].font, primary);
550 assert_eq!(subs[2].text, "b");
551 }
552
553 #[test]
554 fn fallback_all_fail_keeps_primary_notdef() {
555 // Emoji 🎉 (U+1F389) is not in Noto Sans Regular OR Math.
556 // The cluster stays with primary as `.notdef`; no panic, no
557 // duplication, copy-paste yields the source codepoint via the
558 // existing `/ToUnicode` machinery (PDF reader paints empty box).
559 let primary = Font::Embedded(EmbeddedFontId::Regular);
560 let fallbacks = &[EmbeddedFontId::Math];
561 let subs = shape_with_fallback(primary, fallbacks, 11.0, "\u{1F389}");
562 assert_eq!(subs.len(), 1);
563 assert_eq!(subs[0].font, primary);
564 assert!(
565 subs[0].glyphs.iter().any(|g| g.gid == 0),
566 "expected .notdef for unsupported emoji, got {:?}",
567 subs[0].glyphs,
568 );
569 }
570
571 #[test]
572 fn fallback_base14_primary_returns_empty_glyphs_subrun() {
573 // Base14 has no glyph stream to inspect for `.notdef`; fallback
574 // doesn't apply. One sub-run, empty glyphs, advance via the
575 // AFM `text_width` path.
576 let primary = Font::Base14(Base14Font::Helvetica);
577 let fallbacks = &[EmbeddedFontId::Math];
578 let subs = shape_with_fallback(primary, fallbacks, 11.0, "Hello");
579 assert_eq!(subs.len(), 1);
580 assert_eq!(subs[0].font, primary);
581 assert!(subs[0].glyphs.is_empty());
582 assert!(subs[0].advance_pt > 0.0);
583 }
584
585 #[test]
586 fn fallback_no_fallbacks_configured_returns_single_subrun_even_with_notdef() {
587 // Empty fallback chain: even if the primary has .notdef, we
588 // produce one sub-run with whatever the primary shaped. PDF
589 // reader paints empty boxes; no panic.
590 let primary = Font::Embedded(EmbeddedFontId::Regular);
591 let subs = shape_with_fallback(primary, &[], 11.0, "\u{2264}");
592 assert_eq!(subs.len(), 1);
593 assert_eq!(subs[0].font, primary);
594 }
595
596 #[test]
597 fn fallback_math_subrun_advance_uses_math_face_upem() {
598 // Sanity: the math sub-run's `advance_pt` is computed from
599 // Math's `units_per_em`, not Regular's. Both happen to be
600 // 1000 in Noto Sans, but the contract should hold regardless.
601 let primary = Font::Embedded(EmbeddedFontId::Regular);
602 let fallbacks = &[EmbeddedFontId::Math];
603 let subs = shape_with_fallback(primary, fallbacks, 11.0, "\u{2264}");
604 assert_eq!(subs.len(), 1);
605 assert_eq!(subs[0].font, Font::Embedded(EmbeddedFontId::Math));
606 assert!(subs[0].advance_pt > 0.0);
607 }
608}