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}