Skip to main content

fea_rs_ast/
gpos.rs

1use std::ops::Range;
2
3use fea_rs::{
4    Kind,
5    typed::{AstNode as _, GlyphOrClass},
6};
7
8use crate::{
9    Anchor, AsFea, GlyphContainer, MarkClass, PotentiallyContextualStatement, ValueRecord,
10    from_anchor,
11};
12
13/// A single positioning rule (GPOS type 1)
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct SinglePosStatement {
16    /// The glyphs and their associated value records to be positioned
17    pub pos: Vec<(GlyphContainer, Option<ValueRecord>)>,
18    /// The prefix (backtrack) glyphs
19    pub prefix: Vec<GlyphContainer>,
20    /// The suffix (lookahead) glyphs
21    pub suffix: Vec<GlyphContainer>,
22    /// Whether to force this statement to be treated as a contextual positioning rule
23    pub force_chain: bool,
24    /// The location of the statement in the source feature file
25    pub location: Range<usize>,
26}
27
28impl SinglePosStatement {
29    /// Create a new single positioning statement.
30    pub fn new(
31        prefix: Vec<GlyphContainer>,
32        suffix: Vec<GlyphContainer>,
33        pos: Vec<(GlyphContainer, Option<ValueRecord>)>,
34        force_chain: bool,
35        location: Range<usize>,
36    ) -> Self {
37        Self {
38            prefix,
39            suffix,
40            pos,
41            force_chain,
42            location,
43        }
44    }
45}
46
47impl PotentiallyContextualStatement for SinglePosStatement {
48    fn prefix(&self) -> &[GlyphContainer] {
49        &self.prefix
50    }
51    fn suffix(&self) -> &[GlyphContainer] {
52        &self.suffix
53    }
54    fn force_chain(&self) -> bool {
55        self.force_chain
56    }
57
58    fn format_begin(&self, _indent: &str) -> String {
59        "pos ".to_string()
60    }
61
62    fn format_contextual_parts(&self, indent: &str) -> Vec<String> {
63        self.pos
64            .iter()
65            .map(|(p, vr)| {
66                format!(
67                    "{}'{}",
68                    p.as_fea(""),
69                    vr.as_ref()
70                        .map(|v| format!(" {}", v.as_fea(indent)))
71                        .unwrap_or_default()
72                )
73            })
74            .collect()
75    }
76
77    fn format_noncontextual_parts(&self, indent: &str) -> Vec<String> {
78        self.pos
79            .iter()
80            .map(|(p, vr)| {
81                format!(
82                    "{} {}",
83                    p.as_fea(""),
84                    vr.as_ref()
85                        .map(|v| v.as_fea(indent).to_string())
86                        .unwrap_or("<NULL>".to_string())
87                )
88            })
89            .collect()
90    }
91}
92
93impl From<fea_rs::typed::Gpos1> for SinglePosStatement {
94    fn from(val: fea_rs::typed::Gpos1) -> Self {
95        let target = val.iter().find_map(GlyphOrClass::cast).unwrap();
96        let value_record = val
97            .iter()
98            .find_map(fea_rs::typed::ValueRecord::cast)
99            .unwrap();
100        Self::new(
101            vec![],
102            vec![],
103            vec![(target.into(), Some(value_record.into()))],
104            false,
105            val.node().range(),
106        )
107    }
108}
109
110/// A pair positioning rule (GPOS type 2)
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct PairPosStatement {
113    /// The first glyph or class in the pair
114    pub glyphs_1: GlyphContainer,
115    /// The second glyph or class in the pair
116    pub glyphs_2: GlyphContainer,
117    /// The value record for the first glyph
118    pub value_record_1: ValueRecord,
119    /// The value record for the second glyph (if any)
120    pub value_record_2: Option<ValueRecord>,
121    /// Whether this is an enumerated pair positioning rule
122    pub enumerated: bool,
123    /// The location of the statement in the source feature file
124    pub location: Range<usize>,
125}
126
127impl PairPosStatement {
128    /// Create a new pair positioning statement.
129    pub fn new(
130        glyphs_1: GlyphContainer,
131        glyphs_2: GlyphContainer,
132        value_record_1: ValueRecord,
133        value_record_2: Option<ValueRecord>,
134        enumerated: bool,
135        location: Range<usize>,
136    ) -> Self {
137        Self {
138            glyphs_1,
139            glyphs_2,
140            value_record_1,
141            value_record_2,
142            enumerated,
143            location,
144        }
145    }
146}
147
148impl AsFea for PairPosStatement {
149    fn as_fea(&self, indent: &str) -> String {
150        let mut res = String::new();
151        if self.enumerated {
152            res.push_str("enum ");
153        }
154        res.push_str("pos ");
155        if let Some(vr2) = &self.value_record_2 {
156            // glyphs1 valuerecord1 glyphs2 valuerecord2
157            res.push_str(&format!(
158                "{} {} {} {}",
159                self.glyphs_1.as_fea(""),
160                self.value_record_1.as_fea(indent),
161                self.glyphs_2.as_fea(""),
162                vr2.as_fea(indent)
163            ));
164        } else {
165            // glyphs1 glyphs2 valuerecord1
166            res.push_str(&format!(
167                "{} {} {}",
168                self.glyphs_1.as_fea(""),
169                self.glyphs_2.as_fea(""),
170                self.value_record_1.as_fea(indent),
171            ));
172        }
173        res.push(';');
174        res
175    }
176}
177
178impl From<fea_rs::typed::Gpos2> for PairPosStatement {
179    fn from(val: fea_rs::typed::Gpos2) -> Self {
180        let enumerated = val.iter().any(|t| t.kind() == Kind::EnumKw);
181        let glyphs_1 = val.iter().find_map(GlyphOrClass::cast).unwrap().into();
182        let glyphs_2 = val
183            .iter()
184            .filter_map(GlyphOrClass::cast)
185            .nth(1)
186            .unwrap()
187            .into();
188        let value_record_1 = val
189            .iter()
190            .find_map(fea_rs::typed::ValueRecord::cast)
191            .unwrap();
192        let value_record_2 = val
193            .iter()
194            .filter_map(fea_rs::typed::ValueRecord::cast)
195            .nth(1)
196            .map(|vr| vr.into());
197        Self::new(
198            glyphs_1,
199            glyphs_2,
200            value_record_1.into(),
201            value_record_2,
202            enumerated,
203            val.node().range(),
204        )
205    }
206}
207
208/// A cursive positioning rule (GPOS type 3)
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct CursivePosStatement {
211    /// The location of the statement in the source feature file
212    pub location: Range<usize>,
213    /// The glyph or class this rule applies to
214    pub glyphclass: GlyphContainer,
215    /// The entry anchor point
216    pub entry: Option<Anchor>,
217    /// The exit anchor point
218    pub exit: Option<Anchor>,
219}
220
221impl CursivePosStatement {
222    /// Create a new cursive positioning statement.
223    pub fn new(
224        glyphclass: GlyphContainer,
225        entry: Option<Anchor>,
226        exit: Option<Anchor>,
227        location: Range<usize>,
228    ) -> Self {
229        Self {
230            glyphclass,
231            entry,
232            exit,
233            location,
234        }
235    }
236}
237
238impl AsFea for CursivePosStatement {
239    fn as_fea(&self, indent: &str) -> String {
240        format!(
241            "pos cursive {} {} {};",
242            self.glyphclass.as_fea(""),
243            self.entry
244                .as_ref()
245                .map(|e| e.as_fea(indent))
246                .unwrap_or_else(|| "<anchor NULL>".to_string()),
247            self.exit
248                .as_ref()
249                .map(|e| e.as_fea(indent))
250                .unwrap_or_else(|| "<anchor NULL>".to_string()),
251        )
252    }
253}
254impl From<fea_rs::typed::Gpos3> for CursivePosStatement {
255    fn from(val: fea_rs::typed::Gpos3) -> Self {
256        let glyphclass = val.iter().find_map(GlyphOrClass::cast).unwrap().into();
257        let entry = val.iter().find_map(fea_rs::typed::Anchor::cast).unwrap();
258        let exit = val
259            .iter()
260            .filter_map(fea_rs::typed::Anchor::cast)
261            .nth(1)
262            .unwrap();
263        Self::new(
264            glyphclass,
265            from_anchor(entry),
266            from_anchor(exit),
267            val.node().range(),
268        )
269    }
270}
271
272/// A mark-to-base positioning rule (GPOS type 4)
273///
274/// Example: `pos base a <anchor 625 1800> mark @TOP_MARKS;`
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct MarkBasePosStatement {
277    /// The base glyph or class
278    pub base: GlyphContainer,
279    /// The list of (Anchor, MarkClass) tuples for the marks
280    pub marks: Vec<(Anchor, MarkClass)>,
281    /// The location of the statement in the source feature file
282    pub location: Range<usize>,
283}
284
285impl MarkBasePosStatement {
286    /// Create a new mark-to-base positioning statement.
287    pub fn new(
288        base: GlyphContainer,
289        marks: Vec<(Anchor, MarkClass)>,
290        location: Range<usize>,
291    ) -> Self {
292        Self {
293            base,
294            marks,
295            location,
296        }
297    }
298}
299
300impl AsFea for MarkBasePosStatement {
301    fn as_fea(&self, indent: &str) -> String {
302        let mut res = format!("pos base {}", self.base.as_fea(""));
303        for (anchor, mark_class) in &self.marks {
304            res.push_str(&format!(
305                "\n{}    {} mark @{}",
306                indent,
307                anchor.as_fea(""),
308                mark_class.name
309            ));
310        }
311        res.push(';');
312        res
313    }
314}
315
316impl From<fea_rs::typed::Gpos4> for MarkBasePosStatement {
317    fn from(val: fea_rs::typed::Gpos4) -> Self {
318        // Extract base glyph (it's after "pos" keyword and "base" keyword)
319        let base: GlyphContainer = val
320            .iter()
321            .filter(|t| t.kind() != Kind::Whitespace)
322            .nth(2) // Skip "pos" and "base" keywords
323            .and_then(GlyphOrClass::cast)
324            .unwrap()
325            .into();
326
327        // Extract all AnchorMark nodes (after the base glyph)
328        let marks: Vec<(Anchor, MarkClass)> = val
329            .iter()
330            .filter_map(fea_rs::typed::AnchorMark::cast)
331            .map(|anchor_mark| {
332                // Get the anchor from the AnchorMark node
333                let anchor_node = anchor_mark
334                    .iter()
335                    .find_map(fea_rs::typed::Anchor::cast)
336                    .unwrap();
337                let anchor = from_anchor(anchor_node).unwrap();
338
339                // Get the mark class name (it's a @GlyphClass token)
340                let mark_class_node = anchor_mark
341                    .iter()
342                    .find_map(fea_rs::typed::GlyphClassName::cast)
343                    .unwrap();
344                let mark_class_name = mark_class_node.text().trim_start_matches('@');
345                let mark_class = MarkClass::new(mark_class_name);
346
347                (anchor, mark_class)
348            })
349            .collect();
350
351        MarkBasePosStatement::new(base, marks, val.range())
352    }
353}
354
355/// A mark-to-ligature positioning rule (GPOS type 5)
356///
357/// The `marks` field is a list of lists: each element represents a component glyph,
358/// and is made up of a list of (Anchor, MarkClass) tuples for that component.
359///
360/// Example: `pos ligature lam_meem_jeem <anchor 625 1800> mark @TOP_MARKS ligComponent <anchor 376 -378> mark @BOTTOM_MARKS;`
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct MarkLigPosStatement {
363    /// The ligature glyph or class
364    pub ligatures: GlyphContainer,
365    /// The list of lists of (Anchor, MarkClass) tuples for each component
366    pub marks: Vec<Vec<(Anchor, MarkClass)>>,
367    /// The location of the statement in the source feature file
368    pub location: Range<usize>,
369}
370
371impl MarkLigPosStatement {
372    /// Create a new mark-to-ligature positioning statement.
373    pub fn new(
374        ligatures: GlyphContainer,
375        marks: Vec<Vec<(Anchor, MarkClass)>>,
376        location: Range<usize>,
377    ) -> Self {
378        Self {
379            ligatures,
380            marks,
381            location,
382        }
383    }
384}
385
386impl AsFea for MarkLigPosStatement {
387    fn as_fea(&self, indent: &str) -> String {
388        let mut res = format!("pos ligature {}", self.ligatures.as_fea(""));
389
390        // Format each ligature component
391        let mut ligs = Vec::new();
392        for component in &self.marks {
393            if component.is_empty() {
394                // Empty component gets NULL anchor
395                ligs.push(format!("\n{}    <anchor NULL>", indent));
396            } else {
397                let mut temp = String::new();
398                for (anchor, mark_class) in component {
399                    temp.push_str(&format!(
400                        "\n{}    {} mark @{}",
401                        indent,
402                        anchor.as_fea(""),
403                        mark_class.name
404                    ));
405                }
406                ligs.push(temp);
407            }
408        }
409
410        // Join components with "ligComponent" keyword (but not before first)
411        res.push_str(&ligs.join(&format!("\n{}    ligComponent", indent)));
412        res.push(';');
413        res
414    }
415}
416
417impl From<fea_rs::typed::Gpos5> for MarkLigPosStatement {
418    fn from(val: fea_rs::typed::Gpos5) -> Self {
419        // Extract ligature glyph (it's after "pos" keyword and "ligature" keyword)
420        let ligatures: GlyphContainer = val
421            .iter()
422            .filter(|t| t.kind() != Kind::Whitespace)
423            .nth(2) // Skip "pos" and "ligature" keywords
424            .and_then(GlyphOrClass::cast)
425            .unwrap()
426            .into();
427
428        // Extract all LigatureComponent nodes
429        let marks: Vec<Vec<(Anchor, MarkClass)>> = val
430            .iter()
431            .filter_map(fea_rs::typed::LigatureComponent::cast)
432            .map(|lig_component| {
433                // Extract all AnchorMark nodes within this component
434                lig_component
435                    .iter()
436                    .filter_map(fea_rs::typed::AnchorMark::cast)
437                    .flat_map(|anchor_mark| {
438                        // Get the anchor from the AnchorMark node
439                        let anchor_node = anchor_mark
440                            .iter()
441                            .find_map(fea_rs::typed::Anchor::cast)
442                            .unwrap();
443                        let anchor = from_anchor(anchor_node)?;
444
445                        // Get the mark class name (it's a @GlyphClass token)
446                        let mark_class_node = anchor_mark
447                            .iter()
448                            .find_map(fea_rs::typed::GlyphClassName::cast)?;
449                        let mark_class_name = mark_class_node.text().trim_start_matches('@');
450                        let mark_class = MarkClass::new(mark_class_name);
451
452                        Some((anchor, mark_class))
453                    })
454                    .collect()
455            })
456            .collect();
457
458        MarkLigPosStatement::new(ligatures, marks, val.range())
459    }
460}
461
462/// A mark-to-mark positioning rule (GPOS type 6)
463#[derive(Debug, Clone, PartialEq, Eq)]
464pub struct MarkMarkPosStatement {
465    /// The base glyph or class to which the marks will be attached
466    pub base_marks: GlyphContainer,
467    /// The list of (Anchor, MarkClass) tuples for the marks
468    pub marks: Vec<(Anchor, MarkClass)>,
469    /// The location of the statement in the source feature file
470    pub location: Range<usize>,
471}
472
473impl MarkMarkPosStatement {
474    /// Create a new mark-to-mark positioning statement.
475    pub fn new(
476        base_marks: GlyphContainer,
477        marks: Vec<(Anchor, MarkClass)>,
478        location: Range<usize>,
479    ) -> Self {
480        Self {
481            base_marks,
482            marks,
483            location,
484        }
485    }
486}
487
488impl AsFea for MarkMarkPosStatement {
489    fn as_fea(&self, indent: &str) -> String {
490        let mut res = format!("pos mark {}", self.base_marks.as_fea(""));
491        for (anchor, mark_class) in &self.marks {
492            res.push_str(&format!(
493                "\n{}    {} mark @{}",
494                indent,
495                anchor.as_fea(""),
496                mark_class.name
497            ));
498        }
499        res.push(';');
500        res
501    }
502}
503
504impl From<fea_rs::typed::Gpos6> for MarkMarkPosStatement {
505    fn from(val: fea_rs::typed::Gpos6) -> Self {
506        // Extract base mark glyph (it's after "pos" keyword and "mark" keyword)
507        let base_marks: GlyphContainer = val
508            .iter()
509            .filter(|t| t.kind() != Kind::Whitespace)
510            .nth(2) // Skip "pos" and "mark" keywords
511            .and_then(GlyphOrClass::cast)
512            .unwrap()
513            .into();
514
515        // Extract all AnchorMark nodes (after the base mark glyph)
516        let marks: Vec<(Anchor, MarkClass)> = val
517            .iter()
518            .filter_map(fea_rs::typed::AnchorMark::cast)
519            .map(|anchor_mark| {
520                // Get the anchor from the AnchorMark node
521                let anchor_node = anchor_mark
522                    .iter()
523                    .find_map(fea_rs::typed::Anchor::cast)
524                    .unwrap();
525                let anchor = from_anchor(anchor_node).unwrap();
526
527                // Get the mark class name (it's a @GlyphClass token)
528                let mark_class_node = anchor_mark
529                    .iter()
530                    .find_map(fea_rs::typed::GlyphClassName::cast)
531                    .unwrap();
532                let mark_class_name = mark_class_node.text().trim_start_matches('@');
533                let mark_class = MarkClass::new(mark_class_name);
534
535                (anchor, mark_class)
536            })
537            .collect();
538
539        MarkMarkPosStatement::new(base_marks, marks, val.node().range())
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use crate::GlyphName;
546
547    use super::*;
548
549    #[test]
550    fn test_generate_gpos1() {
551        let gpos1 = SinglePosStatement::new(
552            vec![GlyphContainer::GlyphName(GlyphName::new("x"))],
553            vec![],
554            vec![(
555                GlyphContainer::GlyphName(GlyphName::new("A")),
556                Some(ValueRecord {
557                    x_advance: Some(50.into()),
558                    y_advance: None,
559                    x_placement: None,
560                    y_placement: None,
561                    x_placement_device: None,
562                    y_placement_device: None,
563                    x_advance_device: None,
564                    y_advance_device: None,
565                    vertical: false,
566                    location: 0..0,
567                    name: None,
568                }),
569            )],
570            false,
571            0..10,
572        );
573        let fea_str = gpos1.as_fea("");
574        assert_eq!(fea_str, "pos x A' 50;");
575    }
576
577    #[test]
578    fn test_roundtrip_gpos1() {
579        const FEA: &str = "feature foo { pos A 50; } foo;";
580        let (parsed, _) = fea_rs::parse::parse_string(FEA);
581        let gpos1 = parsed
582            .root()
583            .iter_children()
584            .find_map(fea_rs::typed::Feature::cast)
585            .and_then(|feature| {
586                feature
587                    .node()
588                    .iter_children()
589                    .find_map(fea_rs::typed::Gpos1::cast)
590            })
591            .unwrap();
592        let gpos1_stmt: SinglePosStatement = gpos1.into();
593        let fea_str_roundtrip = gpos1_stmt.as_fea("");
594        assert_eq!(fea_str_roundtrip, "pos A 50;");
595    }
596
597    #[test]
598    fn test_generate_gpos2() {
599        let gpos2 = PairPosStatement::new(
600            GlyphContainer::GlyphName(GlyphName::new("A")),
601            GlyphContainer::GlyphName(GlyphName::new("B")),
602            ValueRecord {
603                x_advance: Some(50.into()),
604                y_advance: None,
605                x_placement: None,
606                y_placement: None,
607                x_placement_device: None,
608                y_placement_device: None,
609                x_advance_device: None,
610                y_advance_device: None,
611                vertical: false,
612                location: 0..0,
613                name: None,
614            },
615            Some(ValueRecord {
616                x_advance: Some(30.into()),
617                y_advance: None,
618                x_placement: None,
619                y_placement: None,
620                x_placement_device: None,
621                y_placement_device: None,
622                x_advance_device: None,
623                y_advance_device: None,
624                vertical: false,
625                location: 0..0,
626                name: None,
627            }),
628            false,
629            0..10,
630        );
631        let fea_str = gpos2.as_fea("");
632        assert_eq!(fea_str, "pos A 50 B 30;");
633    }
634
635    #[test]
636    fn test_generate_gpos3() {
637        let gpos3 = CursivePosStatement::new(
638            GlyphContainer::GlyphName(GlyphName::new("A")),
639            Some(Anchor::new_simple(100, 200, 0..0)),
640            Some(Anchor::new_simple(150, 250, 0..0)),
641            0..10,
642        );
643        let fea_str = gpos3.as_fea("");
644        assert_eq!(fea_str, "pos cursive A <anchor 100 200> <anchor 150 250>;");
645
646        // Try with some NULL anchors
647        let gpos3_null = CursivePosStatement::new(
648            GlyphContainer::GlyphName(GlyphName::new("A")),
649            None,
650            Some(Anchor::new_simple(150, 250, 0..10)),
651            0..10,
652        );
653        let fea_str_null = gpos3_null.as_fea("");
654        assert_eq!(
655            fea_str_null,
656            "pos cursive A <anchor NULL> <anchor 150 250>;"
657        );
658    }
659
660    #[test]
661    fn test_roundtrip_gpos3() {
662        const FEA: &str = "feature foo { pos cursive A <anchor 100 200> <anchor 150 250>; } foo;";
663        let (parsed, _) = fea_rs::parse::parse_string(FEA);
664        let gpos3 = parsed
665            .root()
666            .iter_children()
667            .find_map(fea_rs::typed::Feature::cast)
668            .and_then(|feature| {
669                feature
670                    .node()
671                    .iter_children()
672                    .find_map(fea_rs::typed::Gpos3::cast)
673            })
674            .unwrap();
675        let gpos3_stmt: CursivePosStatement = gpos3.into();
676        let fea_str_roundtrip = gpos3_stmt.as_fea("");
677        assert_eq!(
678            fea_str_roundtrip,
679            "pos cursive A <anchor 100 200> <anchor 150 250>;"
680        );
681    }
682
683    #[test]
684    fn test_roundtrip_gpos4() {
685        const FEA: &str = "feature mark { pos base a <anchor 625 1800> mark @TOP_MARKS; } mark;";
686        let (parsed, _) = fea_rs::parse::parse_string(FEA);
687        let gpos4 = parsed
688            .root()
689            .iter_children()
690            .find_map(fea_rs::typed::Feature::cast)
691            .and_then(|feature| {
692                feature
693                    .node()
694                    .iter_children()
695                    .find_map(fea_rs::typed::Gpos4::cast)
696            })
697            .unwrap();
698        let stmt = MarkBasePosStatement::from(gpos4);
699        assert_eq!(stmt.base.as_fea(""), "a");
700        assert_eq!(stmt.marks.len(), 1);
701        assert_eq!(stmt.marks[0].1.name, "TOP_MARKS");
702        assert_eq!(
703            stmt.as_fea(""),
704            "pos base a\n    <anchor 625 1800> mark @TOP_MARKS;"
705        );
706    }
707
708    #[test]
709    fn test_generation_gpos4() {
710        let stmt = MarkBasePosStatement::new(
711            GlyphContainer::GlyphName(GlyphName::new("a")),
712            vec![
713                (
714                    Anchor::new_simple(300, 450, 0..0),
715                    MarkClass::new("TOP_MARKS"),
716                ),
717                (
718                    Anchor::new_simple(300, -100, 0..0),
719                    MarkClass::new("BOTTOM_MARKS"),
720                ),
721            ],
722            0..0,
723        );
724        assert_eq!(
725            stmt.as_fea(""),
726            "pos base a\n    <anchor 300 450> mark @TOP_MARKS\n    <anchor 300 -100> mark @BOTTOM_MARKS;"
727        );
728    }
729
730    #[test]
731    fn test_roundtrip_gpos5() {
732        const FEA: &str = "feature test { pos ligature lam_meem_jeem <anchor 625 1800> mark @TOP_MARKS ligComponent <anchor 376 -378> mark @BOTTOM_MARKS; } test;";
733        let (parsed, _) = fea_rs::parse::parse_string(FEA);
734        let gpos5 = parsed
735            .root()
736            .iter_children()
737            .find_map(fea_rs::typed::Feature::cast)
738            .and_then(|feature| {
739                feature
740                    .node()
741                    .iter_children()
742                    .find_map(fea_rs::typed::Gpos5::cast)
743            })
744            .unwrap();
745        let gpos5_stmt: MarkLigPosStatement = gpos5.into();
746        let fea_str_roundtrip = gpos5_stmt.as_fea("");
747        assert_eq!(
748            fea_str_roundtrip,
749            "pos ligature lam_meem_jeem\n    <anchor 625 1800> mark @TOP_MARKS\n    ligComponent\n    <anchor 376 -378> mark @BOTTOM_MARKS;"
750        );
751    }
752
753    #[test]
754    fn test_generate_gpos5() {
755        let stmt = MarkLigPosStatement::new(
756            GlyphContainer::GlyphName(GlyphName::new("lam_meem_jeem")),
757            vec![
758                vec![(
759                    Anchor::new_simple(625, 1800, 0..0),
760                    MarkClass::new("TOP_MARKS"),
761                )],
762                vec![(
763                    Anchor::new_simple(376, -378, 0..0),
764                    MarkClass::new("BOTTOM_MARKS"),
765                )],
766                vec![], // Empty component (NULL anchor)
767            ],
768            0..0,
769        );
770        assert_eq!(
771            stmt.as_fea(""),
772            "pos ligature lam_meem_jeem\n    <anchor 625 1800> mark @TOP_MARKS\n    ligComponent\n    <anchor 376 -378> mark @BOTTOM_MARKS\n    ligComponent\n    <anchor NULL>;"
773        );
774    }
775}