Skip to main content

mos_fonts/
shape.rs

1use std::borrow::Cow;
2
3use crate::{
4    EmbeddedFontId, Font, ShapedGlyph, advance_units_to_pt, normalize::nfc_text, shape, text_width,
5};
6
7/// Shape `text` against `font`.
8///
9/// Returns both glyph stream and advance width. Callers that will emit glyphs
10/// downstream should use this to avoid shaping twice.
11///
12/// Input is normalized through [`crate::nfc_text`] before shaping, so
13/// decomposed sequences are precomposed to Unicode NFC. Returned glyph
14/// cluster offsets are byte offsets into that normalized text, not
15/// necessarily the caller's original string.
16///
17/// For Base14 faces, `glyphs` is empty (Base14 runs go out as
18/// `WinAnsi`-byte strings, not glyph IDs); only the width is computed.
19///
20/// # Examples
21///
22/// ```
23/// use mos_fonts::{Base14Font, Font, shape_text};
24///
25/// let run = shape_text(Font::Base14(Base14Font::Helvetica), 10.0, "A");
26///
27/// assert!(run.glyphs.is_empty());
28/// assert_eq!(run.advance_pt, 6.67);
29/// ```
30#[must_use]
31pub fn shape_text(font: Font, size: f32, text: &str) -> ShapedRun {
32    let text = nfc_text(text);
33    let text = text.as_ref();
34    match font {
35        Font::Base14(_) => ShapedRun {
36            glyphs: Vec::new(),
37            advance_pt: text_width(font, size, text),
38        },
39        Font::Embedded(id) => {
40            let ef = id.data();
41            let glyphs = shape(ef, text);
42            let upem = f32::from(ef.units_per_em);
43            let advance_pt: f32 = glyphs
44                .iter()
45                .map(|g| advance_units_to_pt(g.advance_units, size, upem))
46                .sum();
47            ShapedRun { glyphs, advance_pt }
48        }
49    }
50}
51
52/// Output of [`shape_text`]: the shaped glyph stream and the total
53/// advance width at the requested point size.
54///
55/// # Examples
56///
57/// ```
58/// use mos_fonts::ShapedRun;
59///
60/// let run = ShapedRun {
61///     glyphs: Vec::new(),
62///     advance_pt: 12.0,
63/// };
64///
65/// assert_eq!(run.advance_pt, 12.0);
66/// ```
67#[derive(Debug, Clone)]
68pub struct ShapedRun {
69    /// Glyphs in visual order (LTR). Empty for Base14 runs.
70    pub glyphs: Vec<ShapedGlyph>,
71    /// Total horizontal advance of the run, in PDF user-space units.
72    pub advance_pt: f32,
73}
74
75/// One face's slice of a per-glyph-fallback shaping result.
76///
77/// Each sub-run is self-contained: its `text` is the covered source slice,
78/// cluster offsets are rebased to local text, and `advance_pt` is the sum of
79/// per-glyph advances at the requested point size.
80///
81/// Caller emits **one PDF `TextRun` per `WordSubRun`**: same
82/// baseline, x-cursor advances by `advance_pt` between sub-runs: and
83/// PDF emit's existing `Tf` switch fires naturally on the font change.
84///
85/// # Examples
86///
87/// ```
88/// use mos_fonts::{Base14Font, Font, WordSubRun};
89///
90/// let subrun = WordSubRun {
91///     font: Font::Base14(Base14Font::Helvetica),
92///     text: "A".to_owned(),
93///     glyphs: Vec::new(),
94///     advance_pt: 6.67,
95/// };
96///
97/// assert_eq!(subrun.text, "A");
98/// ```
99#[derive(Debug, Clone)]
100pub struct WordSubRun {
101    /// Which face owns the glyphs in this slice. May be the primary
102    /// (no fallback was needed for this span) or a fallback face that
103    /// covered codepoints the primary lacked.
104    pub font: Font,
105    /// Source byte slice covered by this sub-run. `glyphs`' `cluster`
106    /// values are byte offsets into **this** field, not into the parent
107    /// word's text.
108    pub text: String,
109    /// Shaped glyphs in visual order (LTR). Cluster offsets are local
110    /// to `text` (rebased from the parent word's full text). Empty for
111    /// Base14 sub-runs (Base14 has no glyph stream: PDF emit goes via
112    /// `WinAnsi`-byte encoding instead).
113    pub glyphs: Vec<ShapedGlyph>,
114    /// Total horizontal advance of this sub-run, in PDF user-space
115    /// units. Sum of PDF-emittable `glyphs[i].advance_units` scaled by
116    /// `size_pt / units_per_em`.
117    pub advance_pt: f32,
118}
119
120/// Split shaped runs at a byte offset in their concatenated [`WordSubRun::text`].
121///
122/// Complete runs on either side retain their glyphs and measured advances.
123/// A run crossed by the split is reshaped on both sides with its own font and
124/// the supplied fallback chain, so ligatures and positioning across the cut
125/// are recalculated. Each output run's cluster offsets remain local to its text.
126/// Use the same `size_pt` and fallback chain used to produce the input runs.
127/// The offset refers to the runs' text, which may already have been normalized,
128/// not to the document text before shaping.
129///
130/// Returns `None` if the offset exceeds the concatenated text length, falls
131/// inside a UTF-8 codepoint, or the accumulated text length overflows.
132/// Empty input accepts only offset zero. Splitting at either end reuses all
133/// runs without shaping. This operation does not insert a hyphen or choose a
134/// line-break opportunity.
135///
136/// # Examples
137/// ```
138/// use mos_fonts::{Base14Font, Font, shape_with_fallback, split_runs_at};
139///
140/// let runs = shape_with_fallback(Font::Base14(Base14Font::Helvetica), &[], 12.0, "hello");
141/// let (left, right) = split_runs_at(&runs, 2, 12.0, &[]).expect("valid split");
142/// assert_eq!(left[0].text, "he");
143/// assert_eq!(right[0].text, "llo");
144/// ```
145#[must_use]
146pub fn split_runs_at(
147    runs: &[WordSubRun],
148    offset: usize,
149    size_pt: f32,
150    fallbacks: &[EmbeddedFontId],
151) -> Option<(Vec<WordSubRun>, Vec<WordSubRun>)> {
152    let mut prefix = Vec::new();
153    let mut suffix = Vec::new();
154    let mut start = 0_usize;
155    for run in runs {
156        let end = start.checked_add(run.text.len())?;
157        if end <= offset {
158            prefix.push(run.clone());
159        } else if start >= offset {
160            suffix.push(run.clone());
161        } else {
162            let local_offset = offset - start;
163            let before = run.text.get(..local_offset)?;
164            let after = run.text.get(local_offset..)?;
165            prefix.extend(shape_with_fallback(run.font, fallbacks, size_pt, before));
166            suffix.extend(shape_with_fallback(run.font, fallbacks, size_pt, after));
167        }
168        start = end;
169    }
170    (offset <= start).then_some((prefix, suffix))
171}
172
173/// Shape `text` against `primary` with per-glyph fallback.
174///
175/// Clusters containing `.notdef` are re-shaped against each fallback face. The
176/// first fallback with no `.notdef` wins the whole cluster.
177///
178/// Returns one [`WordSubRun`] per contiguous source span that shares
179/// a face. Each sub-run's `glyphs` `cluster` offsets are rebased to
180/// the sub-run's local `text`, so `mos-pdf::plan_embedded` reads
181/// `/ToUnicode` clusters with no awareness of the parent word.
182///
183/// Input is normalized through [`crate::nfc_text`] before fallback
184/// shaping. Each returned [`WordSubRun::text`] is therefore a slice of
185/// the normalized NFC string; decomposed caller input may not be
186/// byte-identical to returned text.
187///
188/// Base14 `primary`: returns a single sub-run with empty `glyphs`
189/// (Base14 has no glyph stream to inspect for `.notdef`; fallback
190/// isn't meaningful for that path). The advance comes from the AFM
191/// width sum via [`text_width`], same as the legacy `shape_text`
192/// path.
193///
194/// All-fallback-fails behaviour: if no face in `fallbacks` covers a
195/// `.notdef` cluster, the cluster stays in `primary` with `.notdef`
196/// glyphs. `plan_embedded` already skips GID 0 from `gid_to_text`,
197/// so copy-paste extraction is correct (empty for the un-renderable
198/// span); the PDF reader renders an empty box.
199///
200/// # Examples
201///
202/// ```
203/// use mos_fonts::{Base14Font, Font, shape_with_fallback};
204///
205/// let subruns = shape_with_fallback(Font::Base14(Base14Font::Helvetica), &[], 10.0, "A");
206///
207/// assert_eq!(subruns.len(), 1);
208/// assert_eq!(subruns[0].text, "A");
209/// ```
210#[must_use]
211pub fn shape_with_fallback(
212    primary: Font,
213    fallbacks: &[EmbeddedFontId],
214    size_pt: f32,
215    text: &str,
216) -> Vec<WordSubRun> {
217    let text = nfc_text(text);
218    let text = text.as_ref();
219    if text.is_empty() {
220        return Vec::new();
221    }
222
223    let primary_id = match primary {
224        Font::Base14(_) => {
225            // Base14: no glyph stream available, fallback doesn't apply.
226            return vec![WordSubRun {
227                font: primary,
228                text: text.to_owned(),
229                glyphs: Vec::new(),
230                advance_pt: text_width(primary, size_pt, text),
231            }];
232        }
233        Font::Embedded(id) => id,
234    };
235
236    let primary_ef = primary_id.data();
237    let primary_glyphs = shape(primary_ef, text);
238
239    if primary_glyphs.iter().all(|g| g.gid != 0) || fallbacks.is_empty() {
240        // No `.notdef`, OR no fallbacks configured. One sub-run.
241        return vec![into_subrun(
242            primary,
243            text.to_owned(),
244            primary_glyphs,
245            primary_ef.units_per_em,
246            size_pt,
247        )];
248    }
249
250    // Group primary glyphs by cluster. Each cluster covers source
251    // bytes `[c_n..c_{n+1})` (last cluster runs to `text.len()`).
252    let clusters = glyph_clusters(&primary_glyphs, text.len());
253
254    // Per-cluster resolution: which face owns it + which glyphs to use.
255    // `glyphs` here carry `cluster` offsets into the *parent* `text`;
256    // rebasing to sub-run-local offsets happens at the merge step.
257    let resolutions = clusters.map(|cluster| {
258        let has_notdef = cluster.glyphs.iter().any(|g| g.gid == 0);
259        if !has_notdef {
260            return ClusterResolution {
261                font: primary,
262                byte_range: cluster.byte_range,
263                glyphs: Cow::Borrowed(cluster.glyphs),
264            };
265        }
266        // Retry against each fallback. Cluster-granular: replace the
267        // entire cluster's glyph slice if a fallback covers it.
268        let cluster_text = &text[cluster.byte_range.clone()];
269        let mut accepted: Option<(Font, Vec<ShapedGlyph>)> = None;
270        for &fb_id in fallbacks {
271            let fb_font = Font::Embedded(fb_id);
272            let fb_ef = fb_id.data();
273            let fb_glyphs = shape(fb_ef, cluster_text);
274            if !fb_glyphs.is_empty() && fb_glyphs.iter().all(|g| g.gid != 0) {
275                // Shift fallback glyph clusters into the parent text's
276                // coordinate system; rebasing to the sub-run's local
277                // text happens in the merge step.
278                // `cluster.byte_range.start` is a byte offset into a `&str`
279                // that's at most `text.len()` bytes long. We pipe through
280                // `u32::try_from` for the lint, saturating to u32::MAX in the
281                // unreachable case of source strings ≥ 4 GiB.
282                let shift = u32::try_from(cluster.byte_range.start).unwrap_or(u32::MAX);
283                let shifted: Vec<_> = fb_glyphs
284                    .into_iter()
285                    .map(|g| ShapedGlyph {
286                        cluster: g.cluster + shift,
287                        ..g
288                    })
289                    .collect();
290                accepted = Some((fb_font, shifted));
291                break;
292            }
293        }
294        match accepted {
295            Some((fb_font, fb_glyphs)) => ClusterResolution {
296                font: fb_font,
297                byte_range: cluster.byte_range,
298                glyphs: Cow::Owned(fb_glyphs),
299            },
300            None => ClusterResolution {
301                font: primary,
302                byte_range: cluster.byte_range,
303                glyphs: Cow::Borrowed(cluster.glyphs),
304            },
305        }
306    });
307
308    // Merge adjacent same-font resolutions into one sub-run apiece.
309    let mut subruns: Vec<WordSubRun> = Vec::new();
310    let mut current: Option<(Font, std::ops::Range<usize>, Vec<ShapedGlyph>)> = None;
311    for res in resolutions {
312        match current.take() {
313            Some((font, range, mut glyphs)) if font == res.font => {
314                let new_range = range.start..res.byte_range.end;
315                glyphs.extend_from_slice(&res.glyphs);
316                current = Some((font, new_range, glyphs));
317            }
318            Some((font, range, glyphs)) => {
319                subruns.push(finalize_subrun(font, range, glyphs, text, size_pt));
320                current = Some((res.font, res.byte_range, res.glyphs.into_owned()));
321            }
322            None => current = Some((res.font, res.byte_range, res.glyphs.into_owned())),
323        }
324    }
325    if let Some((font, range, glyphs)) = current {
326        subruns.push(finalize_subrun(font, range, glyphs, text, size_pt));
327    }
328    subruns
329}
330
331/// A borrowed group of consecutive glyphs sharing a source byte offset.
332///
333/// Glyph positions and cluster offsets remain unchanged from the input run.
334#[derive(Clone, Debug)]
335pub struct GlyphCluster<'a> {
336    /// Source byte range, ending at the next cluster or the supplied text length.
337    pub byte_range: std::ops::Range<usize>,
338    /// Glyphs borrowed directly from the input, in their original order.
339    pub glyphs: &'a [ShapedGlyph],
340}
341
342/// Internal: one cluster's resolution after fallback retry. `glyphs`
343/// carry `cluster` offsets into the parent word text.
344struct ClusterResolution<'a> {
345    font: Font,
346    byte_range: std::ops::Range<usize>,
347    glyphs: Cow<'a, [ShapedGlyph]>,
348}
349
350/// Iterate over a left-to-right shaped glyph stream without allocating or copying glyphs.
351///
352/// Consecutive glyphs with equal cluster offsets form one group. Offsets must be
353/// nondecreasing UTF-8 byte boundaries in the text used for shaping; `text_len`
354/// must be that text's byte length. For [`shape_with_fallback`], use each
355/// [`WordSubRun`]'s normalized text and local glyph stream separately.
356///
357/// This function only groups glyphs: it does not normalize text, validate byte
358/// ranges, rebase offsets, or infer grapheme or line-break boundaries. Callers
359/// accepting externally constructed glyph streams should use `str::get` to
360/// validate each returned range before slicing. Descending RTL clusters are
361/// not supported. Empty glyph streams yield no clusters, including Base-14 runs.
362///
363/// ```
364/// use mos_fonts::{EmbeddedFontId, glyph_clusters, shape};
365///
366/// let text = "office";
367/// let glyphs = shape(EmbeddedFontId::Regular.data(), text);
368/// let parts: Vec<_> = glyph_clusters(&glyphs, text.len())
369///     .filter_map(|cluster| text.get(cluster.byte_range))
370///     .collect();
371/// assert_eq!(parts.concat(), text);
372/// ```
373pub fn glyph_clusters(
374    glyphs: &[ShapedGlyph],
375    text_len: usize,
376) -> impl std::iter::FusedIterator<Item = GlyphCluster<'_>> {
377    let mut start = 0;
378    std::iter::from_fn(move || {
379        let cluster = glyphs.get(start)?.cluster;
380        let mut end = start + 1;
381        while glyphs
382            .get(end)
383            .is_some_and(|glyph| glyph.cluster == cluster)
384        {
385            end += 1;
386        }
387        let end_byte = glyphs
388            .get(end)
389            .map_or(text_len, |glyph| glyph.cluster as usize);
390        let group = GlyphCluster {
391            byte_range: cluster as usize..end_byte,
392            glyphs: &glyphs[start..end],
393        };
394        start = end;
395        Some(group)
396    })
397    .fuse()
398}
399
400/// Convert a `(font, byte_range, parent-relative glyphs)` triple into
401/// a fully-baked [`WordSubRun`]: slices `parent_text` to the sub-run's
402/// local string, rebases glyph clusters to that local string, sums
403/// the advance.
404fn finalize_subrun(
405    font: Font,
406    byte_range: std::ops::Range<usize>,
407    glyphs: Vec<ShapedGlyph>,
408    parent_text: &str,
409    size_pt: f32,
410) -> WordSubRun {
411    let local_text = parent_text[byte_range.clone()].to_owned();
412    // Sub-run byte ranges are bounded by the parent word's `text.len()`,
413    // always well below u32::MAX in practice. Saturating cast keeps clippy
414    // happy without an `#[allow]` annotation; the saturation branch is
415    // unreachable for any realistic input.
416    let shift = u32::try_from(byte_range.start).unwrap_or(u32::MAX);
417    let rebased: Vec<_> = glyphs
418        .into_iter()
419        .map(|g| ShapedGlyph {
420            cluster: g.cluster.saturating_sub(shift),
421            ..g
422        })
423        .collect();
424    let advance_pt: f32 = match font {
425        Font::Embedded(id) => {
426            let upem = embedded_upem(id);
427            rebased
428                .iter()
429                .map(|g| advance_units_to_pt(g.advance_units, size_pt, upem))
430                .sum()
431        }
432        Font::Base14(_) => text_width(font, size_pt, &local_text),
433    };
434    WordSubRun {
435        font,
436        text: local_text,
437        glyphs: rebased,
438        advance_pt,
439    }
440}
441
442/// Single-sub-run packaging when no fallback retry was needed. Skips
443/// the rebasing branch: the input glyphs already have cluster offsets
444/// relative to `local_text` (which is the full parent text).
445fn into_subrun(
446    font: Font,
447    text: String,
448    glyphs: Vec<ShapedGlyph>,
449    upem: u16,
450    size_pt: f32,
451) -> WordSubRun {
452    let upem_f = f32::from(upem);
453    let advance_pt: f32 = glyphs
454        .iter()
455        .map(|g| advance_units_to_pt(g.advance_units, size_pt, upem_f))
456        .sum();
457    WordSubRun {
458        font,
459        text,
460        glyphs,
461        advance_pt,
462    }
463}
464
465/// `units_per_em` for an embedded face.
466fn embedded_upem(id: EmbeddedFontId) -> f32 {
467    f32::from(id.data().units_per_em)
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn adjacent_fallback_clusters_merge_and_rebase() {
476        let runs = shape_with_fallback(
477            Font::Embedded(EmbeddedFontId::Regular),
478            &[EmbeddedFontId::Math],
479            11.0,
480            "office⨌⨌office",
481        );
482        assert_eq!(runs.len(), 3);
483        assert_eq!(
484            runs.iter().map(|run| run.text.as_str()).collect::<Vec<_>>(),
485            ["office", "⨌⨌", "office"]
486        );
487        assert_eq!(runs[1].font, Font::Embedded(EmbeddedFontId::Math));
488        let expected = shape_with_fallback(Font::Embedded(EmbeddedFontId::Math), &[], 11.0, "⨌⨌");
489        assert_eq!(runs[1].glyphs, expected[0].glyphs);
490        assert_eq!(runs[1].advance_pt, expected[0].advance_pt);
491        assert_eq!(runs[0].glyphs, runs[2].glyphs);
492    }
493
494    #[test]
495    fn unresolved_fallback_clusters_merge_with_original_positioning() {
496        let primary = Font::Embedded(EmbeddedFontId::Regular);
497        let text = "officeé🎉office🎉".repeat(32);
498        let expected = shape_with_fallback(primary, &[], 11.0, &text);
499        let actual = shape_with_fallback(primary, &[EmbeddedFontId::Math], 11.0, &text);
500        assert_eq!(actual.len(), 1);
501        assert_eq!(actual[0].font, expected[0].font);
502        assert_eq!(actual[0].text, expected[0].text);
503        assert_eq!(actual[0].glyphs, expected[0].glyphs);
504        assert_eq!(actual[0].advance_pt, expected[0].advance_pt);
505    }
506
507    #[test]
508    #[allow(clippy::expect_used, reason = "test requires nonempty cluster groups")]
509    fn cluster_views_borrow_glyphs_and_preserve_positioning() {
510        let glyph = ShapedGlyph {
511            gid: 1,
512            cluster: 0,
513            advance_units: -17,
514            x_offset_units: 23,
515            y_offset_units: -9,
516        };
517        let glyphs = [
518            glyph,
519            ShapedGlyph { gid: 2, ..glyph },
520            ShapedGlyph {
521                gid: 3,
522                cluster: 3,
523                ..glyph
524            },
525        ];
526        let mut clusters = glyph_clusters(&glyphs, "ffié".len());
527        let first = clusters.next().expect("first cluster");
528        assert_eq!(first.byte_range, 0..3);
529        assert!(std::ptr::eq(first.glyphs.as_ptr(), glyphs.as_ptr()));
530        assert_eq!(first.glyphs, &glyphs[..2]);
531        let last = clusters.next().expect("last cluster");
532        assert_eq!(last.byte_range, 3..5);
533        assert_eq!(last.glyphs, &glyphs[2..]);
534        assert!(clusters.next().is_none());
535        assert!(clusters.next().is_none());
536        assert!(glyph_clusters(&[], 4).next().is_none());
537    }
538
539    #[test]
540    #[allow(
541        clippy::expect_used,
542        reason = "test requires valid shaped source ranges"
543    )]
544    fn cluster_views_use_normalized_subrun_text() {
545        let runs = shape_with_fallback(
546            Font::Embedded(EmbeddedFontId::Regular),
547            &[EmbeddedFontId::Math],
548            11.0,
549            "e\u{301}office⨌",
550        );
551        assert!(
552            runs.iter()
553                .any(|run| run.font == Font::Embedded(EmbeddedFontId::Math))
554        );
555        let mut text = String::new();
556        let mut saw_ligature = false;
557        for run in &runs {
558            for cluster in glyph_clusters(&run.glyphs, run.text.len()) {
559                let part = run
560                    .text
561                    .get(cluster.byte_range)
562                    .expect("valid source range");
563                saw_ligature |= part == "ffi";
564                text.push_str(part);
565            }
566        }
567        assert_eq!(text, "éoffice⨌");
568        assert!(saw_ligature);
569    }
570
571    #[test]
572    fn cluster_views_leave_external_range_validation_to_caller() {
573        let glyph = ShapedGlyph {
574            gid: 1,
575            cluster: 1,
576            advance_units: 0,
577            x_offset_units: 0,
578            y_offset_units: 0,
579        };
580        for offsets in [[1, 3], [3, 0], [0, 9]] {
581            let glyphs = offsets.map(|cluster| ShapedGlyph { cluster, ..glyph });
582            let clusters: Vec<_> = glyph_clusters(&glyphs, "éx".len()).collect();
583            assert_eq!(clusters.len(), 2);
584            assert!(
585                clusters
586                    .iter()
587                    .any(|cluster| "éx".get(cluster.byte_range.clone()).is_none())
588            );
589        }
590    }
591
592    use crate::Base14Font;
593
594    #[test]
595    #[allow(clippy::expect_used, reason = "test setup and required split results")]
596    fn split_runs_preserves_untouched_geometry() {
597        let font = Font::Embedded(EmbeddedFontId::Regular);
598        let mut runs = shape_with_fallback(font, &[EmbeddedFontId::Math], 11.0, "a⨌b");
599        assert!(runs.len() >= 3);
600        // Callers can adjust positioning after shaping; boundary splits must
601        // retain that geometry instead of recomputing it from the text.
602        runs[0].advance_pt += 0.25;
603        runs[0].glyphs[0].x_offset_units += 1;
604        let offset = runs[0].text.len();
605        let (left, right) =
606            split_runs_at(&runs, offset, 11.0, &[EmbeddedFontId::Math]).expect("run boundary");
607        let joined: Vec<_> = left.into_iter().chain(right).collect();
608        assert_eq!(joined.len(), runs.len());
609        for (actual, expected) in joined.iter().zip(&runs) {
610            assert_eq!(actual.font, expected.font);
611            assert_eq!(actual.text, expected.text);
612            assert_eq!(actual.glyphs, expected.glyphs);
613            assert_eq!(actual.advance_pt, expected.advance_pt);
614        }
615        for offset in [0, "a⨌b".len()] {
616            let (left, right) = split_runs_at(&runs, offset, 11.0, &[]).expect("end boundary");
617            assert_eq!(left.len() + right.len(), runs.len());
618            assert_eq!(left.is_empty(), offset == 0);
619            assert_eq!(right.is_empty(), offset != 0);
620        }
621    }
622
623    #[test]
624    #[allow(clippy::expect_used, reason = "test setup and required split results")]
625    fn split_runs_reshapes_a_cut_ligature() {
626        let font = Font::Embedded(EmbeddedFontId::Regular);
627        let runs = shape_with_fallback(font, &[], 12.0, "office");
628        assert!(runs.iter().map(|run| run.glyphs.len()).sum::<usize>() < 6);
629        let (left, right) = split_runs_at(&runs, 2, 12.0, &[]).expect("inside ffi ligature");
630        for (actual, text) in [(&left, "of"), (&right, "fice")] {
631            let expected = shape_with_fallback(font, &[], 12.0, text);
632            assert_eq!(actual.len(), expected.len());
633            for (actual, expected) in actual.iter().zip(&expected) {
634                assert_eq!(actual.text, expected.text);
635                assert_eq!(actual.glyphs, expected.glyphs);
636                assert_eq!(actual.advance_pt, expected.advance_pt);
637            }
638        }
639    }
640
641    #[test]
642    #[allow(clippy::expect_used, reason = "required split results")]
643    fn split_runs_checks_offsets_in_normalized_text() {
644        let runs = shape_with_fallback(
645            Font::Embedded(EmbeddedFontId::Regular),
646            &[],
647            12.0,
648            "e\u{301}x",
649        );
650        assert_eq!(runs[0].text, "éx");
651        assert!(split_runs_at(&runs, 1, 12.0, &[]).is_none());
652        assert!(split_runs_at(&runs, 4, 12.0, &[]).is_none());
653        assert!(split_runs_at(&runs, usize::MAX, 12.0, &[]).is_none());
654        let (left, right) = split_runs_at(&runs, 2, 12.0, &[]).expect("normalized boundary");
655        assert_eq!(left[0].text, "é");
656        assert_eq!(right[0].text, "x");
657        let (left, right) = split_runs_at(&[], 0, 12.0, &[]).expect("empty boundary");
658        assert!(left.is_empty() && right.is_empty());
659        assert!(split_runs_at(&[], 1, 12.0, &[]).is_none());
660    }
661
662    #[test]
663    fn embedded_shape_is_empty_for_empty_string() {
664        let ef = EmbeddedFontId::Regular.data();
665        let glyphs = shape(ef, "");
666        assert!(glyphs.is_empty());
667    }
668
669    #[test]
670    fn embedded_shape_returns_clusters_in_byte_order() {
671        let ef = EmbeddedFontId::Regular.data();
672        let glyphs = shape(ef, "Привет");
673        assert!(!glyphs.is_empty());
674        // Cluster values are byte offsets into the source string and
675        // must be monotonically non-decreasing for LTR text.
676        let mut prev: u32 = 0;
677        for g in &glyphs {
678            assert!(
679                g.cluster >= prev,
680                "cluster regression: {prev} -> {}",
681                g.cluster
682            );
683            prev = g.cluster;
684        }
685    }
686
687    #[test]
688    fn embedded_shape_preserves_gpos_kerning() {
689        let ef = EmbeddedFontId::Regular.data();
690        let glyphs = shape(ef, "AV");
691        assert!(!glyphs.is_empty());
692        let nominal: i32 = glyphs
693            .iter()
694            .map(|g| i32::from(ef.advance_units(g.gid)))
695            .sum();
696        let shaped: i32 = glyphs.iter().map(|g| g.advance_units).sum();
697
698        assert!(
699            shaped < nominal,
700            "expected AV kerning to tighten advance: shaped={shaped} nominal={nominal}"
701        );
702    }
703
704    #[test]
705    fn embedded_shape_preserves_combining_mark_offsets() {
706        let ef = EmbeddedFontId::Regular.data();
707        let glyphs = shape(ef, "q\u{0302}\u{0301}");
708        assert!(
709            glyphs.len() >= 3,
710            "expected base 'q' + 2 combining marks, got {glyphs:?}"
711        );
712        assert!(
713            glyphs[1..]
714                .iter()
715                .any(|g| g.x_offset_units != 0 || g.y_offset_units != 0),
716            "expected at least one combining mark offset, got {glyphs:?}"
717        );
718    }
719
720    #[test]
721    fn shape_text_normalizes_decomposed_romanian() {
722        let font = Font::Embedded(EmbeddedFontId::Regular);
723        let decomposed = shape_text(font, 12.0, "S\u{0326}");
724        let precomposed = shape_text(font, 12.0, "\u{0218}");
725
726        let decomposed_gids: Vec<u16> = decomposed.glyphs.iter().map(|g| g.gid).collect();
727        let precomposed_gids: Vec<u16> = precomposed.glyphs.iter().map(|g| g.gid).collect();
728        assert_eq!(decomposed_gids, precomposed_gids);
729        assert!((decomposed.advance_pt - precomposed.advance_pt).abs() < f32::EPSILON);
730    }
731
732    #[test]
733    fn embedded_fi_ligature_collapses_glyphs() {
734        // Noto Sans contains an `fi` ligature; rustybuzz returns one
735        // glyph for `fi` (not two). The substituted gid differs from
736        // both the standalone `f` and `i` gids. (Noto Sans's `fi`
737        // ligature has the same advance as f+i: purely visual,
738        // joining the dot of `i` with the terminal of `f`, so width
739        // is not a useful invariant for this font.)
740        let ef = EmbeddedFontId::Regular.data();
741        let fi = shape(ef, "fi");
742        let f = shape(ef, "f");
743        let i = shape(ef, "i");
744        assert_eq!(fi.len(), 1, "expected fi ligature, got glyphs {fi:?}");
745        assert_ne!(fi[0].gid, f[0].gid);
746        assert_ne!(fi[0].gid, i[0].gid);
747    }
748
749    #[test]
750    fn fallback_empty_text_returns_empty() {
751        let primary = Font::Embedded(EmbeddedFontId::Regular);
752        let fallbacks = &[EmbeddedFontId::Math];
753        assert!(shape_with_fallback(primary, fallbacks, 11.0, "").is_empty());
754    }
755
756    #[test]
757    fn fallback_pure_primary_returns_single_subrun() {
758        // Pure ASCII is fully covered by Noto Sans Regular: no
759        // fallback needed; one sub-run, primary-owned glyphs.
760        let primary = Font::Embedded(EmbeddedFontId::Regular);
761        let fallbacks = &[EmbeddedFontId::Math];
762        let subs = shape_with_fallback(primary, fallbacks, 11.0, "Hello");
763        assert_eq!(subs.len(), 1, "expected one sub-run, got {}", subs.len());
764        assert_eq!(subs[0].font, primary);
765        assert_eq!(subs[0].text, "Hello");
766        assert!(!subs[0].glyphs.is_empty());
767        assert!(subs[0].glyphs.iter().all(|g| g.gid != 0));
768        assert!(subs[0].advance_pt > 0.0);
769    }
770
771    #[test]
772    fn fallback_shape_normalizes_subrun_text() {
773        let primary = Font::Embedded(EmbeddedFontId::Regular);
774        let fallbacks = &[EmbeddedFontId::Math];
775        let subs = shape_with_fallback(primary, fallbacks, 11.0, "S\u{0326}");
776
777        assert_eq!(subs.len(), 1);
778        assert_eq!(subs[0].text, "\u{0218}");
779    }
780
781    #[test]
782    fn fallback_mixed_latin_and_math_produces_alternating_subruns() {
783        // `a≤b`: Latin `a` covered by Regular, math `≤` (U+2264) needs
784        // the Math fallback, Latin `b` covered by Regular again. Expect
785        // three sub-runs in source order. Each sub-run's text is its
786        // own slice; glyph clusters rebased to local text.
787        let primary = Font::Embedded(EmbeddedFontId::Regular);
788        let fallbacks = &[EmbeddedFontId::Math];
789        let subs = shape_with_fallback(primary, fallbacks, 11.0, "a\u{2264}b");
790        assert_eq!(subs.len(), 3, "expected 3 sub-runs, got {subs:?}");
791
792        assert_eq!(subs[0].font, primary);
793        assert_eq!(subs[0].text, "a");
794
795        assert_eq!(subs[1].font, Font::Embedded(EmbeddedFontId::Math));
796        assert_eq!(subs[1].text, "\u{2264}");
797        // Math sub-run glyph clusters must be local: cluster 0 points
798        // at the start of "≤", not at offset 1 in the parent word.
799        assert!(
800            subs[1]
801                .glyphs
802                .iter()
803                .all(|g| (g.cluster as usize) < subs[1].text.len()),
804            "math sub-run glyph clusters not rebased: {:?}",
805            subs[1].glyphs,
806        );
807
808        assert_eq!(subs[2].font, primary);
809        assert_eq!(subs[2].text, "b");
810    }
811
812    #[test]
813    fn fallback_all_fail_keeps_primary_notdef() {
814        // Emoji 🎉 (U+1F389) is not in Noto Sans Regular OR Math.
815        // The cluster stays with primary as `.notdef`; no panic, no
816        // duplication, copy-paste yields the source codepoint via the
817        // existing `/ToUnicode` machinery (PDF reader paints empty box).
818        let primary = Font::Embedded(EmbeddedFontId::Regular);
819        let fallbacks = &[EmbeddedFontId::Math];
820        let subs = shape_with_fallback(primary, fallbacks, 11.0, "\u{1F389}");
821        assert_eq!(subs.len(), 1);
822        assert_eq!(subs[0].font, primary);
823        assert!(
824            subs[0].glyphs.iter().any(|g| g.gid == 0),
825            "expected .notdef for unsupported emoji, got {:?}",
826            subs[0].glyphs,
827        );
828    }
829
830    #[test]
831    fn fallback_base14_primary_returns_empty_glyphs_subrun() {
832        // Base14 has no glyph stream to inspect for `.notdef`; fallback
833        // doesn't apply. One sub-run, empty glyphs, advance via the
834        // AFM `text_width` path.
835        let primary = Font::Base14(Base14Font::Helvetica);
836        let fallbacks = &[EmbeddedFontId::Math];
837        let subs = shape_with_fallback(primary, fallbacks, 11.0, "Hello");
838        assert_eq!(subs.len(), 1);
839        assert_eq!(subs[0].font, primary);
840        assert!(subs[0].glyphs.is_empty());
841        assert!(subs[0].advance_pt > 0.0);
842    }
843
844    #[test]
845    fn fallback_no_fallbacks_configured_returns_single_subrun_even_with_notdef() {
846        // Empty fallback chain: even if the primary has .notdef, we
847        // produce one sub-run with whatever the primary shaped. PDF
848        // reader paints empty boxes; no panic.
849        let primary = Font::Embedded(EmbeddedFontId::Regular);
850        let subs = shape_with_fallback(primary, &[], 11.0, "\u{2264}");
851        assert_eq!(subs.len(), 1);
852        assert_eq!(subs[0].font, primary);
853    }
854
855    #[test]
856    fn fallback_math_subrun_advance_uses_math_face_upem() {
857        // Sanity: the math sub-run's `advance_pt` is computed from
858        // Math's `units_per_em`, not Regular's. Both happen to be
859        // 1000 in Noto Sans, but the contract should hold regardless.
860        let primary = Font::Embedded(EmbeddedFontId::Regular);
861        let fallbacks = &[EmbeddedFontId::Math];
862        let subs = shape_with_fallback(primary, fallbacks, 11.0, "\u{2264}");
863        assert_eq!(subs.len(), 1);
864        assert_eq!(subs[0].font, Font::Embedded(EmbeddedFontId::Math));
865        assert!(subs[0].advance_pt > 0.0);
866    }
867}