Skip to main content

pdfrum_render/
device.rs

1//! The only seam between the engine and a rasterizer.
2//!
3//! Two traits and a handful of vocabulary types, all spoken in kurbo/peniko.
4//! Nothing PDF-specific crosses this boundary: every decision that both
5//! backends must agree on — degeneracy filters, integer rect snapping, the
6//! stroke matrix split, dash normalisation, the ±32000 coordinate clamp — is
7//! made in the engine *before* a device call, so a backend is free to be dumb
8//! and Tier C can demand that the two receive identical geometry.
9
10use kurbo::{Affine, BezPath, Rect, Stroke};
11use pdfrum_page::BlendMode;
12
13use crate::pixmap::{AlphaMask, Pixmap};
14
15/// Whether a primitive's edges are antialiased.
16///
17/// The three modes are AGG's three, and they are genuinely three rather than
18/// a flag and its negation:
19///
20/// - `On` integrates coverage and writes it as alpha.
21/// - `Off` is the oracle's `aliased_path`, which thresholds the *same*
22///   coverage at `> 127 -> 255` rather than turning the rasterizer off. It is
23///   what the axis-aligned rect fast path and a hard-edged clip ask for.
24/// - `FullCover` keeps the rasterizer's choice of *which* pixels a span
25///   covers and then ignores the coverage value, writing every one of them at
26///   the source alpha.
27///
28/// The distinction between the last two is the whole reason `FullCover`
29/// exists. Thresholding drops a pixel two abutting cells each cover halfway,
30/// so a subdivided Coons patch shows white pin-holes along every internal
31/// seam; `full_cover` paints it from both cells, which is what makes the
32/// patch continuous.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum AntiAlias {
35    /// Antialiased, the default for every ordinary fill and stroke.
36    #[default]
37    On,
38    /// Hard-edged: coverage thresholded at its midpoint.
39    Off,
40    /// Every touched pixel at full alpha, whatever its coverage.
41    FullCover,
42}
43
44/// Which winding rule decides a path's interior.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum FillRule {
47    /// Non-zero winding.
48    #[default]
49    Winding,
50    /// Even-odd.
51    EvenOdd,
52}
53
54/// How an image's samples are reconstructed when it is scaled.
55///
56/// The *selection* is the engine's — it ports PDFium's `/Interpolate`, its
57/// `bNoSmoothing`/`bHalftone` flags, the `kHugeImageSize` forcing rule and
58/// the `UseInterpolateBilinear` heuristic — and the kernels are each
59/// backend's own. `Nearest` is passed explicitly for an integer-only
60/// translation, because `vello_cpu` silently downgrades bilinear to nearest
61/// in exactly that case and `tiny-skia` does not.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum ImageQuality {
64    /// Nearest neighbour.
65    #[default]
66    Nearest,
67    /// Bilinear.
68    Bilinear,
69}
70
71/// A premultiplied RGBA8 image ready to be drawn, with its own dimensions.
72pub type RasterImage = Pixmap;
73
74/// What a fill or stroke paints with.
75#[derive(Debug, Clone, Copy)]
76pub enum Brush<'a> {
77    /// A single colour.
78    Solid(peniko::Color),
79    /// An image, mapped onto the primitive by the draw's own transform.
80    Image(&'a RasterImage),
81}
82
83/// The device a page is drawn into: six primitives plus a hard-edged rect
84/// clip, all object-safe.
85///
86/// The engine holds one of these per render target — the page, each
87/// transparency group, each soft mask, each pattern cell — and never learns
88/// which rasterizer is behind it.
89pub trait RenderDevice {
90    /// Fill `path` (in user space) transformed by `t`.
91    fn fill_path(
92        &mut self,
93        path: &BezPath,
94        t: Affine,
95        brush: &Brush<'_>,
96        rule: FillRule,
97        aa: AntiAlias,
98    );
99
100    /// Stroke `path` (in user space) transformed by `t`.
101    ///
102    /// The engine has already resolved the stroke's device width — including
103    /// the one-device-pixel minimum — and normalised its dash array to an
104    /// even-length, all-positive list, so `stroke` is always directly usable.
105    fn stroke_path(
106        &mut self,
107        path: &BezPath,
108        t: Affine,
109        brush: &Brush<'_>,
110        stroke: &Stroke,
111        aa: AntiAlias,
112    );
113
114    /// Draw `img` with its own **pixel grid** mapped through `t`, at a
115    /// constant `alpha`.
116    ///
117    /// `t` maps image pixel `(0, 0)`'s corner to its device position, so an
118    /// identity transform is a texel-for-pixel blit at the origin and a
119    /// translation moves it whole pixels — *not* a unit-square mapping.
120    /// Reading `t` the other way collapses a whole-page image onto a single
121    /// pixel, which is silent and total.
122    ///
123    /// An image being *reduced* has already been box-filtered down to roughly
124    /// its device size by `crate::stretch::prescale`, so `t` scales it by
125    /// less than a pixel in each axis and the two-tap kernel below is running
126    /// near 1:1. An image being *enlarged* arrives at its own resolution, which
127    /// is where the two-tap kernel is the right one and `quality` chooses it.
128    fn draw_image(&mut self, img: &RasterImage, t: Affine, quality: ImageQuality, alpha: f32);
129
130    /// Blit one glyph whose coverage is **three values per pixel**, one per LCD
131    /// stripe, each merged into its own destination channel.
132    ///
133    /// The oracle's `ClearType` text
134    /// (`DrawNormalTextHelper`'s `MergeGammaAdjustRgb` arm): for each pixel and
135    /// each channel `c`, `dest_c = (dest_c·(255 − a_c) + colour_c·a_c) / 255`
136    /// where `a_c = coverage_c · colour_alpha / 255`, and the destination is
137    /// left opaque. Three independent alphas is exactly what
138    /// [`RenderDevice::draw_image`] cannot express — one RGBA pixel carries one
139    /// — which is why this is its own primitive rather than a flag on that one.
140    ///
141    /// `origin` is the device position of the bitmap's top-left corner, in
142    /// whole pixels: both terms of it are integers by construction, so there is
143    /// nothing to resample and a backend blits texel for pixel.
144    ///
145    /// **Defaulted to grayscale.** The default averages each pixel's three
146    /// coverages and draws the result through [`RenderDevice::draw_image`], so
147    /// a backend that cannot address channels separately still renders the text
148    /// — in grey, without the colour fringes, which is what every *other* run
149    /// of text on the page looks like anyway. That is a visible difference from
150    /// the oracle on live-edit text and nothing worse: no glyph goes missing and
151    /// no geometry moves. A backend that owns its pixels should override it.
152    fn draw_glyph_lcd(
153        &mut self,
154        glyph: &crate::glyph::SubpixelBitmap,
155        origin: (f64, f64),
156        colour: peniko::Color,
157    ) {
158        let Some(gray) = crate::glyph::average_to_gray(glyph) else {
159            return;
160        };
161        let Some(pixels) = crate::glyph::recolour(&gray, colour) else {
162            return;
163        };
164        self.draw_image(
165            &pixels,
166            Affine::translate(origin),
167            ImageQuality::Nearest,
168            1.0,
169        );
170    }
171
172    /// Intersect the clip with `path` (already in device space).
173    fn push_clip(&mut self, path: &BezPath, rule: FillRule);
174
175    /// Intersect the clip with an axis-aligned rectangle, **hard-edged**.
176    ///
177    /// PDFium's `SetClip_PathFill` takes a rect fast path that snaps to the
178    /// outer integer rect and applies it with no antialiasing at all; `re W n`
179    /// is the commonest clip in the corpus, so routing it through
180    /// [`RenderDevice::push_clip`] would add a soft pixel along every clipped
181    /// edge on a large fraction of the corpus.
182    fn push_clip_rect(&mut self, rect: Rect);
183
184    /// Begin a layer that will be composited back with `blend`, scaled by
185    /// `alpha` and masked by `mask`.
186    ///
187    /// `mask`, when present, is device-sized and device-aligned. That is an
188    /// invariant, not a convention: a mismatched mask is silently ignored by
189    /// `vello_cpu` and merely warned about by `tiny-skia`, so violating it
190    /// fails *open*, producing unmasked output. Backends assert it.
191    fn push_layer(&mut self, blend: BlendMode, alpha: f32, mask: Option<&AlphaMask>);
192
193    /// End the innermost clip or layer.
194    fn pop(&mut self);
195}
196
197/// The factory that lets the engine rasterize offscreen: soft masks,
198/// transparency groups, pattern cells and the fill+stroke knockout buffer all
199/// need a second target.
200pub trait RasterBackend {
201    /// The device this backend produces.
202    type Device: RenderDevice;
203
204    /// A fresh target of the given size, every pixel set to `clear`.
205    ///
206    /// Both axes must be at most [`MAX_TARGET_DIMENSION`]: `vello_cpu` sizes
207    /// its scenes, pixmaps and masks with `u16`. The engine enforces the
208    /// bound before calling.
209    fn new_target(&self, w: u32, h: u32, clear: peniko::Color) -> Self::Device;
210
211    /// A fresh target seeded with `base`'s pixels.
212    ///
213    /// This is a non-isolated transparency group's backdrop (ISO 32000
214    /// §11.4.6) and the fill+stroke knockout buffer's saved destination.
215    fn new_target_with_backdrop(&self, base: &Pixmap) -> Self::Device;
216
217    /// The device's current pixels, without consuming it.
218    ///
219    /// Only valid with every [`RenderDevice::push_layer`] matched by a
220    /// [`RenderDevice::pop`] — `vello_cpu` asserts it. On a retained-scene
221    /// backend this costs a full rasterization of the scene so far, not a
222    /// rectangle copy, which is why the engine renders each group into its
223    /// own target rather than snapshotting sub-rectangles of a shared one.
224    fn snapshot(&self, d: &Self::Device) -> Pixmap;
225
226    /// Consume the device, yielding its premultiplied RGBA8 pixels.
227    fn finish(&self, d: Self::Device) -> Pixmap;
228
229    /// [`snapshot`][Self::snapshot] cropped to `(origin_x, origin_y, width, height)`.
230    ///
231    /// The default is a full snapshot and a host crop. A retained-scene GPU
232    /// backend still has to rasterize the scene so far — that is the
233    /// structural cost — but can copy only this rectangle off the device,
234    /// which is the whole of a non-isolated group's backdrop.
235    ///
236    /// ```
237    /// use pdfrum_raster_vello_cpu::VelloCpuBackend;
238    /// use pdfrum_render::RasterBackend;
239    ///
240    /// let backend = VelloCpuBackend;
241    /// let device = backend.new_target(4, 4, peniko::Color::WHITE);
242    /// let crop = backend.snapshot_rect(&device, 1, 1, 2, 2);
243    /// assert_eq!((crop.width(), crop.height()), (2, 2));
244    /// ```
245    fn snapshot_rect(
246        &self,
247        d: &Self::Device,
248        origin_x: u32,
249        origin_y: u32,
250        width: u32,
251        height: u32,
252    ) -> Pixmap {
253        self.snapshot(d).cropped(origin_x, origin_y, width, height)
254    }
255
256    /// Whether isolated groups should composite as native layers instead of
257    /// an offscreen pixmap round trip.
258    ///
259    /// The pixmap path is the CPU goldens: `finish`, `multiply_alpha_mask`,
260    /// `remove_backdrop`. Native layers skip that host stall, which is the
261    /// GPU win, but they are not bit-identical to the pixmap arithmetic —
262    /// so the default is `false` and only a backend that has opted in (the
263    /// GPU one) takes it. CPU backends keep the board still.
264    ///
265    /// ```
266    /// use pdfrum_raster_vello_cpu::VelloCpuBackend;
267    /// use pdfrum_render::RasterBackend;
268    ///
269    /// assert!(!VelloCpuBackend.composite_isolated_groups_as_layers());
270    /// assert!(!(&VelloCpuBackend).composite_isolated_groups_as_layers());
271    /// ```
272    fn composite_isolated_groups_as_layers(&self) -> bool {
273        false
274    }
275}
276
277/// A reference to a backend is a backend.
278///
279/// Stateless CPU backends are unit structs, so a caller writes
280/// `page.render(VelloCpuBackend)`. A backend that holds a device is passed
281/// by reference, `page.render(&gpu)`, through this impl — the rasterizer is
282/// still named, and nothing is moved.
283impl<T: RasterBackend + ?Sized> RasterBackend for &T {
284    type Device = T::Device;
285
286    fn new_target(&self, w: u32, h: u32, clear: peniko::Color) -> Self::Device {
287        (**self).new_target(w, h, clear)
288    }
289
290    fn new_target_with_backdrop(&self, base: &Pixmap) -> Self::Device {
291        (**self).new_target_with_backdrop(base)
292    }
293
294    fn snapshot(&self, d: &Self::Device) -> Pixmap {
295        (**self).snapshot(d)
296    }
297
298    fn finish(&self, d: Self::Device) -> Pixmap {
299        (**self).finish(d)
300    }
301
302    fn snapshot_rect(
303        &self,
304        d: &Self::Device,
305        origin_x: u32,
306        origin_y: u32,
307        width: u32,
308        height: u32,
309    ) -> Pixmap {
310        (**self).snapshot_rect(d, origin_x, origin_y, width, height)
311    }
312
313    fn composite_isolated_groups_as_layers(&self) -> bool {
314        (**self).composite_isolated_groups_as_layers()
315    }
316}
317
318/// The largest render target either backend accepts in one axis.
319///
320/// `vello_cpu`'s `RenderContext::new`, `Pixmap::new` and `Mask` are all
321/// `u16`-dimensioned; `tiny-skia` is `u32` but must agree for Tier C.
322pub const MAX_TARGET_DIMENSION: u32 = u16::MAX as u32;
323
324impl From<pdfrum_page::FillRule> for FillRule {
325    /// `kWinding` maps to non-zero and **everything else, `kNoFill` included,
326    /// maps to even-odd**.
327    fn from(value: pdfrum_page::FillRule) -> Self {
328        match value {
329            pdfrum_page::FillRule::Winding => Self::Winding,
330            pdfrum_page::FillRule::EvenOdd | pdfrum_page::FillRule::None => Self::EvenOdd,
331        }
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn no_fill_rule_reads_as_even_odd() {
341        // cfx_agg_devicedriver.cpp:395-400: only kWinding is non-zero.
342        assert_eq!(
343            FillRule::from(pdfrum_page::FillRule::None),
344            FillRule::EvenOdd
345        );
346        assert_eq!(
347            FillRule::from(pdfrum_page::FillRule::EvenOdd),
348            FillRule::EvenOdd
349        );
350        assert_eq!(
351            FillRule::from(pdfrum_page::FillRule::Winding),
352            FillRule::Winding
353        );
354    }
355}