Skip to main content

oxml_layout/
paint.rs

1//! Backend-neutral fill and stroke paint.
2
3use crate::{Color, MediaId, Point, Rect, Transform};
4
5/// One color stop in a gradient.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct GradientStop {
8    pub offset: f64,
9    pub color: Color,
10}
11
12/// Fill or stroke paint independent of a rendering backend.
13#[derive(Debug, Clone, PartialEq)]
14pub enum Paint {
15    Solid(Color),
16    Linear {
17        start: Point,
18        end: Point,
19        stops: Vec<GradientStop>,
20        extend: (bool, bool),
21    },
22    Radial {
23        center: Point,
24        radius: f64,
25        focal: Point,
26        stops: Vec<GradientStop>,
27        extend: (bool, bool),
28    },
29    /// A source-derived colour grid, repeated over its physical tile bounds.
30    /// Path gradients use one tile covering the complete painted owner.
31    Grid {
32        bounds: Rect,
33        columns: u16,
34        rows: u16,
35        colors: Vec<Color>,
36    },
37    Tile {
38        image: MediaId,
39        tile: Rect,
40        transform: Transform,
41    },
42}
43
44impl Paint {
45    /// Validate a bounded colour grid before publishing backend-neutral paint.
46    pub fn grid(bounds: Rect, columns: u16, rows: u16, colors: Vec<Color>) -> Option<Self> {
47        let count = usize::from(columns) * usize::from(rows);
48        (columns > 0
49            && rows > 0
50            && count <= 65_536
51            && colors.len() == count
52            && [bounds.x, bounds.y, bounds.width, bounds.height]
53                .into_iter()
54                .all(f64::is_finite)
55            && bounds.width > 0.0
56            && bounds.height > 0.0
57            && colors.iter().all(|c| {
58                [c.r, c.g, c.b, c.a]
59                    .into_iter()
60                    .all(|v| v.is_finite() && (0.0..=1.0).contains(&v))
61            }))
62        .then_some(Self::Grid {
63            bounds,
64            columns,
65            rows,
66            colors,
67        })
68    }
69
70    /// Construct a linear gradient, degrading one stop to a solid paint.
71    pub fn linear(
72        start: Point,
73        end: Point,
74        stops: Vec<GradientStop>,
75        extend: (bool, bool),
76    ) -> Self {
77        if let [stop] = stops.as_slice() {
78            Self::Solid(stop.color)
79        } else {
80            Self::Linear {
81                start,
82                end,
83                stops,
84                extend,
85            }
86        }
87    }
88
89    /// Construct a radial gradient, degrading one stop to a solid paint.
90    pub fn radial(
91        center: Point,
92        radius: f64,
93        focal: Point,
94        stops: Vec<GradientStop>,
95        extend: (bool, bool),
96    ) -> Self {
97        if let [stop] = stops.as_slice() {
98            Self::Solid(stop.color)
99        } else {
100            Self::Radial {
101                center,
102                radius,
103                focal,
104                stops,
105                extend,
106            }
107        }
108    }
109}
110
111/// Stroke endpoint style.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113pub enum LineCap {
114    #[default]
115    Butt,
116    Round,
117    Square,
118}
119
120/// Stroke segment join style.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum LineJoin {
123    #[default]
124    Miter,
125    Round,
126    Bevel,
127}
128
129/// Paint and geometry used to stroke a path.
130#[derive(Debug, Clone, PartialEq)]
131pub struct Stroke {
132    pub paint: Paint,
133    pub width: f64,
134    pub cap: LineCap,
135    pub join: LineJoin,
136    pub dash: Option<Vec<f64>>,
137}
138
139impl Stroke {
140    /// Construct a stroke with butt caps, miter joins, and no dash.
141    pub fn new(paint: Paint, width: f64) -> Self {
142        Self {
143            paint,
144            width,
145            cap: LineCap::Butt,
146            join: LineJoin::Miter,
147            dash: None,
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::{GradientStop, LineCap, LineJoin, Paint, Stroke};
155    use crate::{Color, MediaId, Point, Rect, Transform};
156
157    fn red() -> Color {
158        Color {
159            r: 1.0,
160            g: 0.0,
161            b: 0.0,
162            a: 1.0,
163        }
164    }
165
166    #[test]
167    fn single_stop_gradients_degrade_to_solid_at_construction() {
168        let stops = vec![GradientStop {
169            offset: 0.25,
170            color: red(),
171        }];
172        assert_eq!(
173            Paint::linear(
174                Point { x: 0.0, y: 0.0 },
175                Point { x: 1.0, y: 1.0 },
176                stops.clone(),
177                (false, false),
178            ),
179            Paint::Solid(red())
180        );
181        assert_eq!(
182            Paint::radial(
183                Point { x: 0.5, y: 0.5 },
184                1.0,
185                Point { x: 0.5, y: 0.5 },
186                stops,
187                (false, false),
188            ),
189            Paint::Solid(red())
190        );
191    }
192
193    #[test]
194    fn multiple_stop_gradients_preserve_their_geometry_and_stops() {
195        let stops = vec![
196            GradientStop {
197                offset: 0.0,
198                color: Color::BLACK,
199            },
200            GradientStop {
201                offset: 1.0,
202                color: Color::WHITE,
203            },
204        ];
205        let paint = Paint::linear(
206            Point { x: 0.0, y: 0.0 },
207            Point { x: 1.0, y: 1.0 },
208            stops.clone(),
209            (true, false),
210        );
211        assert!(matches!(
212            paint,
213            Paint::Linear {
214                stops: actual,
215                extend: (true, false),
216                ..
217            } if actual == stops
218        ));
219    }
220
221    #[test]
222    fn stroke_new_uses_pdf_defaults() {
223        let stroke = Stroke::new(Paint::Solid(red()), 2.0);
224        assert_eq!(stroke.cap, LineCap::Butt);
225        assert_eq!(stroke.join, LineJoin::Miter);
226        assert_eq!(stroke.dash, None);
227    }
228
229    #[test]
230    fn tile_paint_uses_a_media_id() {
231        let media_id = MediaId::from_bytes(b"tile");
232        let paint = Paint::Tile {
233            image: media_id,
234            tile: Rect {
235                x: 0.0,
236                y: 0.0,
237                width: 10.0,
238                height: 20.0,
239            },
240            transform: Transform::IDENTITY,
241        };
242        assert!(matches!(paint, Paint::Tile { image, .. } if image == media_id));
243    }
244}