oxideav-ttf 0.1.8

Pure-Rust TrueType font parser for the oxideav framework — sfnt + cmap + glyf + hmtx + GSUB ligatures + GPOS kerning
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
//! OpenType GSUB/GPOS shaping pipeline.
//!
//! This module wires the per-lookup-type GSUB substitution and GPOS
//! positioning primitives implemented in [`crate::tables::gsub`] and
//! [`crate::tables::gpos`] into a single coherent
//! [`Font::shape`](crate::Font::shape) entry point that turns a run of
//! Unicode text into a sequence of positioned glyphs.
//!
//! ## Pipeline (ISO/IEC 14496-22:2019 §6 "OFF Layout Common Table
//! Formats" + the GSUB/GPOS chapters)
//!
//! 1. **Character-to-glyph mapping.** Each input `char` is mapped to a
//!    nominal glyph id through the `cmap` table
//!    ([`Font::glyph_index`](crate::Font::glyph_index)). Characters with
//!    no mapping resolve to glyph 0 (`.notdef`).
//!
//! 2. **GSUB substitution stage.** The features the caller requested are
//!    resolved against the active script/language through the GSUB
//!    ScriptList → FeatureList → LangSys walk. Per the common-table-format
//!    rules, the *union* of the lookup indices referenced by the active
//!    features is gathered and processed **in LookupList order** (not
//!    feature order): "the client … processes the lookups referenced by
//!    these features in the order the lookup definitions occur in the
//!    LookupList … lookups from several different features may be
//!    interleaved during text processing." Each lookup is applied across
//!    the whole glyph buffer left-to-right (reverse-chaining LookupType 8
//!    is walked right-to-left).
//!
//! 3. **GPOS positioning stage.** Advances are seeded from `hmtx`. The
//!    active GPOS features' lookups are likewise gathered and applied in
//!    LookupList order, accumulating x/y placement and advance
//!    adjustments plus mark-attachment, cursive-attachment, and
//!    pair-kerning offsets onto each glyph.
//!
//! The result is a `Vec<`[`ShapedGlyph`]`>`: one entry per output glyph,
//! carrying the glyph id, the originating cluster (byte index into the
//! input text), and the placement/advance in font units (TT Y-up
//! convention, scale by `units_per_em` for a target ppem).
//!
//! ## Scope
//!
//! This is a *general* OpenType shaper: it applies whatever lookups the
//! requested features reference, for any script, without script-specific
//! reordering logic (the spec explicitly places complex-script glyph
//! reordering — e.g. Indic syllable reordering — outside its scope, in
//! the text-processing client). For scripts whose joining/positional
//! behaviour is fully expressed through GSUB/GPOS lookups keyed off
//! contextual rules (Latin ligatures and kerning, Arabic joining forms
//! driven by `init`/`medi`/`fina` + `mark`/`mkmk`/`curs`), the requested
//! feature set drives correct output directly.

use crate::tables::gpos::PosRecord;
use crate::Font;

/// Maximum number of GSUB lookup passes over the buffer, as a guard
/// against a pathological self-growing lookup graph (a multiple- or
/// contextual-substitution chain that keeps expanding the buffer).
/// Real fonts converge in a handful of passes; this only bounds
/// adversarial inputs.
const MAX_GSUB_BUFFER_GROWTH: usize = 64;

/// One positioned glyph emitted by [`Font::shape`].
///
/// All four positioning fields are in font design units (the same units
/// as `head.unitsPerEm`), in the TrueType Y-up convention. To render at
/// a target pixel-per-em `ppem`, scale by `ppem / units_per_em`.
///
/// * `glyph_id` — the final glyph id after all GSUB substitutions.
/// * `cluster` — the byte offset into the original `&str` of the
///   character (or first character of the ligated group) this glyph
///   originated from. Stable across substitutions: a ligature inherits
///   the cluster of its first component; a multiple-substitution
///   expansion shares the source glyph's cluster across every output.
/// * `x_offset` / `y_offset` — placement adjustment applied to the pen
///   position *for drawing this glyph only* (does not move the pen).
///   Marks attach to bases through this field.
/// * `x_advance` / `y_advance` — how far the pen moves after drawing
///   this glyph. Seeded from the horizontal `hmtx` advance, then
///   adjusted by GPOS.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShapedGlyph {
    pub glyph_id: u16,
    pub cluster: u32,
    pub x_offset: i32,
    pub y_offset: i32,
    pub x_advance: i32,
    pub y_advance: i32,
}

/// Internal working item during the GSUB stage. The position fields are
/// not populated until the GPOS stage; we carry the glyph id + cluster
/// here and materialise [`ShapedGlyph`] at the boundary.
#[derive(Debug, Clone, Copy)]
struct WorkGlyph {
    gid: u16,
    cluster: u32,
}

impl<'a> Font<'a> {
    /// Shape a run of text into positioned glyphs under `script` /
    /// `lang`, applying the listed `features`.
    ///
    /// `script` and `lang` are OpenType tags (`*b"latn"`, `*b"arab"`,
    /// `*b"DFLT"`; `lang = None` selects the script's default language
    /// system). `features` is the ordered list of feature tags the
    /// caller wants enabled (e.g. `[*b"ccmp", *b"liga", *b"kern"]`); a
    /// feature tag the font does not list under the active script is
    /// silently ignored. The relative order of `features` does not by
    /// itself dictate application order — the GSUB/GPOS lookups behind
    /// the *union* of requested features run in LookupList order, per the
    /// OpenType common-table-format rules — but it determines which
    /// features are active.
    ///
    /// Returns one [`ShapedGlyph`] per output glyph. For a font with no
    /// GSUB/GPOS, this degenerates to nominal cmap mapping with `hmtx`
    /// advances (i.e. unshaped glyph runs still come back correctly
    /// positioned for simple scripts).
    ///
    /// The variation-instance-aware feature resolution
    /// ([`Font::gsub_features_for_script_at_instance`]) is used, so a
    /// variable font shaped after [`Font::set_variation_coords`] honours
    /// its FeatureVariations substitutions.
    pub fn shape(
        &self,
        text: &str,
        script: [u8; 4],
        lang: Option<[u8; 4]>,
        features: &[[u8; 4]],
    ) -> Vec<ShapedGlyph> {
        // --- 1. character-to-glyph mapping --------------------------------
        let mut buf: Vec<WorkGlyph> = Vec::with_capacity(text.len());
        for (byte_idx, ch) in text.char_indices() {
            let gid = self.glyph_index(ch).unwrap_or(0);
            buf.push(WorkGlyph {
                gid,
                cluster: byte_idx as u32,
            });
        }

        // --- 2. GSUB substitution stage -----------------------------------
        self.run_gsub(&mut buf, script, lang, features);

        // --- 3. GPOS positioning stage ------------------------------------
        self.run_gpos(buf, script, lang, features)
    }

    /// Resolve the active GSUB lookup indices for the requested features
    /// and apply them, in LookupList order, across `buf`.
    fn run_gsub(
        &self,
        buf: &mut Vec<WorkGlyph>,
        script: [u8; 4],
        lang: Option<[u8; 4]>,
        features: &[[u8; 4]],
    ) {
        if self.gsub.is_none() {
            return;
        }
        let resolved = self.gsub_features_for_script_at_instance(script, lang);
        // Gather the union of lookup indices referenced by every active
        // requested feature.
        let mut active: Vec<u16> = Vec::new();
        for feat in &resolved {
            if !features.contains(&feat.tag) {
                continue;
            }
            for &li in &feat.lookup_indices {
                if !active.contains(&li) {
                    active.push(li);
                }
            }
        }
        if active.is_empty() {
            return;
        }
        // Process in LookupList order, not feature order.
        active.sort_unstable();

        // Map each active lookup index to its (effective) type so we can
        // pick the right per-type apply path.
        let types = self.gsub_lookup_list();
        for &li in &active {
            let kind = types
                .iter()
                .find(|(idx, _, _)| *idx == li)
                .map(|(_, k, _)| *k)
                .unwrap_or(0);
            let flags = self.gsub.as_ref().map(|g| g.lookup_flags(li)).unwrap_or(0);
            self.apply_gsub_lookup(buf, li, kind, flags);
        }
    }

    /// Apply one GSUB lookup of the given effective `kind` across the
    /// whole buffer. `flags` is the lookup's `lookupFlag`; the
    /// IGNORE_MARKS / IGNORE_BASE_GLYPHS / IGNORE_LIGATURES skip bits are
    /// honoured where they affect substitution (most consequentially
    /// IGNORE_MARKS on ligature lookups, so a combining mark sitting
    /// between two ligature components doesn't block the ligature).
    fn apply_gsub_lookup(&self, buf: &mut Vec<WorkGlyph>, li: u16, kind: u16, flags: u16) {
        match kind {
            1 => {
                // Single substitution: 1:1, no length change.
                for w in buf.iter_mut() {
                    if let Some(g) = self.gsub_apply_lookup_type_1(li, w.gid) {
                        w.gid = g;
                    }
                }
            }
            2 => {
                // Multiple substitution: 1 → N (or 0 = deletion). All
                // outputs inherit the source cluster.
                let mut out: Vec<WorkGlyph> = Vec::with_capacity(buf.len());
                let mut growth = 0usize;
                for w in buf.iter() {
                    match self.gsub_apply_lookup_type_2(li, w.gid) {
                        Some(seq) => {
                            growth += seq.len();
                            for g in seq {
                                out.push(WorkGlyph {
                                    gid: g,
                                    cluster: w.cluster,
                                });
                            }
                        }
                        None => out.push(*w),
                    }
                    if growth > buf.len() + MAX_GSUB_BUFFER_GROWTH {
                        // Pathological expansion guard: keep the rest
                        // unsubstituted.
                        break;
                    }
                }
                if growth <= buf.len() + MAX_GSUB_BUFFER_GROWTH {
                    *buf = out;
                }
            }
            3 => {
                // Alternate substitution: default to alternate 0.
                for w in buf.iter_mut() {
                    if let Some(g) = self.gsub_apply_lookup_type_3(li, w.gid, 0) {
                        w.gid = g;
                    }
                }
            }
            4 => {
                // Ligature substitution: N → 1, consuming a prefix from
                // each position. The ligature inherits the cluster of its
                // first component. The lookup's skip filter (§2) decides
                // which glyphs are invisible to the match: a lookup with
                // IGNORE_MARKS matches over the *non-mark* glyphs and
                // removes only the consumed visible components, leaving
                // interspersed marks in place (they re-anchor to the
                // ligature during GPOS); IGNORE_LIGATURES /
                // MARK_ATTACHMENT_CLASS_FILTER / USE_MARK_FILTERING_SET
                // narrow the match the same way.
                let mfs = self.gsub_lookup_mark_filtering_set(li);
                let mut i = 0usize;
                while i < buf.len() {
                    if self.lookup_skips_glyph(flags, mfs, buf[i].gid) {
                        i += 1;
                        continue;
                    }
                    // Build the candidate run from position i, recording
                    // which absolute indices the non-skipped gids came from.
                    let mut cand_gids: Vec<u16> = Vec::new();
                    let mut cand_idx: Vec<usize> = Vec::new();
                    for (off, w) in buf[i..].iter().enumerate() {
                        if self.lookup_skips_glyph(flags, mfs, w.gid) {
                            continue;
                        }
                        cand_gids.push(w.gid);
                        cand_idx.push(i + off);
                    }
                    if let Some((lig, consumed)) = self.gsub_apply_lookup_type_4(li, &cand_gids) {
                        if consumed >= 1 {
                            let cluster = buf[i].cluster;
                            buf[i] = WorkGlyph { gid: lig, cluster };
                            // Remove the consumed components 1..consumed
                            // (their absolute indices), highest first so
                            // earlier removals don't shift later indices.
                            let to_remove: Vec<usize> =
                                cand_idx[1..consumed.min(cand_idx.len())].to_vec();
                            for &idx in to_remove.iter().rev() {
                                if idx < buf.len() {
                                    buf.remove(idx);
                                }
                            }
                            i += 1;
                            continue;
                        }
                    }
                    i += 1;
                }
            }
            5 => {
                // Contextual substitution. apply_lookup_type_5 returns the
                // rewritten run (full buffer) on a hit at `pos`.
                let mut pos = 0usize;
                while pos < buf.len() {
                    let gids: Vec<u16> = buf.iter().map(|w| w.gid).collect();
                    if let Some(rewritten) = self.gsub_apply_lookup_type_5(li, &gids, pos) {
                        self.reconcile_context_rewrite(buf, &gids, rewritten, pos);
                    }
                    pos += 1;
                }
            }
            6 => {
                // Chained-context substitution.
                let mut pos = 0usize;
                while pos < buf.len() {
                    let gids: Vec<u16> = buf.iter().map(|w| w.gid).collect();
                    if let Some(rewritten) = self.gsub_apply_lookup_type_6(li, &gids, pos) {
                        self.reconcile_context_rewrite(buf, &gids, rewritten, pos);
                    }
                    pos += 1;
                }
            }
            8 => {
                // Reverse chained-context single substitution: 1:1, walked
                // right-to-left so a later substitution's lookahead sees
                // the original (not yet substituted) glyphs.
                let gids: Vec<u16> = buf.iter().map(|w| w.gid).collect();
                for pos in (0..buf.len()).rev() {
                    if let Some(g) = self.gsub_apply_lookup_type_8(li, &gids, pos) {
                        buf[pos].gid = g;
                    }
                }
            }
            _ => {}
        }
    }

    /// Reconcile a contextual/chained GSUB rewrite (which returns a full
    /// rewritten gid run) back into the `WorkGlyph` buffer, preserving
    /// clusters as best we can. The rewrite may change the buffer length
    /// (a nested multiple- or ligature-substitution record). We align the
    /// unchanged prefix/suffix and assign the source cluster of `pos` to
    /// any glyphs in the changed middle.
    fn reconcile_context_rewrite(
        &self,
        buf: &mut Vec<WorkGlyph>,
        old: &[u16],
        new: Vec<u16>,
        pos: usize,
    ) {
        if new == old {
            return;
        }
        // Common unchanged prefix.
        let mut pre = 0usize;
        while pre < old.len() && pre < new.len() && old[pre] == new[pre] {
            pre += 1;
        }
        // Common unchanged suffix.
        let mut suf = 0usize;
        while suf < (old.len() - pre)
            && suf < (new.len() - pre)
            && old[old.len() - 1 - suf] == new[new.len() - 1 - suf]
        {
            suf += 1;
        }
        let cluster = buf.get(pos).map(|w| w.cluster).unwrap_or(0);
        let mut rebuilt: Vec<WorkGlyph> = Vec::with_capacity(new.len());
        for &g in &new[..pre] {
            let c = buf.get(rebuilt.len()).map(|w| w.cluster).unwrap_or(cluster);
            rebuilt.push(WorkGlyph { gid: g, cluster: c });
        }
        for &g in &new[pre..new.len() - suf] {
            rebuilt.push(WorkGlyph { gid: g, cluster });
        }
        let suffix_start_old = old.len() - suf;
        for (k, &g) in new[new.len() - suf..].iter().enumerate() {
            let c = buf
                .get(suffix_start_old + k)
                .map(|w| w.cluster)
                .unwrap_or(cluster);
            rebuilt.push(WorkGlyph { gid: g, cluster: c });
        }
        *buf = rebuilt;
    }

    /// GPOS positioning stage. Seeds advances from `hmtx`, then applies
    /// the active GPOS lookups in LookupList order.
    fn run_gpos(
        &self,
        buf: Vec<WorkGlyph>,
        script: [u8; 4],
        lang: Option<[u8; 4]>,
        features: &[[u8; 4]],
    ) -> Vec<ShapedGlyph> {
        // Seed every glyph with its nominal horizontal advance.
        let mut out: Vec<ShapedGlyph> = buf
            .iter()
            .map(|w| ShapedGlyph {
                glyph_id: w.gid,
                cluster: w.cluster,
                x_offset: 0,
                y_offset: 0,
                x_advance: self.glyph_advance(w.gid) as i32,
                y_advance: 0,
            })
            .collect();

        if self.gpos.is_none() {
            return out;
        }
        let resolved = self.gpos_features_for_script_at_instance(script, lang);
        let mut active: Vec<u16> = Vec::new();
        for feat in &resolved {
            if !features.contains(&feat.tag) {
                continue;
            }
            for &li in &feat.lookup_indices {
                if !active.contains(&li) {
                    active.push(li);
                }
            }
        }
        if active.is_empty() {
            return out;
        }
        active.sort_unstable();

        let types = self.gpos_lookup_list();
        for &li in &active {
            let kind = types
                .iter()
                .find(|(idx, _, _)| *idx == li)
                .map(|(_, k, _)| *k)
                .unwrap_or(0);
            self.apply_gpos_lookup(&mut out, li, kind);
        }
        out
    }

    /// Apply one GPOS lookup of the given effective `kind` across the
    /// positioned buffer.
    fn apply_gpos_lookup(&self, out: &mut [ShapedGlyph], li: u16, kind: u16) {
        match kind {
            1 => {
                // Single adjustment.
                for g in out.iter_mut() {
                    if let Some(v) = self.gpos_apply_lookup_type_1(li, g.glyph_id) {
                        g.x_offset += v.x_placement as i32;
                        g.y_offset += v.y_placement as i32;
                        g.x_advance += v.x_advance as i32;
                        g.y_advance += v.y_advance as i32;
                    }
                }
            }
            2 => {
                // Pair adjustment (kerning). The legacy single-value
                // `lookup_kerning` path extracts the x-advance applied to
                // the left glyph of each pair. The pair members are the
                // current glyph and the *next non-skipped* glyph per the
                // lookup's §2 skip filter — so a kern pair separated by an
                // (ignored) combining mark still kerns, the canonical
                // IGNORE_MARKS-on-kern case.
                let gdef = self.gdef.as_ref();
                let flags = self.gpos_lookup_flags(li);
                let mfs = self.gpos_lookup_mark_filtering_set(li);
                for i in 0..out.len() {
                    if self.lookup_skips_glyph(flags, mfs, out[i].glyph_id) {
                        continue;
                    }
                    // Find the next glyph the lookup does not skip.
                    let right_idx = ((i + 1)..out.len())
                        .find(|&k| !self.lookup_skips_glyph(flags, mfs, out[k].glyph_id));
                    let right_idx = match right_idx {
                        Some(k) => k,
                        None => break,
                    };
                    let left = out[i].glyph_id;
                    let right = out[right_idx].glyph_id;
                    let adj = self
                        .gpos
                        .as_ref()
                        .map(|g| g.lookup_kerning_at(li, left, right, gdef))
                        .unwrap_or(0);
                    out[i].x_advance += adj as i32;
                }
            }
            3 => {
                // Cursive attachment: glyph N+1's entry anchor lands on
                // glyph N's exit anchor. The per-glyph delta moves N+1 so
                // its entry aligns with N's exit (x via offset, the
                // baseline shift via y_offset). Glyphs the lookup skips
                // (§2) are invisible to the chain, so the exit of N is
                // matched against the entry of the next *non-skipped*
                // glyph.
                let flags = self.gpos_lookup_flags(li);
                let mfs = self.gpos_lookup_mark_filtering_set(li);
                let mut prev_exit: Option<(i16, i16)> = None;
                for g in out.iter_mut() {
                    if self.lookup_skips_glyph(flags, mfs, g.glyph_id) {
                        continue;
                    }
                    if let Some(att) = self.gpos_apply_lookup_type_3(li, g.glyph_id) {
                        if let (Some((px, py)), Some((ex, ey))) = (prev_exit, att.entry) {
                            g.x_offset += (px - ex) as i32;
                            g.y_offset += (py - ey) as i32;
                        }
                        prev_exit = att.exit;
                    } else {
                        prev_exit = None;
                    }
                }
            }
            4 => {
                // Mark-to-base: a mark glyph attaches to the nearest
                // preceding base glyph.
                self.apply_mark_attach(out, li, false);
            }
            5 => {
                // Mark-to-ligature: a mark attaches to a component of a
                // preceding ligature. We attach to the last preceding
                // ligature, component 0 (a reasonable default without
                // per-component cluster tracking from the substitution
                // stage); the per-lookup apply path handles component
                // resolution when given an explicit component.
                self.apply_mark_to_ligature(out, li);
            }
            6 => {
                // Mark-to-mark: a mark attaches to the immediately
                // preceding mark.
                self.apply_mark_attach(out, li, true);
            }
            7 => {
                // Contextual positioning.
                let gids: Vec<u16> = out.iter().map(|g| g.glyph_id).collect();
                for pos in 0..out.len() {
                    if let Some(records) = self.gpos_apply_lookup_type_7(li, &gids, pos) {
                        apply_pos_records(out, &records);
                    }
                }
            }
            8 => {
                // Chained-context positioning.
                let gids: Vec<u16> = out.iter().map(|g| g.glyph_id).collect();
                for pos in 0..out.len() {
                    if let Some(records) = self.gpos_apply_lookup_type_8(li, &gids, pos) {
                        apply_pos_records(out, &records);
                    }
                }
            }
            _ => {}
        }
    }

    /// Shared mark-to-base (`to_mark = false`) / mark-to-mark
    /// (`to_mark = true`) attachment. For each mark glyph, find the
    /// nearest preceding attachment glyph (a base for mark-to-base, a
    /// mark for mark-to-mark) that the lookup binds it to, and offset the
    /// mark so its anchor lands on the base's anchor.
    ///
    /// The candidate attachment glyph is the nearest preceding glyph the
    /// lookup's §2 skip filter does *not* ignore: a mark-to-base lookup
    /// almost always sets IGNORE_MARKS so the scan steps over interspersed
    /// marks and lands on the base, while a mark-to-mark (`mkmk`) lookup
    /// leaves marks visible so it pairs with the immediately preceding
    /// mark. The current mark itself is left unattached when the lookup
    /// skips it.
    fn apply_mark_attach(&self, out: &mut [ShapedGlyph], li: u16, to_mark: bool) {
        let flags = self.gpos_lookup_flags(li);
        let mfs = self.gpos_lookup_mark_filtering_set(li);
        for i in 0..out.len() {
            let mark = out[i].glyph_id;
            if self.lookup_skips_glyph(flags, mfs, mark) {
                continue;
            }
            // Scan backwards for the nearest non-skipped attachment glyph.
            for j in (0..i).rev() {
                let base = out[j].glyph_id;
                if self.lookup_skips_glyph(flags, mfs, base) {
                    continue;
                }
                let hit = if to_mark {
                    self.gpos
                        .as_ref()
                        .and_then(|g| g.apply_mark_to_mark_at(li, base, mark))
                } else {
                    self.gpos
                        .as_ref()
                        .and_then(|g| g.apply_mark_to_base_at(li, base, mark))
                };
                if let Some((dx, dy)) = hit {
                    // Place the mark relative to the base's pen origin.
                    // The base sits at the accumulated advance from j to i;
                    // a mark has (typically) zero advance, so its drawing
                    // origin is the current pen. We express attachment as a
                    // placement offset that pulls the mark back over the
                    // base by the base's advance run plus the anchor delta.
                    let between: i32 = out[j..i].iter().map(|g| g.x_advance).sum();
                    out[i].x_offset += dx as i32 - between;
                    out[i].y_offset += dy as i32;
                }
                // The first non-skipped predecessor is the only attachment
                // candidate, whether or not it produced a hit.
                break;
            }
        }
    }

    /// Mark-to-ligature attachment (LookupType 5). Attaches each mark to
    /// the nearest preceding ligature glyph at component 0. The candidate
    /// ligature is the nearest preceding glyph the lookup's §2 skip filter
    /// does not ignore (a `mark` / mark-to-ligature lookup typically sets
    /// IGNORE_MARKS so the scan steps over interspersed marks onto the
    /// ligature).
    fn apply_mark_to_ligature(&self, out: &mut [ShapedGlyph], li: u16) {
        let flags = self.gpos_lookup_flags(li);
        let mfs = self.gpos_lookup_mark_filtering_set(li);
        for i in 0..out.len() {
            let mark = out[i].glyph_id;
            if self.lookup_skips_glyph(flags, mfs, mark) {
                continue;
            }
            for j in (0..i).rev() {
                let lig = out[j].glyph_id;
                if self.lookup_skips_glyph(flags, mfs, lig) {
                    continue;
                }
                if let Some((dx, dy)) = self
                    .gpos
                    .as_ref()
                    .and_then(|g| g.apply_lookup_type_5(li, lig, 0, mark))
                {
                    let between: i32 = out[j..i].iter().map(|g| g.x_advance).sum();
                    out[i].x_offset += dx as i32 - between;
                    out[i].y_offset += dy as i32;
                }
                // The first non-skipped predecessor is the only candidate.
                break;
            }
        }
    }
}

/// Apply a set of [`PosRecord`]s (absolute-indexed) from a contextual /
/// chained positioning match onto the output buffer.
fn apply_pos_records(out: &mut [ShapedGlyph], records: &[PosRecord]) {
    for r in records {
        if let Some(g) = out.get_mut(r.glyph_index) {
            g.x_offset += r.value.x_placement as i32;
            g.y_offset += r.value.y_placement as i32;
            g.x_advance += r.value.x_advance as i32;
            g.y_advance += r.value.y_advance as i32;
        }
    }
}