Skip to main content

oxideav_ttf/
shape.rs

1//! OpenType GSUB/GPOS shaping pipeline.
2//!
3//! This module wires the per-lookup-type GSUB substitution and GPOS
4//! positioning primitives implemented in [`crate::tables::gsub`] and
5//! [`crate::tables::gpos`] into a single coherent
6//! [`Font::shape`](crate::Font::shape) entry point that turns a run of
7//! Unicode text into a sequence of positioned glyphs.
8//!
9//! ## Pipeline (ISO/IEC 14496-22:2019 §6 "OFF Layout Common Table
10//! Formats" + the GSUB/GPOS chapters)
11//!
12//! 1. **Character-to-glyph mapping.** Each input `char` is mapped to a
13//!    nominal glyph id through the `cmap` table
14//!    ([`Font::glyph_index`](crate::Font::glyph_index)). Characters with
15//!    no mapping resolve to glyph 0 (`.notdef`).
16//!
17//! 2. **GSUB substitution stage.** The features the caller requested are
18//!    resolved against the active script/language through the GSUB
19//!    ScriptList → FeatureList → LangSys walk. Per the common-table-format
20//!    rules, the *union* of the lookup indices referenced by the active
21//!    features is gathered and processed **in LookupList order** (not
22//!    feature order): "the client … processes the lookups referenced by
23//!    these features in the order the lookup definitions occur in the
24//!    LookupList … lookups from several different features may be
25//!    interleaved during text processing." Each lookup is applied across
26//!    the whole glyph buffer left-to-right (reverse-chaining LookupType 8
27//!    is walked right-to-left).
28//!
29//! 3. **GPOS positioning stage.** Advances are seeded from `hmtx`. The
30//!    active GPOS features' lookups are likewise gathered and applied in
31//!    LookupList order, accumulating x/y placement and advance
32//!    adjustments plus mark-attachment, cursive-attachment, and
33//!    pair-kerning offsets onto each glyph.
34//!
35//! The result is a `Vec<`[`ShapedGlyph`]`>`: one entry per output glyph,
36//! carrying the glyph id, the originating cluster (byte index into the
37//! input text), and the placement/advance in font units (TT Y-up
38//! convention, scale by `units_per_em` for a target ppem).
39//!
40//! ## Scope
41//!
42//! This is a *general* OpenType shaper: it applies whatever lookups the
43//! requested features reference, for any script, without script-specific
44//! reordering logic (the spec explicitly places complex-script glyph
45//! reordering — e.g. Indic syllable reordering — outside its scope, in
46//! the text-processing client). For scripts whose joining/positional
47//! behaviour is fully expressed through GSUB/GPOS lookups keyed off
48//! contextual rules (Latin ligatures and kerning, Arabic joining forms
49//! driven by `init`/`medi`/`fina` + `mark`/`mkmk`/`curs`), the requested
50//! feature set drives correct output directly.
51
52use crate::tables::gpos::PosRecord;
53use crate::Font;
54
55/// Maximum number of GSUB lookup passes over the buffer, as a guard
56/// against a pathological self-growing lookup graph (a multiple- or
57/// contextual-substitution chain that keeps expanding the buffer).
58/// Real fonts converge in a handful of passes; this only bounds
59/// adversarial inputs.
60const MAX_GSUB_BUFFER_GROWTH: usize = 64;
61
62/// One positioned glyph emitted by [`Font::shape`].
63///
64/// All four positioning fields are in font design units (the same units
65/// as `head.unitsPerEm`), in the TrueType Y-up convention. To render at
66/// a target pixel-per-em `ppem`, scale by `ppem / units_per_em`.
67///
68/// * `glyph_id` — the final glyph id after all GSUB substitutions.
69/// * `cluster` — the byte offset into the original `&str` of the
70///   character (or first character of the ligated group) this glyph
71///   originated from. Stable across substitutions: a ligature inherits
72///   the cluster of its first component; a multiple-substitution
73///   expansion shares the source glyph's cluster across every output.
74/// * `x_offset` / `y_offset` — placement adjustment applied to the pen
75///   position *for drawing this glyph only* (does not move the pen).
76///   Marks attach to bases through this field.
77/// * `x_advance` / `y_advance` — how far the pen moves after drawing
78///   this glyph. Seeded from the horizontal `hmtx` advance, then
79///   adjusted by GPOS.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct ShapedGlyph {
82    pub glyph_id: u16,
83    pub cluster: u32,
84    pub x_offset: i32,
85    pub y_offset: i32,
86    pub x_advance: i32,
87    pub y_advance: i32,
88}
89
90/// Internal working item during the GSUB stage. The position fields are
91/// not populated until the GPOS stage; we carry the glyph id + cluster
92/// here and materialise [`ShapedGlyph`] at the boundary.
93#[derive(Debug, Clone, Copy)]
94struct WorkGlyph {
95    gid: u16,
96    cluster: u32,
97}
98
99impl<'a> Font<'a> {
100    /// Shape a run of text into positioned glyphs under `script` /
101    /// `lang`, applying the listed `features`.
102    ///
103    /// `script` and `lang` are OpenType tags (`*b"latn"`, `*b"arab"`,
104    /// `*b"DFLT"`; `lang = None` selects the script's default language
105    /// system). `features` is the ordered list of feature tags the
106    /// caller wants enabled (e.g. `[*b"ccmp", *b"liga", *b"kern"]`); a
107    /// feature tag the font does not list under the active script is
108    /// silently ignored. The relative order of `features` does not by
109    /// itself dictate application order — the GSUB/GPOS lookups behind
110    /// the *union* of requested features run in LookupList order, per the
111    /// OpenType common-table-format rules — but it determines which
112    /// features are active.
113    ///
114    /// Returns one [`ShapedGlyph`] per output glyph. For a font with no
115    /// GSUB/GPOS, this degenerates to nominal cmap mapping with `hmtx`
116    /// advances (i.e. unshaped glyph runs still come back correctly
117    /// positioned for simple scripts).
118    ///
119    /// The variation-instance-aware feature resolution
120    /// ([`Font::gsub_features_for_script_at_instance`]) is used, so a
121    /// variable font shaped after [`Font::set_variation_coords`] honours
122    /// its FeatureVariations substitutions.
123    pub fn shape(
124        &self,
125        text: &str,
126        script: [u8; 4],
127        lang: Option<[u8; 4]>,
128        features: &[[u8; 4]],
129    ) -> Vec<ShapedGlyph> {
130        // --- 1. character-to-glyph mapping --------------------------------
131        let mut buf: Vec<WorkGlyph> = Vec::with_capacity(text.len());
132        for (byte_idx, ch) in text.char_indices() {
133            let gid = self.glyph_index(ch).unwrap_or(0);
134            buf.push(WorkGlyph {
135                gid,
136                cluster: byte_idx as u32,
137            });
138        }
139
140        // --- 2. GSUB substitution stage -----------------------------------
141        self.run_gsub(&mut buf, script, lang, features);
142
143        // --- 3. GPOS positioning stage ------------------------------------
144        self.run_gpos(buf, script, lang, features)
145    }
146
147    /// Resolve the active GSUB lookup indices for the requested features
148    /// and apply them, in LookupList order, across `buf`.
149    fn run_gsub(
150        &self,
151        buf: &mut Vec<WorkGlyph>,
152        script: [u8; 4],
153        lang: Option<[u8; 4]>,
154        features: &[[u8; 4]],
155    ) {
156        if self.gsub.is_none() {
157            return;
158        }
159        let resolved = self.gsub_features_for_script_at_instance(script, lang);
160        // Gather the union of lookup indices referenced by every active
161        // requested feature.
162        let mut active: Vec<u16> = Vec::new();
163        for feat in &resolved {
164            if !features.contains(&feat.tag) {
165                continue;
166            }
167            for &li in &feat.lookup_indices {
168                if !active.contains(&li) {
169                    active.push(li);
170                }
171            }
172        }
173        if active.is_empty() {
174            return;
175        }
176        // Process in LookupList order, not feature order.
177        active.sort_unstable();
178
179        // Map each active lookup index to its (effective) type so we can
180        // pick the right per-type apply path.
181        let types = self.gsub_lookup_list();
182        for &li in &active {
183            let kind = types
184                .iter()
185                .find(|(idx, _, _)| *idx == li)
186                .map(|(_, k, _)| *k)
187                .unwrap_or(0);
188            let flags = self.gsub.as_ref().map(|g| g.lookup_flags(li)).unwrap_or(0);
189            self.apply_gsub_lookup(buf, li, kind, flags);
190        }
191    }
192
193    /// Apply one GSUB lookup of the given effective `kind` across the
194    /// whole buffer. `flags` is the lookup's `lookupFlag`; the
195    /// IGNORE_MARKS / IGNORE_BASE_GLYPHS / IGNORE_LIGATURES skip bits are
196    /// honoured where they affect substitution (most consequentially
197    /// IGNORE_MARKS on ligature lookups, so a combining mark sitting
198    /// between two ligature components doesn't block the ligature).
199    fn apply_gsub_lookup(&self, buf: &mut Vec<WorkGlyph>, li: u16, kind: u16, flags: u16) {
200        match kind {
201            1 => {
202                // Single substitution: 1:1, no length change.
203                for w in buf.iter_mut() {
204                    if let Some(g) = self.gsub_apply_lookup_type_1(li, w.gid) {
205                        w.gid = g;
206                    }
207                }
208            }
209            2 => {
210                // Multiple substitution: 1 → N (or 0 = deletion). All
211                // outputs inherit the source cluster.
212                let mut out: Vec<WorkGlyph> = Vec::with_capacity(buf.len());
213                let mut growth = 0usize;
214                for w in buf.iter() {
215                    match self.gsub_apply_lookup_type_2(li, w.gid) {
216                        Some(seq) => {
217                            growth += seq.len();
218                            for g in seq {
219                                out.push(WorkGlyph {
220                                    gid: g,
221                                    cluster: w.cluster,
222                                });
223                            }
224                        }
225                        None => out.push(*w),
226                    }
227                    if growth > buf.len() + MAX_GSUB_BUFFER_GROWTH {
228                        // Pathological expansion guard: keep the rest
229                        // unsubstituted.
230                        break;
231                    }
232                }
233                if growth <= buf.len() + MAX_GSUB_BUFFER_GROWTH {
234                    *buf = out;
235                }
236            }
237            3 => {
238                // Alternate substitution: default to alternate 0.
239                for w in buf.iter_mut() {
240                    if let Some(g) = self.gsub_apply_lookup_type_3(li, w.gid, 0) {
241                        w.gid = g;
242                    }
243                }
244            }
245            4 => {
246                // Ligature substitution: N → 1, consuming a prefix from
247                // each position. The ligature inherits the cluster of its
248                // first component. The lookup's skip filter (§2) decides
249                // which glyphs are invisible to the match: a lookup with
250                // IGNORE_MARKS matches over the *non-mark* glyphs and
251                // removes only the consumed visible components, leaving
252                // interspersed marks in place (they re-anchor to the
253                // ligature during GPOS); IGNORE_LIGATURES /
254                // MARK_ATTACHMENT_CLASS_FILTER / USE_MARK_FILTERING_SET
255                // narrow the match the same way.
256                let mfs = self.gsub_lookup_mark_filtering_set(li);
257                let mut i = 0usize;
258                while i < buf.len() {
259                    if self.lookup_skips_glyph(flags, mfs, buf[i].gid) {
260                        i += 1;
261                        continue;
262                    }
263                    // Build the candidate run from position i, recording
264                    // which absolute indices the non-skipped gids came from.
265                    let mut cand_gids: Vec<u16> = Vec::new();
266                    let mut cand_idx: Vec<usize> = Vec::new();
267                    for (off, w) in buf[i..].iter().enumerate() {
268                        if self.lookup_skips_glyph(flags, mfs, w.gid) {
269                            continue;
270                        }
271                        cand_gids.push(w.gid);
272                        cand_idx.push(i + off);
273                    }
274                    if let Some((lig, consumed)) = self.gsub_apply_lookup_type_4(li, &cand_gids) {
275                        if consumed >= 1 {
276                            let cluster = buf[i].cluster;
277                            buf[i] = WorkGlyph { gid: lig, cluster };
278                            // Remove the consumed components 1..consumed
279                            // (their absolute indices), highest first so
280                            // earlier removals don't shift later indices.
281                            let to_remove: Vec<usize> =
282                                cand_idx[1..consumed.min(cand_idx.len())].to_vec();
283                            for &idx in to_remove.iter().rev() {
284                                if idx < buf.len() {
285                                    buf.remove(idx);
286                                }
287                            }
288                            i += 1;
289                            continue;
290                        }
291                    }
292                    i += 1;
293                }
294            }
295            5 => {
296                // Contextual substitution. apply_lookup_type_5 returns the
297                // rewritten run (full buffer) on a hit at `pos`.
298                let mut pos = 0usize;
299                while pos < buf.len() {
300                    let gids: Vec<u16> = buf.iter().map(|w| w.gid).collect();
301                    if let Some(rewritten) = self.gsub_apply_lookup_type_5(li, &gids, pos) {
302                        self.reconcile_context_rewrite(buf, &gids, rewritten, pos);
303                    }
304                    pos += 1;
305                }
306            }
307            6 => {
308                // Chained-context substitution.
309                let mut pos = 0usize;
310                while pos < buf.len() {
311                    let gids: Vec<u16> = buf.iter().map(|w| w.gid).collect();
312                    if let Some(rewritten) = self.gsub_apply_lookup_type_6(li, &gids, pos) {
313                        self.reconcile_context_rewrite(buf, &gids, rewritten, pos);
314                    }
315                    pos += 1;
316                }
317            }
318            8 => {
319                // Reverse chained-context single substitution: 1:1, walked
320                // right-to-left so a later substitution's lookahead sees
321                // the original (not yet substituted) glyphs.
322                let gids: Vec<u16> = buf.iter().map(|w| w.gid).collect();
323                for pos in (0..buf.len()).rev() {
324                    if let Some(g) = self.gsub_apply_lookup_type_8(li, &gids, pos) {
325                        buf[pos].gid = g;
326                    }
327                }
328            }
329            _ => {}
330        }
331    }
332
333    /// Reconcile a contextual/chained GSUB rewrite (which returns a full
334    /// rewritten gid run) back into the `WorkGlyph` buffer, preserving
335    /// clusters as best we can. The rewrite may change the buffer length
336    /// (a nested multiple- or ligature-substitution record). We align the
337    /// unchanged prefix/suffix and assign the source cluster of `pos` to
338    /// any glyphs in the changed middle.
339    fn reconcile_context_rewrite(
340        &self,
341        buf: &mut Vec<WorkGlyph>,
342        old: &[u16],
343        new: Vec<u16>,
344        pos: usize,
345    ) {
346        if new == old {
347            return;
348        }
349        // Common unchanged prefix.
350        let mut pre = 0usize;
351        while pre < old.len() && pre < new.len() && old[pre] == new[pre] {
352            pre += 1;
353        }
354        // Common unchanged suffix.
355        let mut suf = 0usize;
356        while suf < (old.len() - pre)
357            && suf < (new.len() - pre)
358            && old[old.len() - 1 - suf] == new[new.len() - 1 - suf]
359        {
360            suf += 1;
361        }
362        let cluster = buf.get(pos).map(|w| w.cluster).unwrap_or(0);
363        let mut rebuilt: Vec<WorkGlyph> = Vec::with_capacity(new.len());
364        for &g in &new[..pre] {
365            let c = buf.get(rebuilt.len()).map(|w| w.cluster).unwrap_or(cluster);
366            rebuilt.push(WorkGlyph { gid: g, cluster: c });
367        }
368        for &g in &new[pre..new.len() - suf] {
369            rebuilt.push(WorkGlyph { gid: g, cluster });
370        }
371        let suffix_start_old = old.len() - suf;
372        for (k, &g) in new[new.len() - suf..].iter().enumerate() {
373            let c = buf
374                .get(suffix_start_old + k)
375                .map(|w| w.cluster)
376                .unwrap_or(cluster);
377            rebuilt.push(WorkGlyph { gid: g, cluster: c });
378        }
379        *buf = rebuilt;
380    }
381
382    /// GPOS positioning stage. Seeds advances from `hmtx`, then applies
383    /// the active GPOS lookups in LookupList order.
384    fn run_gpos(
385        &self,
386        buf: Vec<WorkGlyph>,
387        script: [u8; 4],
388        lang: Option<[u8; 4]>,
389        features: &[[u8; 4]],
390    ) -> Vec<ShapedGlyph> {
391        // Seed every glyph with its nominal horizontal advance.
392        let mut out: Vec<ShapedGlyph> = buf
393            .iter()
394            .map(|w| ShapedGlyph {
395                glyph_id: w.gid,
396                cluster: w.cluster,
397                x_offset: 0,
398                y_offset: 0,
399                x_advance: self.glyph_advance(w.gid) as i32,
400                y_advance: 0,
401            })
402            .collect();
403
404        if self.gpos.is_none() {
405            return out;
406        }
407        let resolved = self.gpos_features_for_script_at_instance(script, lang);
408        let mut active: Vec<u16> = Vec::new();
409        for feat in &resolved {
410            if !features.contains(&feat.tag) {
411                continue;
412            }
413            for &li in &feat.lookup_indices {
414                if !active.contains(&li) {
415                    active.push(li);
416                }
417            }
418        }
419        if active.is_empty() {
420            return out;
421        }
422        active.sort_unstable();
423
424        let types = self.gpos_lookup_list();
425        for &li in &active {
426            let kind = types
427                .iter()
428                .find(|(idx, _, _)| *idx == li)
429                .map(|(_, k, _)| *k)
430                .unwrap_or(0);
431            self.apply_gpos_lookup(&mut out, li, kind);
432        }
433        out
434    }
435
436    /// Apply one GPOS lookup of the given effective `kind` across the
437    /// positioned buffer.
438    fn apply_gpos_lookup(&self, out: &mut [ShapedGlyph], li: u16, kind: u16) {
439        match kind {
440            1 => {
441                // Single adjustment.
442                for g in out.iter_mut() {
443                    if let Some(v) = self.gpos_apply_lookup_type_1(li, g.glyph_id) {
444                        g.x_offset += v.x_placement as i32;
445                        g.y_offset += v.y_placement as i32;
446                        g.x_advance += v.x_advance as i32;
447                        g.y_advance += v.y_advance as i32;
448                    }
449                }
450            }
451            2 => {
452                // Pair adjustment (kerning). The legacy single-value
453                // `lookup_kerning` path extracts the x-advance applied to
454                // the left glyph of each pair. The pair members are the
455                // current glyph and the *next non-skipped* glyph per the
456                // lookup's §2 skip filter — so a kern pair separated by an
457                // (ignored) combining mark still kerns, the canonical
458                // IGNORE_MARKS-on-kern case.
459                let gdef = self.gdef.as_ref();
460                let flags = self.gpos_lookup_flags(li);
461                let mfs = self.gpos_lookup_mark_filtering_set(li);
462                for i in 0..out.len() {
463                    if self.lookup_skips_glyph(flags, mfs, out[i].glyph_id) {
464                        continue;
465                    }
466                    // Find the next glyph the lookup does not skip.
467                    let right_idx = ((i + 1)..out.len())
468                        .find(|&k| !self.lookup_skips_glyph(flags, mfs, out[k].glyph_id));
469                    let right_idx = match right_idx {
470                        Some(k) => k,
471                        None => break,
472                    };
473                    let left = out[i].glyph_id;
474                    let right = out[right_idx].glyph_id;
475                    let adj = self
476                        .gpos
477                        .as_ref()
478                        .map(|g| g.lookup_kerning_at(li, left, right, gdef))
479                        .unwrap_or(0);
480                    out[i].x_advance += adj as i32;
481                }
482            }
483            3 => {
484                // Cursive attachment: glyph N+1's entry anchor lands on
485                // glyph N's exit anchor. The per-glyph delta moves N+1 so
486                // its entry aligns with N's exit (x via offset, the
487                // baseline shift via y_offset). Glyphs the lookup skips
488                // (§2) are invisible to the chain, so the exit of N is
489                // matched against the entry of the next *non-skipped*
490                // glyph.
491                let flags = self.gpos_lookup_flags(li);
492                let mfs = self.gpos_lookup_mark_filtering_set(li);
493                let mut prev_exit: Option<(i16, i16)> = None;
494                for g in out.iter_mut() {
495                    if self.lookup_skips_glyph(flags, mfs, g.glyph_id) {
496                        continue;
497                    }
498                    if let Some(att) = self.gpos_apply_lookup_type_3(li, g.glyph_id) {
499                        if let (Some((px, py)), Some((ex, ey))) = (prev_exit, att.entry) {
500                            g.x_offset += (px - ex) as i32;
501                            g.y_offset += (py - ey) as i32;
502                        }
503                        prev_exit = att.exit;
504                    } else {
505                        prev_exit = None;
506                    }
507                }
508            }
509            4 => {
510                // Mark-to-base: a mark glyph attaches to the nearest
511                // preceding base glyph.
512                self.apply_mark_attach(out, li, false);
513            }
514            5 => {
515                // Mark-to-ligature: a mark attaches to a component of a
516                // preceding ligature. We attach to the last preceding
517                // ligature, component 0 (a reasonable default without
518                // per-component cluster tracking from the substitution
519                // stage); the per-lookup apply path handles component
520                // resolution when given an explicit component.
521                self.apply_mark_to_ligature(out, li);
522            }
523            6 => {
524                // Mark-to-mark: a mark attaches to the immediately
525                // preceding mark.
526                self.apply_mark_attach(out, li, true);
527            }
528            7 => {
529                // Contextual positioning.
530                let gids: Vec<u16> = out.iter().map(|g| g.glyph_id).collect();
531                for pos in 0..out.len() {
532                    if let Some(records) = self.gpos_apply_lookup_type_7(li, &gids, pos) {
533                        apply_pos_records(out, &records);
534                    }
535                }
536            }
537            8 => {
538                // Chained-context positioning.
539                let gids: Vec<u16> = out.iter().map(|g| g.glyph_id).collect();
540                for pos in 0..out.len() {
541                    if let Some(records) = self.gpos_apply_lookup_type_8(li, &gids, pos) {
542                        apply_pos_records(out, &records);
543                    }
544                }
545            }
546            _ => {}
547        }
548    }
549
550    /// Shared mark-to-base (`to_mark = false`) / mark-to-mark
551    /// (`to_mark = true`) attachment. For each mark glyph, find the
552    /// nearest preceding attachment glyph (a base for mark-to-base, a
553    /// mark for mark-to-mark) that the lookup binds it to, and offset the
554    /// mark so its anchor lands on the base's anchor.
555    ///
556    /// The candidate attachment glyph is the nearest preceding glyph the
557    /// lookup's §2 skip filter does *not* ignore: a mark-to-base lookup
558    /// almost always sets IGNORE_MARKS so the scan steps over interspersed
559    /// marks and lands on the base, while a mark-to-mark (`mkmk`) lookup
560    /// leaves marks visible so it pairs with the immediately preceding
561    /// mark. The current mark itself is left unattached when the lookup
562    /// skips it.
563    fn apply_mark_attach(&self, out: &mut [ShapedGlyph], li: u16, to_mark: bool) {
564        let flags = self.gpos_lookup_flags(li);
565        let mfs = self.gpos_lookup_mark_filtering_set(li);
566        for i in 0..out.len() {
567            let mark = out[i].glyph_id;
568            if self.lookup_skips_glyph(flags, mfs, mark) {
569                continue;
570            }
571            // Scan backwards for the nearest non-skipped attachment glyph.
572            for j in (0..i).rev() {
573                let base = out[j].glyph_id;
574                if self.lookup_skips_glyph(flags, mfs, base) {
575                    continue;
576                }
577                let hit = if to_mark {
578                    self.gpos
579                        .as_ref()
580                        .and_then(|g| g.apply_mark_to_mark_at(li, base, mark))
581                } else {
582                    self.gpos
583                        .as_ref()
584                        .and_then(|g| g.apply_mark_to_base_at(li, base, mark))
585                };
586                if let Some((dx, dy)) = hit {
587                    // Place the mark relative to the base's pen origin.
588                    // The base sits at the accumulated advance from j to i;
589                    // a mark has (typically) zero advance, so its drawing
590                    // origin is the current pen. We express attachment as a
591                    // placement offset that pulls the mark back over the
592                    // base by the base's advance run plus the anchor delta.
593                    let between: i32 = out[j..i].iter().map(|g| g.x_advance).sum();
594                    out[i].x_offset += dx as i32 - between;
595                    out[i].y_offset += dy as i32;
596                }
597                // The first non-skipped predecessor is the only attachment
598                // candidate, whether or not it produced a hit.
599                break;
600            }
601        }
602    }
603
604    /// Mark-to-ligature attachment (LookupType 5). Attaches each mark to
605    /// the nearest preceding ligature glyph at component 0. The candidate
606    /// ligature is the nearest preceding glyph the lookup's §2 skip filter
607    /// does not ignore (a `mark` / mark-to-ligature lookup typically sets
608    /// IGNORE_MARKS so the scan steps over interspersed marks onto the
609    /// ligature).
610    fn apply_mark_to_ligature(&self, out: &mut [ShapedGlyph], li: u16) {
611        let flags = self.gpos_lookup_flags(li);
612        let mfs = self.gpos_lookup_mark_filtering_set(li);
613        for i in 0..out.len() {
614            let mark = out[i].glyph_id;
615            if self.lookup_skips_glyph(flags, mfs, mark) {
616                continue;
617            }
618            for j in (0..i).rev() {
619                let lig = out[j].glyph_id;
620                if self.lookup_skips_glyph(flags, mfs, lig) {
621                    continue;
622                }
623                if let Some((dx, dy)) = self
624                    .gpos
625                    .as_ref()
626                    .and_then(|g| g.apply_lookup_type_5(li, lig, 0, mark))
627                {
628                    let between: i32 = out[j..i].iter().map(|g| g.x_advance).sum();
629                    out[i].x_offset += dx as i32 - between;
630                    out[i].y_offset += dy as i32;
631                }
632                // The first non-skipped predecessor is the only candidate.
633                break;
634            }
635        }
636    }
637}
638
639/// Apply a set of [`PosRecord`]s (absolute-indexed) from a contextual /
640/// chained positioning match onto the output buffer.
641fn apply_pos_records(out: &mut [ShapedGlyph], records: &[PosRecord]) {
642    for r in records {
643        if let Some(g) = out.get_mut(r.glyph_index) {
644            g.x_offset += r.value.x_placement as i32;
645            g.y_offset += r.value.y_placement as i32;
646            g.x_advance += r.value.x_advance as i32;
647            g.y_advance += r.value.y_advance as i32;
648        }
649    }
650}