Skip to main content

read_fonts/model/glyph/color/
traversal.rs

1//! Traversal of a `COLR` paint graph into a [`PaintSink`].
2
3use super::{
4    decycler::{Decycler, DecyclerError},
5    instance::{resolve_clip_box, ColrInstance, MaybeBrush, ResolvedColorStop, ResolvedPaint},
6    Brush, ColorStop, Error, PaintCachedColorGlyph, PaintSink,
7};
8use crate::{
9    limits::MAX_RECURSION_DEPTH,
10    tables::colr::{CompositeMode, Paint},
11    types::{BoundingBox, GlyphId},
12};
13use core::ops::Range;
14
15#[cfg(feature = "libm")]
16#[allow(unused_imports)]
17use core_maths::CoreFloat;
18
19// Bounds how deep a paint graph is followed, and detects cycles in it.
20// HarfBuzz bounds its own paint context the same way; see
21// <https://github.com/harfbuzz/harfbuzz/blob/724ef405f3a0a0b2792c7a575d1f3341b41ca950/src/OT/Color/COLR/COLR.hh#L74>.
22pub(super) type PaintDecycler = Decycler<usize, MAX_RECURSION_DEPTH>;
23
24// Avoid heap allocations for any gradient with <= 32 color stops. This number
25// was chosen to keep stack size < 512 bytes.
26//
27// The largest gradient in Noto Color Emoji has 13 stops.
28//
29// Only one ColorStopVec will be created per paint graph traversal.
30const MAX_INLINE_COLOR_STOPS: usize = 32;
31
32pub(super) type ColorStopVec = smallvec::SmallVec<[ColorStop; MAX_INLINE_COLOR_STOPS]>;
33
34impl From<DecyclerError> for Error {
35    // A cycle never ends, so it exceeds the nesting limit like a graph that
36    // is merely too deep.
37    fn from(_: DecyclerError) -> Self {
38        Self::LimitExceeded
39    }
40}
41
42/// Maximum number of nodes visited during a single traversal.
43///
44/// Prevents excessive execution time on graphs with high fan-out. This is the
45/// 16384 cap recommended by security analysis. It is above HarfBuzz's limit
46/// of 2048
47/// (<https://github.com/harfbuzz/harfbuzz/blob/9f2f03173b7fee860cc00d999857d09fa4a362e2/src/hb-limits.hh#L96>)
48/// because some glyphs, such as the flags in Noto Color Emoji, visit about
49/// 6700 nodes.
50const MAX_NODES: u32 = 16384;
51
52pub(super) fn get_clipbox_font_units(
53    colr_instance: &ColrInstance,
54    glyph: GlyphId,
55) -> Option<BoundingBox<f32>> {
56    let maybe_clipbox = (*colr_instance).v1_clip_box(glyph)?;
57    Some(resolve_clip_box(colr_instance, &maybe_clipbox))
58}
59
60impl From<ResolvedColorStop> for ColorStop {
61    fn from(resolved_stop: ResolvedColorStop) -> Self {
62        ColorStop {
63            offset: resolved_stop.offset,
64            alpha: resolved_stop.alpha,
65            palette_index: resolved_stop.palette_index,
66        }
67    }
68}
69
70pub(super) struct TraversalState<'a> {
71    instance: ColrInstance<'a>,
72    sink: &'a mut dyn PaintSink,
73    stops_buf: ColorStopVec,
74    nodes_left: u32,
75}
76
77impl<'a> TraversalState<'a> {
78    pub(super) fn new(instance: ColrInstance<'a>, sink: &'a mut dyn PaintSink) -> Self {
79        Self {
80            instance,
81            sink,
82            stops_buf: ColorStopVec::new(),
83            nodes_left: MAX_NODES,
84        }
85    }
86
87    pub(super) fn resolve_paint(&mut self, paint: &Paint<'a>) -> Result<ResolvedPaint<'a>, Error> {
88        self.nodes_left = self.nodes_left.checked_sub(1).ok_or(Error::LimitExceeded)?;
89        super::instance::resolve_paint(&self.instance, paint)
90    }
91}
92
93pub(super) fn traverse_with_callbacks<'a>(
94    paint: &ResolvedPaint<'a>,
95    state: &mut TraversalState<'a>,
96    decycler: &mut PaintDecycler,
97    recurse_depth: usize,
98) -> Result<(), Error> {
99    if recurse_depth >= MAX_RECURSION_DEPTH {
100        return Err(Error::LimitExceeded);
101    }
102    match paint {
103        ResolvedPaint::ColrLayers { range } => {
104            for layer_index in range.clone() {
105                // Perform cycle detection with paint id here, second part of the tuple.
106                let (layer_paint, paint_id) = state
107                    .instance
108                    .v1_layer(layer_index)
109                    .ok_or(Error::Malformed)?;
110                let mut cycle_guard = decycler.enter(paint_id)?;
111                traverse_with_callbacks(
112                    &state.resolve_paint(&layer_paint)?,
113                    state,
114                    &mut cycle_guard,
115                    recurse_depth + 1,
116                )?;
117            }
118            Ok(())
119        }
120        ResolvedPaint::Solid { .. }
121        | ResolvedPaint::LinearGradient { .. }
122        | ResolvedPaint::RadialGradient { .. }
123        | ResolvedPaint::SweepGradient { .. } => {
124            if let MaybeBrush::Some(brush) =
125                paint.as_brush(&state.instance, &mut state.stops_buf)?
126            {
127                state.sink.fill(brush);
128            }
129            Ok(())
130        }
131        ResolvedPaint::Glyph { glyph, paint } => {
132            let glyph = *glyph;
133            // Look for the pattern `(transform)* fill` and optimize it to a
134            // single paint call
135            let mut next_paint = state.resolve_paint(paint)?;
136            // Collect any chain of intermediate transforms
137            let mut intermediate_transform = None;
138            while let Some((transform, child_paint)) = next_paint.as_transform() {
139                intermediate_transform = Some(match intermediate_transform {
140                    Some(existing_transform) => existing_transform * transform,
141                    None => transform,
142                });
143                next_paint = state.resolve_paint(&child_paint)?;
144            }
145            // If the next paint is a brush, we can optimize the traversal to a
146            // single fill_glyph call
147            match next_paint.as_brush(&state.instance, &mut state.stops_buf)? {
148                MaybeBrush::Some(brush) => {
149                    state.sink.fill_glyph(glyph, intermediate_transform, brush);
150                    return Ok(());
151                }
152                // Valid brush but doesn't produce any rendering, so we can
153                // skip the glyph entirely
154                MaybeBrush::NonRendering => {
155                    return Ok(());
156                }
157                // Not a brush, fall through to the unoptimized path
158                MaybeBrush::None => {}
159            }
160            // In case the optimization was not successful, just push a clip,
161            // and continue unoptimized traversal.
162            state.sink.push_clip_glyph(glyph);
163            if let Some(transform) = intermediate_transform {
164                state.sink.push_transform(transform);
165            }
166            let result = traverse_with_callbacks(&next_paint, state, decycler, recurse_depth + 1);
167            if intermediate_transform.is_some() {
168                state.sink.pop_transform();
169            }
170            state.sink.pop_clip();
171            result
172        }
173        ResolvedPaint::ColrGlyph { glyph } => {
174            let glyph = (*glyph).into();
175            match state.instance.v1_base_glyph(glyph) {
176                Some((base_glyph, base_glyph_paint_id)) => {
177                    let mut cycle_guard = decycler.enter(base_glyph_paint_id)?;
178                    let draw_result = state.sink.paint_cached_color_glyph(glyph)?;
179                    match draw_result {
180                        PaintCachedColorGlyph::Ok => Ok(()),
181                        PaintCachedColorGlyph::Unimplemented => {
182                            let clipbox = get_clipbox_font_units(&state.instance, glyph);
183
184                            if let Some(rect) = clipbox {
185                                state.sink.push_clip_box(rect);
186                            }
187                            let result = traverse_unresolved_paint(
188                                &base_glyph,
189                                state,
190                                &mut cycle_guard,
191                                recurse_depth + 1,
192                            );
193                            if clipbox.is_some() {
194                                state.sink.pop_clip();
195                            }
196                            result
197                        }
198                    }
199                }
200                None => Err(Error::MissingResource),
201            }
202        }
203        ResolvedPaint::Transform {
204            paint: next_paint, ..
205        }
206        | ResolvedPaint::Translate {
207            paint: next_paint, ..
208        }
209        | ResolvedPaint::Scale {
210            paint: next_paint, ..
211        }
212        | ResolvedPaint::Rotate {
213            paint: next_paint, ..
214        }
215        | ResolvedPaint::Skew {
216            paint: next_paint, ..
217        } => {
218            state
219                .sink
220                .push_transform(paint.as_transform().ok_or(Error::Malformed)?.0);
221            let result = traverse_unresolved_paint(next_paint, state, decycler, recurse_depth + 1);
222            state.sink.pop_transform();
223            result
224        }
225        ResolvedPaint::Composite {
226            source_paint,
227            mode,
228            backdrop_paint,
229        } => {
230            state.sink.push_layer(CompositeMode::SrcOver);
231            let mut result =
232                traverse_unresolved_paint(backdrop_paint, state, decycler, recurse_depth + 1);
233            if result.is_err() {
234                state.sink.pop_layer_with_mode(CompositeMode::SrcOver);
235                return result;
236            }
237            state.sink.push_layer(*mode);
238            result = traverse_unresolved_paint(source_paint, state, decycler, recurse_depth + 1);
239            state.sink.pop_layer_with_mode(*mode);
240            state.sink.pop_layer_with_mode(CompositeMode::SrcOver);
241            result
242        }
243    }
244}
245
246fn traverse_unresolved_paint<'a>(
247    paint: &Paint<'a>,
248    state: &mut TraversalState<'a>,
249    decycler: &mut PaintDecycler,
250    recurse_depth: usize,
251) -> Result<(), Error> {
252    let resolved_paint = state.resolve_paint(paint)?;
253    traverse_with_callbacks(&resolved_paint, state, decycler, recurse_depth)
254}
255
256pub(super) fn traverse_v0_range(
257    range: &Range<usize>,
258    instance: &ColrInstance,
259    sink: &mut dyn PaintSink,
260) -> Result<(), Error> {
261    for layer_index in range.clone() {
262        let (layer_glyph, palette_index) =
263            (*instance).v0_layer(layer_index).ok_or(Error::Malformed)?;
264        sink.fill_glyph(
265            layer_glyph.into(),
266            None,
267            Brush::Solid {
268                palette_index,
269                alpha: 1.0,
270            },
271        );
272    }
273    Ok(())
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::{
280        model::{
281            font::ColrTables,
282            glyph::color::{
283                test_glyph_defs::CLIPBOX, Brush, ColorGlyphVersion, CompositeMode, Font, PaintSink,
284            },
285        },
286        types::{GlyphId16, Matrix},
287        FontData, FontRead, FontRef, TableProvider,
288    };
289    use alloc::vec::Vec;
290
291    #[test]
292    fn clipbox_test() {
293        let font = FontRef::new(font_test_data::COLRV0V1_VARIABLE).unwrap();
294        let model = Font::new(font_test_data::COLRV0V1_VARIABLE.to_vec(), 0).unwrap();
295        let test_glyph_id = model.charmap().map_unicode(CLIPBOX[0]).unwrap();
296        let upem = font.head().unwrap().units_per_em();
297
298        let base_bounding_box = BoundingBox {
299            x_min: 0.0,
300            x_max: upem as f32 / 2.0,
301            y_min: upem as f32 / 2.0,
302            y_max: upem as f32,
303        };
304        // Fractional value needed to match variation scaling of clipbox.
305        const CLIPBOX_SHIFT: f32 = 200.0122;
306
307        macro_rules! test_entry {
308            ($axis:literal, $shift:expr, $field:ident) => {
309                (
310                    $axis,
311                    $shift,
312                    BoundingBox {
313                        $field: base_bounding_box.$field + ($shift),
314                        ..base_bounding_box
315                    },
316                )
317            };
318        }
319
320        let test_data_expectations = [
321            ("", 0.0, base_bounding_box),
322            test_entry!("CLXI", CLIPBOX_SHIFT, x_min),
323            test_entry!("CLXA", -CLIPBOX_SHIFT, x_max),
324            test_entry!("CLYI", CLIPBOX_SHIFT, y_min),
325            test_entry!("CLYA", -CLIPBOX_SHIFT, y_max),
326        ];
327
328        let tables = ColrTables::new(font.colr().unwrap());
329        for axis_test in test_data_expectations {
330            let instance = model
331                .instance_builder()
332                .variations([(axis_test.0, axis_test.1)])
333                .build();
334            let color_instance = ColrInstance::new(&tables, instance.normalized_coords(), &[]);
335            let clip_box = get_clipbox_font_units(&color_instance, test_glyph_id);
336            assert!(clip_box.is_some());
337            assert!(
338                clip_box.unwrap() == axis_test.2,
339                "Clip boxes do not match. Actual: {:?}, expected: {:?}",
340                clip_box.unwrap(),
341                axis_test.2
342            );
343        }
344    }
345
346    struct NopSink;
347
348    impl PaintSink for NopSink {
349        fn push_transform(&mut self, _transform: Matrix<f32>) {}
350
351        fn pop_transform(&mut self) {}
352
353        fn push_clip_glyph(&mut self, _glyph_id: GlyphId) {}
354
355        fn push_clip_box(&mut self, _clip_box: BoundingBox<f32>) {}
356
357        fn pop_clip(&mut self) {}
358
359        fn fill(&mut self, _brush: Brush<'_>) {}
360
361        fn push_layer(&mut self, _composite_mode: CompositeMode) {}
362
363        fn pop_layer(&mut self) {}
364    }
365
366    #[derive(Default)]
367    struct StackTrackingSink {
368        transform_pushes: usize,
369        transform_pops: usize,
370        clip_pushes: usize,
371        clip_pops: usize,
372        layer_pushes: usize,
373        layer_pops: usize,
374        clip_glyphs: Vec<GlyphId>,
375        filled_glyphs: Vec<GlyphId>,
376        fills: usize,
377    }
378
379    impl PaintSink for StackTrackingSink {
380        fn push_transform(&mut self, _transform: Matrix<f32>) {
381            self.transform_pushes += 1;
382        }
383
384        fn pop_transform(&mut self) {
385            self.transform_pops += 1;
386        }
387
388        fn push_clip_glyph(&mut self, glyph: GlyphId) {
389            self.clip_pushes += 1;
390            self.clip_glyphs.push(glyph);
391        }
392
393        fn push_clip_box(&mut self, _clip_box: BoundingBox<f32>) {
394            self.clip_pushes += 1;
395        }
396
397        fn pop_clip(&mut self) {
398            self.clip_pops += 1;
399        }
400
401        fn fill(&mut self, _brush: Brush<'_>) {
402            self.fills += 1;
403        }
404
405        fn fill_glyph(
406            &mut self,
407            glyph: GlyphId,
408            _brush_transform: Option<Matrix<f32>>,
409            _brush: Brush<'_>,
410        ) {
411            self.filled_glyphs.push(glyph);
412            self.fills += 1;
413        }
414
415        fn push_layer(&mut self, _composite_mode: CompositeMode) {
416            self.layer_pushes += 1;
417        }
418
419        fn pop_layer(&mut self) {
420            self.layer_pops += 1;
421        }
422    }
423
424    fn colr_tables(font: &FontRef<'static>) -> ColrTables<'static> {
425        ColrTables::new(font.colr().unwrap())
426    }
427
428    #[test]
429    fn transform_error_unwinds_transform_stack() {
430        let font = FontRef::new(font_test_data::COLRV0V1_VARIABLE).unwrap();
431        let tables = colr_tables(&font);
432        let instance = ColrInstance::new(&tables, &[], &[]);
433        let model = Font::new(font_test_data::COLRV0V1_VARIABLE.to_vec(), 0).unwrap();
434        let glyph = model.charmap().map_unicode(CLIPBOX[0]).unwrap();
435        let (paint, _) = instance.v1_base_glyph(glyph).unwrap();
436        let mut sink = StackTrackingSink::default();
437        let mut state = TraversalState::new(instance, &mut sink);
438        let mut decycler = PaintDecycler::new();
439        // Exhaust the node budget so the traversal fails inside the transform.
440        state.nodes_left = 1;
441        let paint = ResolvedPaint::Translate {
442            dx: 0.0,
443            dy: 0.0,
444            paint,
445        };
446        let result = traverse_with_callbacks(&paint, &mut state, &mut decycler, 0);
447        assert!(matches!(result, Err(Error::LimitExceeded)));
448        assert_eq!(sink.transform_pushes, sink.transform_pops);
449        assert_ne!(sink.transform_pushes, 0);
450    }
451
452    #[test]
453    fn composite_error_unwinds_layer_stack() {
454        let font = FontRef::new(font_test_data::COLRV0V1_VARIABLE).unwrap();
455        let tables = colr_tables(&font);
456        let instance = ColrInstance::new(&tables, &[], &[]);
457        let model = Font::new(font_test_data::COLRV0V1_VARIABLE.to_vec(), 0).unwrap();
458        let glyph = model.charmap().map_unicode(CLIPBOX[0]).unwrap();
459        let (inner_paint, _) = instance.v1_base_glyph(glyph).unwrap();
460        let mut sink = StackTrackingSink::default();
461        let mut state = TraversalState::new(instance, &mut sink);
462        let mut decycler = PaintDecycler::new();
463        state.nodes_left = 0;
464        let paint = ResolvedPaint::Composite {
465            source_paint: inner_paint.clone(),
466            mode: CompositeMode::SrcOver,
467            backdrop_paint: inner_paint,
468        };
469        let result = traverse_with_callbacks(&paint, &mut state, &mut decycler, 0);
470        assert!(matches!(result, Err(Error::LimitExceeded)));
471        assert_eq!(sink.layer_pushes, sink.layer_pops);
472        assert_ne!(sink.layer_pushes, 0);
473    }
474
475    #[test]
476    fn clipbox_error_unwinds_clip_stack() {
477        let font = FontRef::new(font_test_data::COLRV0V1_VARIABLE).unwrap();
478        let tables = colr_tables(&font);
479        let instance = ColrInstance::new(&tables, &[], &[]);
480        let model = Font::new(font_test_data::COLRV0V1_VARIABLE.to_vec(), 0).unwrap();
481        let glyph = model.charmap().map_unicode(CLIPBOX[0]).unwrap();
482        let mut sink = StackTrackingSink::default();
483        let mut state = TraversalState::new(instance, &mut sink);
484        let mut decycler = PaintDecycler::new();
485        state.nodes_left = 0;
486        let paint = ResolvedPaint::ColrGlyph {
487            glyph: GlyphId16::new(glyph.to_u32() as u16),
488        };
489        let result = traverse_with_callbacks(&paint, &mut state, &mut decycler, 0);
490        assert!(matches!(result, Err(Error::LimitExceeded)));
491        assert_eq!(sink.clip_pushes, sink.clip_pops);
492        assert_ne!(sink.clip_pushes, 0);
493    }
494
495    #[test]
496    fn no_panic_on_empty_colorline() {
497        // Minimized test case from <https://issues.oss-fuzz.com/issues/375768991>.
498        let test_case: &[u8] = &[
499            0, 1, 0, 0, 0, 3, 32, 32, 32, 32, 32, 32, 0, 32, 32, 32, 32, 32, 32, 32, 255, 32, 32,
500            32, 32, 32, 32, 32, 67, 79, 76, 82, 32, 32, 32, 32, 0, 0, 0, 229, 0, 0, 0, 178, 99,
501            109, 97, 112, 32, 32, 32, 32, 0, 0, 0, 10, 0, 0, 1, 32, 32, 32, 32, 255, 32, 32, 32, 0,
502            4, 32, 255, 32, 32, 0, 32, 32, 32, 32, 32, 32, 32, 255, 32, 32, 32, 32, 32, 32, 32, 32,
503            32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 255, 32, 0, 0,
504            32, 32, 0, 0, 0, 57, 32, 32, 32, 32, 32, 32, 32, 255, 32, 32, 32, 32, 32, 32, 32, 32,
505            32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32,
506            32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32,
507            32, 0, 0, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32,
508            32, 32, 32, 32, 32, 32, 32, 32, 32, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
509            255, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 0, 0, 0, 4, 32, 32, 32, 32, 32, 32, 32,
510            32, 32, 0, 0, 0, 1, 32, 32, 32, 32, 32, 32, 255, 0, 0, 0, 40, 32, 32, 32, 32, 32, 32,
511            32, 255, 255, 32, 32, 32, 4, 0, 0, 32, 32, 32, 32, 32, 0, 0, 0, 0, 0, 0, 0, 0, 32, 32,
512            32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 0, 0, 32, 32, 32, 255, 255,
513            255, 255, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32,
514            32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32,
515            32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 255, 255, 255, 255, 255, 255, 255, 255, 255,
516            255, 255, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32,
517            255, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32,
518        ];
519
520        let font = FontRef::new(test_case).unwrap();
521        font.cmap().unwrap();
522        font.colr().unwrap();
523
524        let model = Font::new(test_case.to_vec(), 0).unwrap();
525        let color_glyph = model
526            .color_glyphs()
527            .get_with_version(GlyphId::new(8447), ColorGlyphVersion::ColrV1)
528            .unwrap();
529        let _ = color_glyph.paint(&mut NopSink);
530    }
531
532    #[test]
533    fn visited_nodes_limit() {
534        let font = FontRef::new(font_test_data::COLRV0V1_VARIABLE).unwrap();
535        let tables = colr_tables(&font);
536        let gid = GlyphId::new(120);
537        let mut sink = NopSink;
538        let mut traverse_with_limit = |limit| {
539            let instance = ColrInstance::new(&tables, &[], &[]);
540            let mut state = TraversalState::new(instance, &mut sink);
541            let mut decycler = PaintDecycler::new();
542            state.nodes_left = limit;
543            let paint = state
544                .resolve_paint(&state.instance.v1_base_glyph(gid).unwrap().0)
545                .unwrap();
546            traverse_with_callbacks(&paint, &mut state, &mut decycler, 0)
547                .map(|_| limit - state.nodes_left)
548        };
549        // Compute the actual number of nodes used by the glyph
550        let node_count = traverse_with_limit(MAX_NODES).unwrap();
551        // Now run with a reduced limit and verify that we get an error
552        let result = traverse_with_limit(node_count - 1);
553        assert!(matches!(result, Err(Error::LimitExceeded)));
554    }
555
556    #[test]
557    fn a_minimal_colr_reads_without_variation_tables() {
558        // A version 0 table with no records.
559        let bytes = [0u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
560        let colr = crate::tables::colr::Colr::read(FontData::new(&bytes)).unwrap();
561        let tables = ColrTables::new(colr);
562        assert!(tables.index_map.is_none());
563        assert!(tables.var_store.is_none());
564    }
565}