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