glyphs-reader 1.0.0

Parsing .glyphs source files
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
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
use std::{
    collections::{BTreeMap, HashMap},
    str::FromStr,
};
use thiserror::Error;

use fontdrasil::{
    coords::{NormalizedCoord, NormalizedLocation},
    types::Tag,
    variations::{RoundingBehaviour, VariationModel},
};
use kurbo::{Affine, Point, Vec2};
use smol_str::SmolStr;

use crate::{Anchor, Component, Glyph, Layer, Node, Shape, font::AxisPole};

/// Result of instantiating a smart component.
///
/// Contains both the interpolated shapes and anchors from the smart component.
pub(crate) struct SmartComponentInstance {
    pub shapes: Vec<Shape>,
    pub anchors: Vec<Anchor>,
}

/// Things that can go wrong when instantiating a smart component
#[derive(Debug, Error)]
pub enum BadSmartComponent {
    #[error("unknown component axis '{0}'")]
    UnknownAxis(SmolStr),
    #[error("strange mapping for axis '{axis}': base {base:?} child {child:?}")]
    StrangeAxisMapping {
        base: Option<AxisPole>,
        child: AxisPole,
        axis: SmolStr,
    },
    #[error("Smart component layers are not interpolation compatible")]
    IncoherentLayers,
    #[error("Axis '{0}' is not defined for the default layer")]
    AxisUndefinedForDefaultLayer(SmolStr),
    #[error("No layers exist with associated id '{0}'")]
    NoLayer(String),
}

/// Instantiate an instance of a smart component.
///
/// A smart component is a glyph that defines its own little variation space,
/// such that specific instances of the glyph can be included as components of
/// other glyphs.
///
/// See <https://glyphsapp.com/learn/smart-components>.
///
/// This code is based on <https://github.com/googlefonts/glyphsLib/blob/52c982399b/Lib/glyphsLib/builder/smart_components.py#L96>
pub(crate) fn instantiate_for_layer(
    layer_master_id: &str,
    component: &Component,
    ref_glyph: &Glyph,
) -> Result<SmartComponentInstance, BadSmartComponent> {
    assert!(!ref_glyph.smart_component_axes.is_empty());
    let (axis_order, name_to_tag_map) = axes_for_glyph(ref_glyph);

    // these are the layers of the glyph that have the same associated master
    // as the layer that we are instantiating for.
    let mut relevant_layers = ref_glyph
        .layers
        .iter()
        .filter(|layer| {
            !layer.smart_component_positions.is_empty() && layer.master_id() == layer_master_id
        })
        .collect::<Vec<_>>();

    // make sure default layer is first (in python this happens in the iterator itself)
    // https://github.com/googlefonts/glyphsLib/blob/52c98239/Lib/glyphsLib/classes.py#L555
    if let Some(idx) = relevant_layers
        .iter()
        .position(|l| l.layer_id == layer_master_id)
        .filter(|idx| *idx != 0)
    {
        relevant_layers.swap(0, idx);
    }

    if relevant_layers.is_empty() {
        return Err(BadSmartComponent::NoLayer(layer_master_id.to_string()));
    }
    if relevant_layers.len() == 1 {
        // Only one layer for this master — the smart axes can't vary, so treat
        // as a regular component reference, matching glyphsLib's model=None fallback:
        // https://github.com/googlefonts/glyphsLib/blob/044f19e4/Lib/glyphsLib/builder/smart_components.py#L182-L184
        log::debug!(
            "smart component {} only has one layer, keeping as regular component",
            component.name
        );
        let mut kept = component.clone();
        kept.smart_component_values.clear();
        // No anchors here; they'll be propagated through the component ref
        // by propagate_all_anchors in fontir (see propagate_anchors.rs).
        return Ok(SmartComponentInstance {
            shapes: vec![Shape::Component(kept)],
            anchors: vec![],
        });
    }

    validate_relevant_layers(&relevant_layers)?;
    for axis in ref_glyph.smart_component_axes.keys() {
        if !relevant_layers[0]
            .smart_component_positions
            .contains_key(axis)
        {
            return Err(BadSmartComponent::UnknownAxis(axis.clone()));
        }
    }

    let locations = relevant_layers
        .iter()
        .map(|layer| normalized_location(layer, relevant_layers[0], &name_to_tag_map))
        .collect::<Result<_, _>>()?;

    let model = VariationModel::new_extrapolating(locations, axis_order.clone());

    let axis_tuples = ref_glyph
        .smart_component_axes
        .iter()
        .map(|(name, range)| {
            let default_value = if *relevant_layers[0]
                .smart_component_positions
                .get(name)
                .unwrap()
                == AxisPole::Min
            {
                *range.start()
            } else {
                *range.end()
            };
            (name, (*range.start(), default_value, *range.end()))
        })
        .collect::<HashMap<_, _>>();

    let location: NormalizedLocation = axis_tuples
        .iter()
        .map(|(name, &(min, default, max))| {
            let value = component
                .smart_component_values
                .get(*name)
                .copied()
                .unwrap_or(default as f64);
            (
                *name_to_tag_map.get(*name).unwrap(),
                normalize_value_with_extrapolation(value, (min, default, max)),
            )
        })
        .collect();

    log::debug!(
        "instantiating component '{}' at {location:?}",
        component.name
    );

    let point_seqs = relevant_layers
        .iter()
        .map(|layer| {
            let loc = normalized_location(layer, relevant_layers[0], &name_to_tag_map)?;
            let points = layer
                .shapes
                .iter()
                .filter_map(Shape::as_path)
                .flat_map(|path| path.nodes.iter().map(|node| node.pt))
                .collect::<Vec<_>>();
            Ok((loc, points))
        })
        .collect::<Result<HashMap<_, _>, BadSmartComponent>>()?;
    let deltas = model
        .deltas_with_rounding(&point_seqs, RoundingBehaviour::None)
        .unwrap();
    let points = model.interpolate_from_deltas(&location, &deltas);
    let mut shapes = shapes_with_new_points(relevant_layers[0], &points);
    shapes.iter_mut().for_each(|shape| {
        shape.apply_affine(component.transform);
        if component.transform.determinant() < 0.0 {
            shape.reverse();
        }
    });

    // Interpolate anchors from the smart component layers
    let anchors = interpolate_anchors(
        &model,
        &location,
        &relevant_layers,
        &name_to_tag_map,
        component.transform,
    )?;

    Ok(SmartComponentInstance { shapes, anchors })
}

fn validate_relevant_layers(layers: &[&Layer]) -> Result<(), BadSmartComponent> {
    let Some((head, tail)) = layers.split_first() else {
        return Ok(());
    };

    for layer in tail {
        if layer.shapes.len() != head.shapes.len()
            || layer
                .shapes
                .iter()
                .zip(&head.shapes)
                .any(|pair| match pair {
                    (Shape::Component(_), Shape::Component(_)) => false,
                    (Shape::Path(one), Shape::Path(two)) => one.nodes.len() != two.nodes.len(),
                    _ => true,
                })
        {
            return Err(BadSmartComponent::IncoherentLayers);
        }
    }
    Ok(())
}

// component parts just have names, not tags, but VariationModel needs tags;
// we give them tags like `ax01`, `ax02`.. etc
fn axes_for_glyph(glyph: &Glyph) -> (Vec<Tag>, BTreeMap<SmolStr, Tag>) {
    let name_to_tag_map: BTreeMap<_, _> = glyph
        .smart_component_axes
        .keys()
        .enumerate()
        .map(|(i, name)| {
            (
                name.clone(),
                Tag::from_str(&format!("ax{i:02}")).expect("only 99 axes supported!"),
            )
        })
        .collect();
    let axis_order = name_to_tag_map.values().copied().collect();

    (axis_order, name_to_tag_map)
}

fn shapes_with_new_points(layer: &Layer, points: &[Vec2]) -> Vec<Shape> {
    let mut points = points;
    layer
        .shapes
        .iter()
        .map(|shape| match shape {
            Shape::Path(path) => {
                let mut path = path.to_owned();
                for (node, newpt) in path.nodes.iter_mut().zip(points) {
                    node.pt = newpt.to_point();
                }
                points = &points[path.nodes.len()..];
                Shape::Path(path)
            }
            // we just skip components, which matches fonttools. Should we error instead?
            Shape::Component(_) => shape.clone(),
        })
        .collect()
}

/// Interpolate anchor positions from smart component layers.
///
/// Uses the same variation model as shape interpolation to compute anchor
/// positions at the target location in the smart component's axis space.
fn interpolate_anchors(
    model: &VariationModel,
    location: &NormalizedLocation,
    layers: &[&Layer],
    name_to_tag_map: &BTreeMap<SmolStr, Tag>,
    transform: Affine,
) -> Result<Vec<Anchor>, BadSmartComponent> {
    // Get anchor names from the default layer (first layer), preserving order
    let anchor_names: Vec<_> = layers[0].anchors.iter().map(|a| a.name.clone()).collect();

    if anchor_names.is_empty() {
        return Ok(Vec::new());
    }

    // Build position sequences for all anchors at each location,
    // similar to how point_seqs works for shapes
    let anchor_seqs: HashMap<NormalizedLocation, Vec<Point>> = layers
        .iter()
        .filter_map(|layer| {
            let loc = normalized_location(layer, layers[0], name_to_tag_map).ok()?;
            // None if any anchor is missing from non-default layer; the variation model
            // treats missing locations as 'sparse' sources. Better than fail altogether.
            let positions: Option<Vec<Point>> = anchor_names
                .iter()
                .map(|name| {
                    layer
                        .anchors
                        .iter()
                        .find(|a| &a.name == name)
                        .map(|a| a.pos)
                })
                .collect();
            if positions.is_none() {
                log::warn!(
                    "smart component layer '{}' missing some anchors, \
                     dropping from anchor interpolation",
                    layer.layer_id
                );
            }
            Some((loc, positions?))
        })
        .collect();

    // Interpolate all anchor positions at once
    let deltas = model
        .deltas_with_rounding(&anchor_seqs, RoundingBehaviour::None)
        .map_err(|_| BadSmartComponent::IncoherentLayers)?;
    let interpolated_positions = model.interpolate_from_deltas(location, &deltas);

    // Build result anchors with transformed positions
    // interpolate_from_deltas returns Vec<Vec2>, convert to Point and apply transform
    let result = anchor_names
        .into_iter()
        .zip(interpolated_positions)
        .map(|(name, pos)| {
            let point = Point::new(pos.x, pos.y);
            let transformed = transform * point;
            Anchor {
                name,
                pos: transformed,
            }
        })
        .collect();

    Ok(result)
}

//https://github.com/fonttools/fonttools/blob/03a3c8ed9e/Lib/fontTools/varLib/models.py#L47
fn normalize_value_with_extrapolation(val: f64, tuple: (i64, i64, i64)) -> NormalizedCoord {
    let (lower, default, upper) = tuple;
    let (lower, default, upper) = (lower as f64, default as f64, upper as f64);
    assert!(lower <= default);
    assert!(default <= upper);
    if val == default || lower == upper {
        return NormalizedCoord::default();
    }

    let val = if (val < default && lower != default) || (val > default && upper == default) {
        (val - default) / (default - lower)
    } else {
        assert!((val > default && upper != default) || (val < default && lower == default));
        (val - default) / (upper - default)
    };
    NormalizedCoord::new(val)
}

impl Shape {
    fn apply_affine(&mut self, affine: Affine) {
        match self {
            Shape::Path(path) => {
                path.nodes
                    .iter_mut()
                    .for_each(|node| node.apply_affine(affine));
            }
            Shape::Component(component) => {
                component.transform = affine * component.transform;
            }
        }
    }
}

impl Node {
    fn apply_affine(&mut self, affine: Affine) {
        self.pt = affine * self.pt;
    }
}

//https://github.com/googlefonts/glyphsLib/blob/52c982399b/Lib/glyphsLib/builder/smart_components.py#L43
fn normalized_location(
    for_layer: &Layer,
    base_layer: &Layer,
    name_to_tag_map: &BTreeMap<SmolStr, Tag>,
) -> Result<NormalizedLocation, BadSmartComponent> {
    for_layer
        .smart_component_positions
        .iter()
        .map(|(axis, pos)| {
            let tag = *name_to_tag_map
                .get(axis)
                .ok_or_else(|| BadSmartComponent::UnknownAxis(axis.clone()))?;
            let base_value = base_layer.smart_component_positions.get(axis).copied();
            let pos = match (base_value, *pos) {
                (Some(base), this) if base == this => NormalizedCoord::new(0.0),
                (None | Some(AxisPole::Min), AxisPole::Max) => NormalizedCoord::MAX,
                (None | Some(AxisPole::Max), AxisPole::Min) => NormalizedCoord::MIN,
                (base, this) => {
                    return Err(BadSmartComponent::StrangeAxisMapping {
                        axis: axis.clone(),
                        base,
                        child: this,
                    });
                }
            };
            Ok((tag, pos))
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use kurbo::{BezPath, Rect, Shape as _};

    use super::*;
    use crate::{Path, Shape};

    // ported from python:
    //https://github.com/googlefonts/glyphsLib/blob/52c982399b/tests/smart_components_test.py#L37
    fn smart_glyphs(master_id: &str) -> BTreeMap<SmolStr, Glyph> {
        fn rectangle_path(x: f64, y: f64, width: f64, height: f64) -> Path {
            fn line_to(x: f64, y: f64) -> Node {
                Node {
                    pt: (x, y).into(),
                    node_type: crate::NodeType::Line,
                }
            }
            Path {
                nodes: vec![
                    line_to(x, y),
                    line_to(x + width, y),
                    line_to(x + width, y + height),
                    line_to(x, y + height),
                ],
                closed: true,
                ..Default::default()
            }
        }
        let mut glyphs = BTreeMap::new();

        // Rectangle smart component
        let mut rectangle = Glyph {
            name: "_part.rectangle".into(),
            ..Default::default()
        };

        // Three axes
        rectangle
            .smart_component_axes
            .insert(SmolStr::new("Width"), 0..=1);
        rectangle
            .smart_component_axes
            .insert(SmolStr::new("Height"), 100..=500);
        rectangle
            .smart_component_axes
            .insert(SmolStr::new("Shift"), -100..=0);

        // Four layers
        let regular = Layer {
            layer_id: master_id.into(),
            width: 300.0.into(),
            shapes: vec![Shape::Path(rectangle_path(100.0, 100.0, 100.0, 100.0))],
            smart_component_positions: [
                (SmolStr::new("Width"), AxisPole::Min), // 1 is bottom pole
                (SmolStr::new("Height"), AxisPole::Min),
                (SmolStr::new("Shift"), AxisPole::Max), // 2 is the top pole
            ]
            .into_iter()
            .collect(),
            ..Default::default()
        };

        let wide = Layer {
            layer_id: "wide".into(),
            associated_master_id: Some(master_id.into()),
            width: 700.0.into(),
            shapes: vec![Shape::Path(rectangle_path(100.0, 100.0, 500.0, 100.0))],
            smart_component_positions: [
                (SmolStr::new("Width"), AxisPole::Max),
                (SmolStr::new("Height"), AxisPole::Min),
                (SmolStr::new("Shift"), AxisPole::Max),
            ]
            .into_iter()
            .collect(),
            ..Default::default()
        };

        let tall = Layer {
            layer_id: "tall".into(),
            associated_master_id: Some(master_id.into()),
            width: 300.0.into(),
            shapes: vec![Shape::Path(rectangle_path(100.0, 100.0, 100.0, 500.0))],
            smart_component_positions: [
                (SmolStr::new("Width"), AxisPole::Min),
                (SmolStr::new("Height"), AxisPole::Max),
                (SmolStr::new("Shift"), AxisPole::Max),
            ]
            .into_iter()
            .collect(),
            ..Default::default()
        };

        let shifted = Layer {
            layer_id: "shifted".into(),
            associated_master_id: Some(master_id.into()),
            width: 100.0.into(),
            shapes: vec![Shape::Path(rectangle_path(0.0, 0.0, 100.0, 100.0))],
            smart_component_positions: [
                (SmolStr::new("Width"), AxisPole::Min),
                (SmolStr::new("Height"), AxisPole::Min),
                (SmolStr::new("Shift"), AxisPole::Min),
            ]
            .into_iter()
            .collect(),
            ..Default::default()
        };

        rectangle.layers.extend([regular, wide, tall, shifted]);
        glyphs.insert(rectangle.name.clone(), rectangle);

        // Normal glyph "a" with a component
        let mut a = Glyph {
            name: "a".into(),
            ..Default::default()
        };

        let a_layer = Layer {
            layer_id: master_id.into(),
            width: 1000.0.into(),
            shapes: vec![Shape::Component(Component {
                name: SmolStr::new("_part.rectangle"),
                smart_component_values: BTreeMap::from([("derp".into(), 52.2)]),
                ..Default::default()
            })],
            ..Default::default()
        };

        a.layers.push(a_layer);
        glyphs.insert(a.name.clone(), a);

        glyphs
    }

    #[derive(Debug, PartialEq)]
    enum PathDirection {
        Clockwise,
        Otherwise,
    }

    fn get_rectangle_data(shape: &Shape) -> (Rect, PathDirection) {
        let shape = shape.as_path().unwrap();
        let nodes = &shape.nodes;
        assert_eq!(nodes.len(), 4);
        let mut rect = BezPath::new();
        rect.move_to(nodes[0].pt);
        nodes[1..].iter().for_each(|nd| rect.line_to(nd.pt));

        let center = rect.bounding_box().center();
        let dir = if rect.winding(center) < 0 {
            PathDirection::Clockwise
        } else {
            PathDirection::Otherwise
        };
        (rect.bounding_box(), dir)
    }

    //https://github.com/googlefonts/glyphsLib/blob/52c982399b/tests/smart_components_test.py#L159
    #[test]
    fn test_smart_component_regular() {
        let master_id = "master01";
        let test_cases = [
            // Eight corners
            (
                [("Width", 0.0), ("Height", 100.0), ("Shift", 0.0)].as_slice(),
                (100.0, 100.0, 100.0, 100.0),
            ),
            (
                [("Width", 1.0), ("Height", 100.0), ("Shift", 0.0)].as_slice(),
                (100.0, 100.0, 500.0, 100.0),
            ),
            (
                [("Width", 0.0), ("Height", 500.0), ("Shift", 0.0)].as_slice(),
                (100.0, 100.0, 100.0, 500.0),
            ),
            (
                [("Width", 1.0), ("Height", 500.0), ("Shift", 0.0)].as_slice(),
                (100.0, 100.0, 500.0, 500.0),
            ),
            (
                [("Width", 0.0), ("Height", 100.0), ("Shift", -100.0)].as_slice(),
                (0.0, 0.0, 100.0, 100.0),
            ),
            (
                [("Width", 1.0), ("Height", 100.0), ("Shift", -100.0)].as_slice(),
                (0.0, 0.0, 500.0, 100.0),
            ),
            (
                [("Width", 0.0), ("Height", 500.0), ("Shift", -100.0)].as_slice(),
                (0.0, 0.0, 100.0, 500.0),
            ),
            (
                [("Width", 1.0), ("Height", 500.0), ("Shift", -100.0)].as_slice(),
                (0.0, 0.0, 500.0, 500.0),
            ),
            // Some points in the middle
            (
                [("Width", 0.5), ("Height", 300.0), ("Shift", -50.0)].as_slice(),
                (50.0, 50.0, 300.0, 300.0),
            ),
            // Extrapolation
            (
                [("Width", 0.0), ("Height", 800.0), ("Shift", 0.0)].as_slice(),
                (100.0, 100.0, 100.0, 800.0),
            ),
        ];

        let glyphs = smart_glyphs(master_id);
        let a_glyph = glyphs.get(&SmolStr::new("a")).unwrap();
        let a_layer = &a_glyph.layers[0];
        let component = a_layer.shapes[0].as_component().unwrap();

        for (location, expected) in test_cases {
            // Set smart component values
            let mut modified_component = component.clone();
            modified_component.smart_component_values = location
                .iter()
                .map(|(k, v)| (SmolStr::new(k), *v))
                .collect();

            let rectangle = glyphs.get(&SmolStr::new("_part.rectangle")).unwrap();
            let instance = instantiate_for_layer(master_id, &modified_component, rectangle)
                .expect("instantiate should succeed");

            let (rect, dir) = get_rectangle_data(&instance.shapes[0]);
            let (x, y, w, h) = expected;
            let expected = Rect::new(x, y, x + w, y + h);

            assert_eq!(rect, expected, "Failed for values: {:?}", location);
            assert_eq!(
                dir,
                PathDirection::Otherwise,
                "Expected counter-clockwise winding for values: {:?}",
                location
            );
        }
    }

    //https://github.com/googlefonts/glyphsLib/blob/52c982399b/tests/smart_components_test.py#L198
    #[test]
    fn test_smart_component_regular_flipped_x() {
        let master_id = "master01";
        let test_cases = [
            // Eight corners
            (
                [("Width", 0.0), ("Height", 100.0), ("Shift", 0.0)].as_slice(),
                (-200.0, 100.0, 100.0, 100.0),
            ),
            (
                [("Width", 1.0), ("Height", 100.0), ("Shift", 0.0)].as_slice(),
                (-600.0, 100.0, 500.0, 100.0),
            ),
            (
                [("Width", 0.0), ("Height", 500.0), ("Shift", 0.0)].as_slice(),
                (-200.0, 100.0, 100.0, 500.0),
            ),
            (
                [("Width", 1.0), ("Height", 500.0), ("Shift", 0.0)].as_slice(),
                (-600.0, 100.0, 500.0, 500.0),
            ),
            (
                [("Width", 0.0), ("Height", 100.0), ("Shift", -100.0)].as_slice(),
                (-100.0, 0.0, 100.0, 100.0),
            ),
            (
                [("Width", 1.0), ("Height", 100.0), ("Shift", -100.0)].as_slice(),
                (-500.0, 0.0, 500.0, 100.0),
            ),
            (
                [("Width", 0.0), ("Height", 500.0), ("Shift", -100.0)].as_slice(),
                (-100.0, 0.0, 100.0, 500.0),
            ),
            (
                [("Width", 1.0), ("Height", 500.0), ("Shift", -100.0)].as_slice(),
                (-500.0, 0.0, 500.0, 500.0),
            ),
            // Some points in the middle
            (
                [("Width", 0.5), ("Height", 300.0), ("Shift", -50.0)].as_slice(),
                (-350.0, 50.0, 300.0, 300.0),
            ),
            // Extrapolation
            (
                [("Width", 0.0), ("Height", 800.0), ("Shift", 0.0)].as_slice(),
                (-200.0, 100.0, 100.0, 800.0),
            ),
        ];

        let glyphs = smart_glyphs(master_id);
        let a_glyph = glyphs.get(&SmolStr::new("a")).unwrap();
        let a_layer = &a_glyph.layers[0];
        let component = a_layer.shapes[0].as_component().unwrap();

        for (location, expected) in test_cases {
            // Set smart component values and flip x transform
            let mut modified_component = component.clone();
            modified_component.transform = Affine::new([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
            assert!(modified_component.transform.determinant() < 0.0);
            modified_component.smart_component_values = location
                .iter()
                .map(|(k, v)| (SmolStr::new(k), *v))
                .collect();

            let rectangle = glyphs.get(&SmolStr::new("_part.rectangle")).unwrap();
            let instance = instantiate_for_layer(master_id, &modified_component, rectangle)
                .expect("instantiate should succeed");

            let (rect, dir) = get_rectangle_data(&instance.shapes[0]);
            let (x, y, w, h) = expected;
            let expected = Rect::new(x, y, x + w, y + h);

            assert_eq!(rect, expected, "Failed for values: {:?}", location);
            assert_eq!(
                dir,
                PathDirection::Otherwise,
                "Expected counter-clockwise winding after flipped x transform for values: {:?}",
                location
            );
        }
    }

    // Test that a flipped smart component preserves the Glyphs "start node"
    // (last in the node list) after decomposition and reversal.
    //
    // Bug: a naive nodes.reverse() moves the start node from last position to
    // first, causing to_ir_path to pick the wrong start point. The fix is to
    // also rotate_left(1) so the start stays at the end.
    #[test]
    fn test_smart_component_flipped_x_start_point() {
        let master_id = "master01";
        let glyphs = smart_glyphs(master_id);
        let rectangle = glyphs.get(&SmolStr::new("_part.rectangle")).unwrap();

        // Default layer: rectangle_path(100, 100, 100, 100)
        // Nodes: [(100,100), (200,100), (200,200), (100,200)]
        // In Glyphs convention, the last node (100,200) is the "start" node.
        // After flipping x (transform = [-1,0,0,1,0,0]):
        //   (100,100) → (-100,100), (200,100) → (-200,100),
        //   (200,200) → (-200,200), (100,200) → (-100,200) [start]
        // The decomposed path should start at (-100,200).
        let component = Component {
            name: SmolStr::new("_part.rectangle"),
            transform: Affine::new([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
            smart_component_values: BTreeMap::from([
                ("Width".into(), 0.0),
                ("Height".into(), 100.0),
                ("Shift".into(), 0.0),
            ]),
            ..Default::default()
        };
        assert!(component.transform.determinant() < 0.0);

        let instance =
            instantiate_for_layer(master_id, &component, rectangle).expect("should succeed");
        let path = instance.shapes[0].as_path().unwrap();

        // The last node in the returned path is the Glyphs "start" node. After
        // decomposing with a flip transform it should be the transformed original
        // start, i.e. (-100, 200).
        let last = path.nodes.last().expect("path has nodes");
        assert_eq!(
            last.pt,
            kurbo::Point::new(-100.0, 200.0),
            "start node after flip should be (-100, 200), got {:?}",
            last.pt
        );
    }

    /// Helper to create a smart component with anchors for testing.
    /// Test data adapted from glyphsLib's test_smart_component_anchors
    /// (googlefonts/glyphsLib#1131, credit: @khaledhosny).
    fn smart_glyph_with_anchors(master_id: &str) -> Glyph {
        use crate::Anchor;

        let mut base = Glyph {
            name: "base".into(),
            ..Default::default()
        };

        base.smart_component_axes
            .insert(SmolStr::new("TEST"), -100..=100);

        // Default layer (pole Min = -100)
        let default_layer = Layer {
            layer_id: master_id.into(),
            width: 200.0.into(),
            shapes: vec![],
            anchors: vec![
                Anchor {
                    name: "top".into(),
                    pos: (23.0, 103.0).into(),
                },
                Anchor {
                    name: "bottom".into(),
                    pos: (36.0, -51.0).into(),
                },
            ],
            smart_component_positions: [(SmolStr::new("TEST"), AxisPole::Min)]
                .into_iter()
                .collect(),
            ..Default::default()
        };

        // Max layer (pole Max = 100)
        let max_layer = Layer {
            layer_id: "max".into(),
            associated_master_id: Some(master_id.into()),
            width: 200.0.into(),
            shapes: vec![],
            anchors: vec![
                Anchor {
                    name: "top".into(),
                    pos: (33.0, 123.0).into(),
                },
                Anchor {
                    name: "bottom".into(),
                    pos: (36.0, -51.0).into(),
                },
            ],
            smart_component_positions: [(SmolStr::new("TEST"), AxisPole::Max)]
                .into_iter()
                .collect(),
            ..Default::default()
        };

        base.layers.extend([default_layer, max_layer]);
        base
    }

    #[test]
    fn test_anchor_interpolation() {
        let master_id = "master01";
        let smart_comp = smart_glyph_with_anchors(master_id);

        // At min (TEST=-100): anchors unchanged from default layer
        let component = Component {
            name: "base".into(),
            smart_component_values: [("TEST".into(), -100.0)].into_iter().collect(),
            ..Default::default()
        };
        let instance = instantiate_for_layer(master_id, &component, &smart_comp)
            .expect("instantiate should succeed");
        assert_eq!(instance.anchors.len(), 2);
        let top = instance.anchors.iter().find(|a| a.name == "top").unwrap();
        let bottom = instance
            .anchors
            .iter()
            .find(|a| a.name == "bottom")
            .unwrap();
        assert_eq!((top.pos.x, top.pos.y), (23.0, 103.0));
        assert_eq!((bottom.pos.x, bottom.pos.y), (36.0, -51.0));

        // At max (TEST=100): anchors from max layer
        let component = Component {
            name: "base".into(),
            smart_component_values: [("TEST".into(), 100.0)].into_iter().collect(),
            ..Default::default()
        };
        let instance = instantiate_for_layer(master_id, &component, &smart_comp)
            .expect("instantiate should succeed");
        let top = instance.anchors.iter().find(|a| a.name == "top").unwrap();
        let bottom = instance
            .anchors
            .iter()
            .find(|a| a.name == "bottom")
            .unwrap();
        assert_eq!((top.pos.x, top.pos.y), (33.0, 123.0));
        assert_eq!((bottom.pos.x, bottom.pos.y), (36.0, -51.0));

        // At midpoint (TEST=0): interpolated
        let component = Component {
            name: "base".into(),
            smart_component_values: [("TEST".into(), 0.0)].into_iter().collect(),
            ..Default::default()
        };
        let instance = instantiate_for_layer(master_id, &component, &smart_comp)
            .expect("instantiate should succeed");
        let top = instance.anchors.iter().find(|a| a.name == "top").unwrap();
        let bottom = instance
            .anchors
            .iter()
            .find(|a| a.name == "bottom")
            .unwrap();
        assert_eq!((top.pos.x, top.pos.y), (28.0, 113.0));
        assert_eq!((bottom.pos.x, bottom.pos.y), (36.0, -51.0));

        // At midpoint (TEST=0) with component offset (20, 10)
        let component = Component {
            name: "base".into(),
            smart_component_values: [("TEST".into(), 0.0)].into_iter().collect(),
            transform: Affine::translate((20.0, 10.0)),
            ..Default::default()
        };
        let instance = instantiate_for_layer(master_id, &component, &smart_comp)
            .expect("instantiate should succeed");
        let top = instance.anchors.iter().find(|a| a.name == "top").unwrap();
        let bottom = instance
            .anchors
            .iter()
            .find(|a| a.name == "bottom")
            .unwrap();
        assert_eq!((top.pos.x, top.pos.y), (48.0, 123.0));
        assert_eq!((bottom.pos.x, bottom.pos.y), (56.0, -41.0));
    }

    // Regression tests for https://github.com/googlefonts/fontc/issues/1973
    //
    // MonaSans ninesuperior → nineinferior(smart, rotate 180°) → sixinferior

    #[test]
    fn apply_affine_component_transform_order() {
        // Inner component: rotate 180° + translate (like nineinferior → sixinferior)
        let mut shape = Shape::Component(Component {
            name: "base".into(),
            transform: Affine::new([-1.0, 0.0, 0.0, -1.0, 417.0, 353.0]),
            ..Default::default()
        });

        // Outer: translate(0, 376) (like ninesuperior → nineinferior)
        shape.apply_affine(Affine::translate((0.0, 376.0)));

        let c = shape.as_component().unwrap().transform.as_coeffs();
        // outer * inner = translate(0,376) * [-1,0,0,-1,417,353] = [-1,0,0,-1,417,729]
        assert_eq!(
            (c[4], c[5]),
            (417.0, 729.0),
            "should compose as outer * inner; wrong order gives (417, -23)"
        );
    }

    #[test]
    fn single_layer_smart_component_preserves_component_ref() {
        let master_id = "master01";

        // Smart component with 1 layer per master — smart axes can't vary.
        // Its shape is a Component (like nineinferior → sixinferior).
        let mut inner = Glyph {
            name: "inner".into(),
            ..Default::default()
        };
        inner
            .smart_component_axes
            .insert(SmolStr::new("Axis"), 0..=100);

        let inner_layer = Layer {
            layer_id: master_id.into(),
            width: 500.0.into(),
            shapes: vec![Shape::Component(Component {
                name: "base".into(),
                transform: Affine::new([-1.0, 0.0, 0.0, -1.0, 417.0, 353.0]),
                ..Default::default()
            })],
            smart_component_positions: [(SmolStr::new("Axis"), AxisPole::Min)]
                .into_iter()
                .collect(),
            ..Default::default()
        };
        inner.layers.push(inner_layer);

        // Caller references "inner" with translate(0, 376)
        let caller = Component {
            name: "inner".into(),
            transform: Affine::translate((0.0, 376.0)),
            ..Default::default()
        };

        let instance = instantiate_for_layer(master_id, &caller, &inner).expect("should succeed");

        // glyphsLib keeps this as a regular component ref (model=None fallback).
        // fontc should do the same — not flatten "inner" into its child "base".
        assert_eq!(instance.shapes.len(), 1);
        let comp = instance.shapes[0]
            .as_component()
            .expect("should preserve as Component, not inline inner shapes");
        assert_eq!(
            comp.name.as_str(),
            "inner",
            "should keep reference to 'inner', not flatten to 'base'"
        );
        assert_eq!(comp.transform, Affine::translate((0.0, 376.0)));
        assert!(comp.smart_component_values.is_empty());
        assert!(instance.anchors.is_empty());
    }
}