Skip to main content

pdfrum_edit/canvas/
gradient.rs

1//! Gradient fills: a shape painted with an axial or radial shading
2//! (ISO 32000-1 ยง8.7.4.5.3-4), the stops stitched into one function.
3//!
4//! PDF paints a shading with `sh` over the whole clip, so the shape becomes
5//! the clip and the shading is painted through it. Colour and alpha are
6//! separate in PDF: stops that share one alpha need only a constant opacity;
7//! stops whose alpha varies get a luminosity soft mask painted with the same
8//! geometry in grey, so a fade to transparent is a fade, not a fade to black.
9
10use kurbo::{Affine, Point, Shape};
11use pdfrum_object::{Array, ByteSpan, Dict, Name, Object, Stream, names as pdf_names};
12use peniko::Color;
13
14use super::{Canvas, Fill, as_f32};
15use crate::write_matrix;
16
17/// A colour ramp, in its own coordinate space.
18#[derive(Debug, Clone, PartialEq)]
19pub struct Gradient {
20    /// Its geometry.
21    pub kind: GradientKind,
22    /// Colours along it, by offset from 0 (start) to 1 (end). Out-of-order
23    /// or out-of-range offsets are clamped into order; beyond the ends the
24    /// end colours extend.
25    pub stops: Vec<GradientStop>,
26}
27
28/// Where a [`Gradient`] runs.
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub enum GradientKind {
31    /// Along the line from `start` to `end`, constant across it.
32    Linear {
33        /// Offset 0.
34        start: Point,
35        /// Offset 1.
36        end: Point,
37    },
38    /// Between two circles, the start circle at offset 0 and the end circle
39    /// at offset 1 (a two-point conical gradient, as CSS and SVG define).
40    Radial {
41        /// Centre of the start circle.
42        start_center: Point,
43        /// Radius of the start circle.
44        start_radius: f64,
45        /// Centre of the end circle.
46        end_center: Point,
47        /// Radius of the end circle.
48        end_radius: f64,
49    },
50}
51
52/// One colour of a [`Gradient`].
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct GradientStop {
55    /// Where, from 0 to 1.
56    pub offset: f64,
57    /// The colour there; its alpha is honoured.
58    pub color: Color,
59}
60
61impl Canvas<'_, '_> {
62    /// Fill `shape` by `rule` with `gradient`, placed by `transform` from
63    /// the gradient's space into canvas space.
64    ///
65    /// A gradient with no stops paints nothing; one stop is a solid fill.
66    ///
67    /// ```
68    /// use pdfrum_edit::{EditDoc, Fill, Gradient, GradientKind, GradientStop, Size, blank_document};
69    /// use pdfrum_common::Limits;
70    /// use kurbo::{Affine, Point, Rect};
71    /// use peniko::Color;
72    ///
73    /// let base = blank_document(&[Size::new(200.0, 100.0)])?;
74    /// let mut edit = EditDoc::new(&base);
75    /// let ramp = Gradient {
76    ///     kind: GradientKind::Linear { start: Point::new(0.0, 0.0), end: Point::new(200.0, 0.0) },
77    ///     stops: vec![
78    ///         GradientStop { offset: 0.0, color: Color::from_rgb8(255, 0, 0) },
79    ///         GradientStop { offset: 1.0, color: Color::from_rgba8(0, 0, 255, 0) },
80    ///     ],
81    /// };
82    /// edit.draw_page(0, &Limits::default(), |c| {
83    ///     c.fill_gradient(Rect::new(0.0, 0.0, 200.0, 100.0), Fill::NonZero, &ramp, Affine::IDENTITY);
84    /// })?;
85    /// # Ok::<(), pdfrum_edit::Error>(())
86    /// ```
87    pub fn fill_gradient(
88        &mut self,
89        shape: impl Shape,
90        rule: Fill,
91        gradient: &Gradient,
92        transform: Affine,
93    ) {
94        let stops = ordered(&gradient.stops);
95        let path = shape.into_path(0.1);
96        match stops.as_slice() {
97            [] => return,
98            [only] => return self.draw(path, super::Paint::Fill(only.color), rule),
99            _ => {}
100        }
101        if path.elements().is_empty() {
102            return;
103        }
104        let shading = shading(gradient.kind, &stops, Channel::Colour);
105        let alphas: Vec<f32> = stops.iter().map(|stop| stop.color.components[3]).collect();
106        let uniform = alphas
107            .windows(2)
108            .all(|pair| matches!(pair, [a, b] if (a - b).abs() < 1e-4));
109        let bounds = transform.inverse().transform_rect_bbox(path.bounding_box());
110        let name = self.realize(pdf_names::SHADING, Object::Dict(shading));
111        self.out.push_str("q\n");
112        self.write_path(&path);
113        self.out.push_str(match rule {
114            Fill::NonZero => " W n\n",
115            Fill::EvenOdd => " W* n\n",
116        });
117        write_matrix(&mut self.out, transform);
118        self.out.push_str(" cm\n");
119        match (uniform, alphas.first()) {
120            (true, Some(&alpha)) if alpha < 1.0 => self.opacity(f64::from(alpha)),
121            (true, _) => {}
122            (false, _) => self.alpha_mask(gradient.kind, &stops, bounds),
123        }
124        self.out.push('/');
125        self.push_name(&name);
126        self.out.push_str(" sh\nQ\n");
127    }
128
129    /// Set a luminosity soft mask that paints each stop's alpha as grey,
130    /// over `bounds` in the gradient's space.
131    fn alpha_mask(&mut self, kind: GradientKind, stops: &[GradientStop], bounds: kurbo::Rect) {
132        let grey = shading(kind, stops, Channel::Alpha);
133        let form = Dict::from_pairs([
134            (
135                pdf_names::TYPE.clone(),
136                Object::Name(pdf_names::XOBJECT.clone()),
137            ),
138            (pdf_names::SUBTYPE.clone(), Object::Name(Name::from("Form"))),
139            (
140                Name::from("BBox"),
141                Object::Array(Array::of(
142                    [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(|v| Object::Real(as_f32(v))),
143                )),
144            ),
145            (
146                Name::from("Group"),
147                Object::Dict(Dict::from_pairs([
148                    (Name::from("S"), Object::Name(Name::from("Transparency"))),
149                    (Name::from("CS"), Object::Name(Name::from("DeviceGray"))),
150                ])),
151            ),
152            (
153                pdf_names::RESOURCES.clone(),
154                Object::Dict(Dict::from_pairs([(
155                    pdf_names::SHADING.clone(),
156                    Object::Dict(Dict::from_pairs([(Name::from("A"), Object::Dict(grey))])),
157                )])),
158            ),
159        ]);
160        let form = self.edit.add(Object::Stream(Box::new(Stream::new(
161            form,
162            ByteSpan::from(b"/A sh\n".to_vec()),
163        ))));
164        let state = Dict::from_pairs([(
165            Name::from("SMask"),
166            Object::Dict(Dict::from_pairs([
167                (pdf_names::TYPE.clone(), Object::Name(Name::from("Mask"))),
168                (Name::from("S"), Object::Name(Name::from("Luminosity"))),
169                (Name::from("G"), Object::Ref(form)),
170            ])),
171        )]);
172        let name = self.realize(pdf_names::EXT_G_STATE, Object::Dict(state));
173        self.out.push('/');
174        self.push_name(&name);
175        self.out.push_str(" gs\n");
176    }
177}
178
179/// Stops clamped into 0..=1 and into order.
180fn ordered(stops: &[GradientStop]) -> Vec<GradientStop> {
181    let mut out: Vec<GradientStop> = Vec::with_capacity(stops.len());
182    for stop in stops {
183        let floor = out.last().map_or(0.0, |last| last.offset);
184        let offset = if stop.offset.is_finite() {
185            stop.offset
186        } else {
187            0.0
188        };
189        out.push(GradientStop {
190            offset: offset.clamp(floor, 1.0),
191            color: stop.color,
192        });
193    }
194    out
195}
196
197/// Which part of a stop's colour a shading paints.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199enum Channel {
200    /// Red, green and blue, in `/DeviceRGB`.
201    Colour,
202    /// The alpha, as `/DeviceGray`, for a soft mask.
203    Alpha,
204}
205
206/// The shading dictionary for `kind` over `stops`, extended at both ends.
207fn shading(kind: GradientKind, stops: &[GradientStop], channel: Channel) -> Dict {
208    let (shading_type, coords) = match kind {
209        GradientKind::Linear { start, end } => (2, vec![start.x, start.y, end.x, end.y]),
210        GradientKind::Radial {
211            start_center,
212            start_radius,
213            end_center,
214            end_radius,
215        } => (
216            3,
217            vec![
218                start_center.x,
219                start_center.y,
220                start_radius.max(0.0),
221                end_center.x,
222                end_center.y,
223                end_radius.max(0.0),
224            ],
225        ),
226    };
227    let space = match channel {
228        Channel::Colour => "DeviceRGB",
229        Channel::Alpha => "DeviceGray",
230    };
231    Dict::from_pairs([
232        (Name::from("ShadingType"), Object::Int(shading_type)),
233        (Name::from("ColorSpace"), Object::Name(Name::from(space))),
234        (
235            Name::from("Coords"),
236            Object::Array(Array::of(
237                coords.into_iter().map(|v| Object::Real(as_f32(v))),
238            )),
239        ),
240        (
241            Name::from("Function"),
242            Object::Dict(stitched(stops, channel)),
243        ),
244        (
245            Name::from("Extend"),
246            Object::Array(Array::of([Object::Bool(true), Object::Bool(true)])),
247        ),
248    ])
249}
250
251/// One stitching function (type 3) over a linear interpolation (type 2) per
252/// adjacent pair of stops, with constant segments before the first stop and
253/// after the last so offsets inside 0..1 still cover the whole domain.
254fn stitched(stops: &[GradientStop], channel: Channel) -> Dict {
255    let values = |stop: &GradientStop| -> Object {
256        let [r, g, b, a] = stop.color.components;
257        let components: Vec<f32> = match channel {
258            Channel::Colour => vec![r, g, b],
259            Channel::Alpha => vec![a],
260        };
261        Object::Array(Array::of(
262            components
263                .into_iter()
264                .map(|c| Object::Real(c.clamp(0.0, 1.0))),
265        ))
266    };
267    let mut ends: Vec<GradientStop> = stops.to_vec();
268    if let Some(first) = stops.first().filter(|first| first.offset > 0.0) {
269        ends.insert(
270            0,
271            GradientStop {
272                offset: 0.0,
273                ..*first
274            },
275        );
276    }
277    if let Some(last) = stops.last().filter(|last| last.offset < 1.0) {
278        ends.push(GradientStop {
279            offset: 1.0,
280            ..*last
281        });
282    }
283    let mut functions = Vec::new();
284    let mut bounds = Vec::new();
285    let mut encode = Vec::new();
286    for pair in ends.windows(2) {
287        let [from, to] = pair else { continue };
288        functions.push(Object::Dict(Dict::from_pairs([
289            (Name::from("FunctionType"), Object::Int(2)),
290            (Name::from("Domain"), unit_domain()),
291            (Name::from("C0"), values(from)),
292            (Name::from("C1"), values(to)),
293            (Name::from("N"), Object::Real(1.0)),
294        ])));
295        encode.push(Object::Real(0.0));
296        encode.push(Object::Real(1.0));
297        bounds.push(Object::Real(as_f32(to.offset)));
298    }
299    // One fewer bound than functions: the last stop's offset is the domain's end.
300    bounds.pop();
301    Dict::from_pairs([
302        (Name::from("FunctionType"), Object::Int(3)),
303        (Name::from("Domain"), unit_domain()),
304        (Name::from("Functions"), Object::Array(Array::of(functions))),
305        (Name::from("Bounds"), Object::Array(Array::of(bounds))),
306        (Name::from("Encode"), Object::Array(Array::of(encode))),
307    ])
308}
309
310fn unit_domain() -> Object {
311    Object::Array(Array::of([Object::Real(0.0), Object::Real(1.0)]))
312}
313
314#[cfg(test)]
315mod tests {
316    use super::{Channel, GradientStop, ordered, stitched};
317    use peniko::Color;
318
319    fn stop(offset: f64) -> GradientStop {
320        GradientStop {
321            offset,
322            color: Color::BLACK,
323        }
324    }
325
326    #[test]
327    fn stops_are_clamped_into_order() {
328        let offsets: Vec<f64> = ordered(&[stop(-1.0), stop(0.6), stop(0.4), stop(2.0)])
329            .iter()
330            .map(|stop| stop.offset)
331            .collect();
332        assert_eq!(offsets, vec![0.0, 0.6, 0.6, 1.0]);
333    }
334
335    #[test]
336    fn inner_stops_are_extended_to_the_ends() {
337        let function = stitched(&[stop(0.25), stop(0.75)], Channel::Colour);
338        let functions = function
339            .raw(&pdfrum_object::Name::from("Functions"))
340            .and_then(pdfrum_object::Object::as_array)
341            .map(pdfrum_object::Array::len);
342        assert_eq!(functions, Some(3), "flat, ramp, flat");
343    }
344}