glyphs_reader/
corner_components.rs

1//! Corner components support
2//!
3//! Implements corner component insertion for Glyphs fonts.
4//! Based on: <https://github.com/googlefonts/glyphsLib/blob/main/Lib/glyphsLib/filters/cornerComponents.py>
5
6use std::collections::BTreeMap;
7
8use kurbo::{Affine, Line, ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point};
9use smol_str::SmolStr;
10use thiserror::Error;
11use write_fonts::OtRound;
12
13use crate::font::{Alignment, Glyph, Hint, HintType, Layer, Node, NodeType, Path, Shape};
14
15impl OtRound<Node> for Node {
16    fn ot_round(self) -> Node {
17        let (x, y) = self.pt.ot_round();
18        Node {
19            pt: Point::new(x as _, y as _),
20            node_type: self.node_type,
21        }
22    }
23}
24
25#[derive(Debug, Error, Clone)]
26#[error("component '{component}' failed: {reason}")]
27pub struct BadCornerComponent {
28    component: SmolStr,
29    reason: BadCornerComponentReason,
30}
31
32#[derive(Debug, Error, Clone)]
33pub enum BadCornerComponentReason {
34    #[error("corner glyph contains no layer '{0}'")]
35    MissingLayer(SmolStr),
36    #[error("glyph contains no paths")]
37    NoPaths,
38    #[error("no path at shape index '{0}'")]
39    BadShapeIndex(usize),
40    #[error("path contains too few points")]
41    PathTooShort,
42}
43
44impl BadCornerComponentReason {
45    // convenience to turn this into the actual error type we return
46    fn add_name(self, component: SmolStr) -> BadCornerComponent {
47        BadCornerComponent {
48            component,
49            reason: self,
50        }
51    }
52}
53
54/// Find the t parameter on a segment at a given distance along it
55///
56/// <https://github.com/googlefonts/glyphsLib/blob/f90e4060/Lib/glyphsLib/filters/cornerComponents.py#L151>
57fn point_on_seg_at_distance(seg: PathSeg, distance: f64) -> f64 {
58    seg.inv_arclen(distance, 1e-6)
59}
60
61/// Insert all corner components for a layer
62pub(crate) fn insert_corner_components_for_layer(
63    layer: &mut Layer,
64    glyphs: &BTreeMap<SmolStr, Glyph>,
65) -> Result<(), BadCornerComponent> {
66    let mut corner_hints: Vec<Hint> = layer
67        .hints
68        .iter()
69        .filter(|h| h.type_ == HintType::Corner)
70        .cloned()
71        .collect();
72
73    // make sure we do earlier shapes first and earlier nodes first
74    corner_hints.sort_by_key(|hint| (hint.shape_index, hint.node_index));
75
76    if corner_hints.is_empty() {
77        return Ok(());
78    }
79
80    // if we insert points for one corner, it will change the index of
81    // a subsequent corner, so we track how many points we've inserted.
82    let mut inserted_pts = 0;
83    let mut current_shape = 0;
84
85    for hint in corner_hints {
86        if hint.shape_index != current_shape {
87            current_shape = hint.shape_index;
88            inserted_pts = 0;
89        }
90
91        let Some(corner_glyph) = glyphs.get(&hint.name) else {
92            log::warn!("corner component '{}' not found", hint.name);
93            continue;
94        };
95
96        let component = corner_glyph
97            .layers
98            .iter()
99            .find(|l| l.layer_id == layer.master_id())
100            .ok_or_else(|| BadCornerComponentReason::MissingLayer(layer.master_id().into()))
101            .and_then(CornerComponent::new)
102            .map_err(|e| e.add_name(hint.name.clone()))?;
103
104        let n_points = component.corner_path.nodes.len() - 1;
105        layer
106            .insert_corner_component(component, &hint, inserted_pts)
107            .map_err(|e| e.add_name(hint.name.clone()))?;
108        inserted_pts += n_points;
109    }
110
111    // Clear hints after applying
112    layer.hints.retain(|h| h.type_ != HintType::Corner);
113
114    Ok(())
115}
116
117impl Layer {
118    // approximately follows the logic at https://github.com/googlefonts/glyphsLib/blob/f90e4060b/Lib/glyphsLib/filters/cornerComponents.py#L230
119    fn insert_corner_component(
120        &mut self,
121        mut component: CornerComponent,
122        hint: &Hint,
123        delta_pt_index: usize,
124    ) -> Result<(), BadCornerComponentReason> {
125        let path = match self.shapes.get_mut(hint.shape_index) {
126            Some(Shape::Path(p)) => p,
127            _ => return Err(BadCornerComponentReason::BadShapeIndex(hint.shape_index)),
128        };
129
130        let point_idx = (hint.node_index + delta_pt_index) % path.nodes.len();
131        let scale = Affine::scale_non_uniform(hint.scale.x.0, hint.scale.y.0);
132        // first scale the component as required by the hint
133        component.apply_transform(scale);
134
135        let AlignmentState {
136            mut instroke_pt,
137            outstroke_pt: _,
138            correction,
139            // this mutates the component, aligning it with the target segment
140        } = component.align_to_main_path(path, hint, point_idx);
141
142        let original_outstroke = path.get_next_segment(point_idx).unwrap();
143        if hint.alignment != Alignment::InStroke && correction {
144            instroke_pt = component
145                .recompute_instroke_intersection_point(path, point_idx)
146                .unwrap_or(instroke_pt);
147
148            if !matches!(
149                component.corner_path.get_next_segment(0).unwrap(),
150                PathSeg::Line(_)
151            ) {
152                component.stretch_first_seg_to_fit(instroke_pt);
153            }
154        }
155        // adjust the instroke (the stroke leading into the point where we're
156        // adding the new corner)
157        path.split_instroke(point_idx, instroke_pt);
158        // now insert the corner into the path
159        let insert_pt = path.next_idx(point_idx);
160        path.nodes.splice(
161            insert_pt..insert_pt,
162            component
163                .corner_path
164                .nodes
165                .iter()
166                .cloned()
167                .skip(1)
168                .map(|node| node.ot_round()),
169        );
170
171        let added_points = component.corner_path.nodes.len() - 1;
172        // 'prev' because the last point we inserted is the new outstroke start
173        let new_outstroke_idx = path.prev_idx(insert_pt + added_points);
174
175        // then adjust the outstroke
176        if let Some(outstroke_intersection_point) =
177            component.recompute_outstroke_intersection_point(original_outstroke, hint)
178        {
179            path.fixup_outstroke(
180                original_outstroke,
181                outstroke_intersection_point,
182                new_outstroke_idx,
183            );
184        }
185
186        // and finally, add any new extra paths
187        for mut path in component.other_paths.into_iter() {
188            path.nodes
189                .iter_mut()
190                .for_each(|node| *node = node.ot_round());
191            self.shapes.push(Shape::Path(path));
192        }
193
194        Ok(())
195    }
196}
197
198impl Hint {
199    fn is_flipped(&self) -> bool {
200        self.scale.x.0 * self.scale.y.0 < 0.0
201    }
202}
203
204impl Path {
205    fn set_point(&mut self, idx: usize, point: Point) {
206        self.nodes[idx].pt = point;
207        self.nodes[idx] = self.nodes[idx].ot_round();
208    }
209
210    // should maybe be called "truncate instroke?"
211    //https://github.com/googlefonts/glyphsLib/blob/f90e4060ba/Lib/glyphsLib/filters/cornerComponents.py#L414
212    fn split_instroke(&mut self, point_idx: usize, intersection: Point) {
213        let instroke = self.get_previous_segment(point_idx).unwrap();
214        let nearest_t = instroke.nearest(intersection, 1e-6).t;
215        let split = instroke.subsegment(0.0..nearest_t);
216        match split {
217            PathSeg::Line(line) => self.set_point(point_idx, line.p1),
218            PathSeg::Quad(quad) => {
219                self.set_point(point_idx, quad.p2);
220                let idx = self.prev_idx(point_idx);
221                self.set_point(idx, quad.p1);
222            }
223            PathSeg::Cubic(cubic) => {
224                self.set_point(point_idx, cubic.p3);
225                let idx = self.prev_idx(point_idx);
226                self.set_point(idx, cubic.p2);
227                let idx = self.prev_idx(idx);
228                self.set_point(idx, cubic.p1);
229            }
230        };
231    }
232
233    fn fixup_outstroke(&mut self, original: PathSeg, intersection: Point, point_idx: usize) {
234        let nearest_t = original.nearest(intersection, 1e-6).t;
235        let split = original.subsegment(nearest_t..1.0);
236        match split {
237            PathSeg::Line(line) => self.set_point(point_idx, line.p0),
238            PathSeg::Quad(quad) => {
239                self.set_point(point_idx, quad.p0);
240                let idx = self.next_idx(point_idx);
241                self.set_point(idx, quad.p1);
242            }
243            PathSeg::Cubic(cubic) => {
244                self.set_point(point_idx, cubic.p0);
245                let idx = self.next_idx(point_idx);
246                self.set_point(idx, cubic.p1);
247                let idx = self.next_idx(idx);
248                self.set_point(idx, cubic.p2);
249            }
250        }
251    }
252}
253
254struct CornerComponent {
255    corner_path: Path,
256    other_paths: Vec<Path>,
257    // the 'left' anchor of the component, (0,0) by default
258    #[expect(dead_code)] // used for alignment, not handled yet
259    left: Point,
260    // the 'right' anchor of the component, (0,0) by default
261    #[expect(dead_code)] // used for alignment, not handled yet
262    right: Point,
263}
264
265impl CornerComponent {
266    fn new(corner_layer: &Layer) -> Result<Self, BadCornerComponentReason> {
267        let origin = corner_layer.get_anchor_pt("origin").unwrap_or_default();
268        let left = corner_layer.get_anchor_pt("left").unwrap_or_default();
269        let right = corner_layer.get_anchor_pt("right").unwrap_or_default();
270
271        // Extract the main path and other paths
272        let mut path_iter = corner_layer
273            .shapes
274            .iter()
275            .filter_map(Shape::as_path)
276            .cloned()
277            // apply the origin here
278            .map(|mut path| {
279                path.nodes
280                    .iter_mut()
281                    .for_each(|node| node.pt -= origin.to_vec2());
282                path
283            });
284
285        let corner_path = path_iter.next().ok_or(BadCornerComponentReason::NoPaths)?;
286        if corner_path.nodes.len() < 2 {
287            return Err(BadCornerComponentReason::PathTooShort);
288        }
289        let other_paths = path_iter.collect::<Vec<_>>();
290
291        Ok(Self {
292            corner_path,
293            other_paths,
294            left,
295            right,
296        })
297    }
298
299    fn apply_transform(&mut self, transform: Affine) {
300        for node in self.corner_path.nodes.iter_mut().chain(
301            self.other_paths
302                .iter_mut()
303                .flat_map(|path| path.nodes.iter_mut()),
304        ) {
305            node.pt = transform * node.pt;
306        }
307    }
308
309    fn last_point(&self) -> Point {
310        // by construction we are not empty
311        self.corner_path.nodes.last().unwrap().pt
312    }
313
314    fn reverse_corner_path(&mut self) {
315        self.corner_path.reverse();
316        // fixup the node types; a simple cubic bezier corner has types,
317        // 'line, offcurve, offcurve, curveto' and when reversed we end up
318        // with a lineto at the end, which we later think is an error:
319        let [.., p0, pn] = self.corner_path.nodes.as_mut_slice() else {
320            return;
321        };
322        if p0.node_type == NodeType::OffCurve {
323            pn.node_type = match pn.node_type {
324                NodeType::Line => NodeType::Curve,
325                NodeType::LineSmooth => NodeType::CurveSmooth,
326                other => other,
327            };
328        }
329    }
330
331    //https://github.com/googlefonts/glyphsLib/blob/f90e4060/Lib/glyphsLib/filters/cornerComponents.py#L340
332    fn align_to_main_path(&mut self, path: &Path, hint: &Hint, point_idx: usize) -> AlignmentState {
333        let mut angle = (-self.last_point().y).atan2(self.last_point().x);
334        if hint.is_flipped() {
335            angle += std::f64::consts::FRAC_PI_2;
336            self.reverse_corner_path();
337        }
338
339        let instroke = path.get_previous_segment(point_idx).unwrap();
340        let outstroke = path.get_next_segment(point_idx).unwrap();
341        let target_pt = path.nodes.get(point_idx).unwrap().pt;
342
343        // calculate outstroke angle
344        let distance = if hint.is_flipped() {
345            self.last_point().y
346        } else {
347            self.last_point().x
348        };
349
350        let outstroke_t = point_on_seg_at_distance(outstroke, distance.abs());
351        let outstroke_pt = outstroke.eval(outstroke_t);
352        let outstroke_angle = (outstroke_pt - target_pt).angle();
353
354        // calculate instroke angle
355        let distance = if hint.is_flipped() {
356            -self.corner_path.nodes.first().unwrap().pt.x
357        } else {
358            self.corner_path.nodes.first().unwrap().pt.y
359        };
360
361        let instroke_t = point_on_seg_at_distance(instroke, distance.abs());
362        let instroke_pt = instroke.reverse().eval(instroke_t);
363        let instroke_angle = (target_pt - instroke_pt).angle() + std::f64::consts::FRAC_PI_2;
364
365        let correction = !(py_is_close(instroke_t, 0.0) || py_is_close(instroke_t, 1.0));
366
367        let angle = angle
368            + match hint.alignment {
369                Alignment::OutStroke => outstroke_angle,
370                Alignment::InStroke => instroke_angle,
371                Alignment::Middle => (instroke_angle + outstroke_angle) / 2.0,
372                _ => 0.0,
373            };
374
375        // rotate the paths around the origin and align them
376        // so that the origin of the corner starts on the target node
377        //https://github.com/googlefonts/glyphsLib/blob/f90e4060/Lib/glyphsLib/filters/cornerComponents.py#L384
378        let xform = Affine::translate(target_pt.to_vec2()).pre_rotate(angle);
379        self.apply_transform(xform);
380
381        AlignmentState {
382            instroke_pt,
383            outstroke_pt,
384            correction,
385        }
386    }
387
388    //https://github.com/googlefonts/glyphsLib/blob/f90e4060/Lib/glyphsLib/filters/cornerComponents.py#L396
389    fn recompute_instroke_intersection_point(
390        &self,
391        path: &Path,
392        target_node_ix: usize,
393    ) -> Option<Point> {
394        // see ref above, this just treats it as a line
395        let first_seg_as_line = &self.corner_path.nodes.as_slice()[..2];
396        let first_seg_as_line = Line::new(first_seg_as_line[0].pt, first_seg_as_line[1].pt);
397        let instroke = path.get_previous_segment(target_node_ix).unwrap();
398        unbounded_seg_seg_intersection(first_seg_as_line.into(), instroke)
399    }
400
401    //https://github.com/googlefonts/glyphsLib/blob/f90e4060b/Lib/glyphsLib/filters/cornerComponents.py#L401
402    fn recompute_outstroke_intersection_point(
403        &self,
404        original_outstroke: PathSeg,
405        hint: &Hint,
406    ) -> Option<Point> {
407        if hint.is_flipped() {
408            unbounded_seg_seg_intersection(
409                self.corner_path
410                    .get_previous_segment(self.corner_path.nodes.len() - 1)
411                    .unwrap(),
412                original_outstroke,
413            )
414        } else {
415            // the python all uses custom geometry fns, which i would like to avoid..
416            let nearest = original_outstroke.nearest(self.last_point(), 1e-6);
417            Some(original_outstroke.eval(nearest.t))
418        }
419    }
420
421    fn stretch_first_seg_to_fit(&mut self, intersection_pt: Point) {
422        let delta = intersection_pt - self.corner_path.nodes[0].pt;
423        self.corner_path.nodes[1].pt += delta;
424    }
425}
426
427/// Find the intersection of two unbounded segments
428///
429/// <https://github.com/googlefonts/glyphsLib/blob/f90e4060b/Lib/glyphsLib/filters/cornerComponents.py#L127>
430fn unbounded_seg_seg_intersection(seg1: PathSeg, seg2: PathSeg) -> Option<Point> {
431    // Line-line intersection
432    match (seg1, seg2) {
433        (PathSeg::Line(one), PathSeg::Line(two)) => one.crossing_point(two),
434        (seg, PathSeg::Line(line)) | (PathSeg::Line(line), seg) => {
435            // a value by which we extend our line, to find the crossing point.
436            // should be enough for anybody!
437            const LITERALLY_UNBOUNDED: f64 = 1e9;
438
439            // Extend the line by 1000 units in both directions to simulate unbounded line
440            let direction = (line.p1 - line.p0).normalize();
441            let extended_line = Line::new(
442                line.p0 - direction * LITERALLY_UNBOUNDED,
443                line.p1 + direction * LITERALLY_UNBOUNDED,
444            );
445            seg.intersect_line(extended_line)
446                .first()
447                .map(|hit| seg.eval(hit.segment_t))
448        }
449        _ => None,
450    }
451}
452
453// https://docs.python.org/3.14/library/math.html#math.isclose
454fn py_is_close(a: f64, b: f64) -> bool {
455    // abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol).
456    const REL_TOL: f64 = 1e-09;
457    (a - b).abs() <= REL_TOL * a.abs().max(b.abs())
458}
459
460struct AlignmentState {
461    instroke_pt: Point,
462    #[expect(dead_code, reason = "python does it")]
463    outstroke_pt: Point,
464    correction: bool,
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::font::Font;
471    use rstest::rstest;
472    use std::path::{Path as FilePath, PathBuf};
473
474    fn testdata_dir() -> PathBuf {
475        let mut dir = FilePath::new("../resources/testdata");
476        if !dir.is_dir() {
477            dir = FilePath::new("./resources/testdata");
478        }
479        dir.to_path_buf()
480    }
481
482    fn glyphs3_dir() -> PathBuf {
483        testdata_dir().join("glyphs3")
484    }
485
486    /// Compare two layers' paths for equality
487    fn compare_paths(test_layer: &Layer, expectation_layer: &Layer, glyph_name: &str) {
488        // Extract only Path shapes, ignoring Components
489        let test_paths: Vec<_> = test_layer
490            .shapes
491            .iter()
492            .filter_map(Shape::as_path)
493            .collect();
494
495        let expectation_paths: Vec<_> = expectation_layer
496            .shapes
497            .iter()
498            .filter_map(Shape::as_path)
499            .collect();
500
501        assert_eq!(
502            test_paths.len(),
503            expectation_paths.len(),
504            "Number of paths differs for glyph '{glyph_name}': expected {}, got {}",
505            expectation_paths.len(),
506            test_paths.len()
507        );
508
509        for (i, (test_path, expectation_path)) in
510            test_paths.iter().zip(expectation_paths.iter()).enumerate()
511        {
512            assert_eq!(
513                test_path.to_points(),
514                expectation_path.to_points(),
515                "Path {i} differs for glyph '{glyph_name}'",
516            );
517        }
518    }
519
520    fn test_corner_component_glyph(glyph_name: &str) {
521        let font_path = glyphs3_dir().join("CornerComponents.glyphs");
522        let font = Font::load_raw(&font_path).expect("Failed to load CornerComponents.glyphs");
523
524        let mut test_glyph = font
525            .glyphs
526            .get(glyph_name)
527            .cloned()
528            .unwrap_or_else(|| panic!("Test glyph '{}' not found", glyph_name));
529
530        let expectation_glyph_name = format!("{}.expectation", glyph_name);
531
532        // Apply corner components to the test glyph
533        for layer in &mut test_glyph.layers {
534            insert_corner_components_for_layer(layer, &font.glyphs)
535                .expect("Failed to insert corner components");
536        }
537
538        let expectation_glyph = font
539            .glyphs
540            .get(expectation_glyph_name.as_str())
541            .unwrap_or_else(|| panic!("Expectation glyph '{}' not found", expectation_glyph_name));
542
543        // Get the first master's layer (assuming single master for test)
544        assert!(
545            !test_glyph.layers.is_empty(),
546            "Test glyph '{glyph_name}' has no layers",
547        );
548        assert!(
549            !expectation_glyph.layers.is_empty(),
550            "Expectation glyph '{}' has no layers",
551            expectation_glyph_name
552        );
553
554        let test_layer = &test_glyph.layers[0];
555        let expectation_layer = &expectation_glyph.layers[0];
556
557        // Compare the results
558        compare_paths(test_layer, expectation_layer, glyph_name);
559    }
560
561    #[rstest]
562    #[case::aa_simple_angleinstroke("aa_simple_angleinstroke")]
563    #[case::ab_simple_angled("ab_simple_angled")]
564    #[case::ac_scale("ac_scale")]
565    #[case::ad_curved_instroke("ad_curved_instroke")]
566    #[case::ae_curved_corner_firstseg("ae_curved_corner_firstseg")]
567    #[case::af_curved_corner_firstseg_slanted("af_curved_corner_firstseg_slanted")]
568    #[case::ag_curved_corner_bothsegs("ag_curved_corner_bothsegs")]
569    #[case::ag_curved_corner_bothsegs_rotated("ag_curved_corner_bothsegs_rotated")]
570    #[case::ah_origin("ah_origin")]
571    #[case::ai_curved_outstroke("ai_curved_outstroke")]
572    #[case::aj_right_alignment("aj_right_alignment")]
573    #[case::ak_right_slanted("ak_right_slanted")]
574    #[case::al_unaligned("al_unaligned")]
575    #[case::am_middle("am_middle")]
576    #[case::an_flippy("an_flippy")]
577    #[case::ao_firstnode("ao_firstnode")]
578    #[case::ap_twoofthem("ap_twoofthem")]
579    #[case::aq_rightleg("aq_rightleg")]
580    #[case::ar_leftleg("ar_leftleg")]
581    #[case::as_closedpaths("as_closedpaths")]
582    #[case::at_unaligned_lastseg("at_unaligned_lastseg")]
583    #[case::au_left_anchoronpath("au_left_anchoronpath")]
584    #[case::av_left_anchoroffpath("av_left_anchoroffpath")]
585    #[case::aw_direction("aw_direction")]
586    #[case::ax_curved_instroke2("ax_curved_instroke2")]
587    // ported from glyphsLib: https://github.com/googlefonts/glyphsLib/blob/f90e4060ba/tests/corner_components_test.py#L14
588    fn test_corner_components(#[case] glyph_name: &str) {
589        let _ = env_logger::builder().is_test(true).try_init();
590        // Skip glyphs with left_anchor as noted in the Python test
591        if glyph_name.contains("left_anchor") {
592            // In rstest we can't easily skip tests, so we just return early
593            log::info!(
594                "Skipping '{}': left anchors not quite working yet",
595                glyph_name
596            );
597            return;
598        }
599
600        test_corner_component_glyph(glyph_name);
601    }
602}