Skip to main content

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}