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
230/// The largest render target either backend accepts in one axis.
231///
232/// `vello_cpu`'s `RenderContext::new`, `Pixmap::new` and `Mask` are all
233/// `u16`-dimensioned; `tiny-skia` is `u32` but must agree for Tier C.
234pub const MAX_TARGET_DIMENSION: u32 = u16::MAX as u32;
235
236impl From<pdfrum_page::FillRule> for FillRule {
237    /// `kWinding` maps to non-zero and **everything else, `kNoFill` included,
238    /// maps to even-odd**.
239    fn from(value: pdfrum_page::FillRule) -> Self {
240        match value {
241            pdfrum_page::FillRule::Winding => Self::Winding,
242            pdfrum_page::FillRule::EvenOdd | pdfrum_page::FillRule::None => Self::EvenOdd,
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn no_fill_rule_reads_as_even_odd() {
253        // cfx_agg_devicedriver.cpp:395-400: only kWinding is non-zero.
254        assert_eq!(
255            FillRule::from(pdfrum_page::FillRule::None),
256            FillRule::EvenOdd
257        );
258        assert_eq!(
259            FillRule::from(pdfrum_page::FillRule::EvenOdd),
260            FillRule::EvenOdd
261        );
262        assert_eq!(
263            FillRule::from(pdfrum_page::FillRule::Winding),
264            FillRule::Winding
265        );
266    }
267}