pdfrum_render/scanline.rs
1//! The analytic cell rasterizer: exact per-pixel area and cover, in the style
2//! the oracle's scan converter uses.
3//!
4//! **Part of the backend seam.** A backend that owns its pixels rasterizes
5//! with [`Rasterizer`] rather than with a rasterizer of its own, because the
6//! coverage a page's edges get is exactly what the cross-backend equality
7//! property compares. `pdfrum-raster-agg` is the in-tree caller.
8//!
9//! Coverage is *integrated* from the polygon's edges, not sampled: a pixel's
10//! coverage is the real number the geometry implies, quantised only by the
11//! output byte. The sweep is all-integer, so the same path always produces
12//! the same bytes on every machine.
13
14// A supersampler asks a fixed grid of sample points and counts hits, which
15// quantises coverage to the number of samples it took. `tiny-skia`
16// supersamples at four subsamples per axis, so a diagonal edge has seventeen
17// distinct coverage levels and a half-covered pixel lands on 8/16 of the
18// range, where the oracle's own edge writes the exact half. Over the corpus
19// that is a persistent few-count spread along every non-axis-aligned edge.
20//
21// # The representation
22//
23// Each pixel the polygon's boundary crosses gets a `Cell` carrying two
24// integers:
25//
26// - **`cover`** — the net signed vertical distance the boundary travelled
27// through this pixel, in `SUBPIXEL_SCALE`ths of a pixel. Summing `cover`
28// left to right along a scanline gives the winding number, scaled, at every
29// point to the right of the cell.
30// - **`area`** — twice the signed area the boundary swept *inside* this
31// pixel, in the same units squared. It is the correction that turns the
32// running `cover` into the exact coverage of the boundary pixel itself.
33//
34// A cell is therefore a *difference*: interior pixels between two boundaries
35// carry no cell at all, and their coverage falls out of the running sum. That
36// is what makes the sweep linear in the boundary rather than in the area.
37//
38// It lives in the engine rather than in a backend because `crate::glyph`
39// rasterizes every small glyph into an alpha bitmap that must be identical
40// under all three rasterizers — the oracle's own glyph bitmap comes from
41// FreeType rather than from whatever draws the page's paths. Same argument as
42// `crate::blend::composite_premultiplied`: a decision the *engine* takes has
43// one implementation the engine owns.
44
45use core::ops::Range;
46
47use store::CellStore;
48
49mod store;
50
51/// The subpixel grid the rasterizer works on: 256 steps per pixel per axis.
52///
53/// Coordinates arrive as `f64` device pixels and are scaled by this and
54/// truncated, so the rasterizer's whole interior is integer arithmetic. 256
55/// is the oracle's own `poly_base_size`, and it is load-bearing rather than a
56/// tunable: [`coverage_to_alpha`]'s mapping is derived from this scale, and
57/// changing it would change every antialiased edge byte in the corpus.
58pub(crate) const SUBPIXEL_SCALE: i32 = 256;
59
60/// `log2(SUBPIXEL_SCALE)`, the shift the coordinate conversion uses.
61pub(crate) const SUBPIXEL_SHIFT: u32 = 8;
62
63/// The low bits of a subpixel coordinate: its position within its pixel.
64pub(crate) const SUBPIXEL_MASK: i32 = SUBPIXEL_SCALE - 1;
65
66/// One pixel's accumulated boundary contribution.
67///
68/// `cover` and `area` are signed because a boundary crossing downward
69/// contributes the negative of one crossing upward, which is what makes the
70/// winding number fall out of a running sum.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub(crate) struct Cell {
73 /// Pixel column.
74 pub x: i32,
75 /// Pixel row.
76 pub y: i32,
77 /// Net signed vertical travel through this pixel, in subpixel units.
78 pub cover: i32,
79 /// Twice the signed swept area inside this pixel, in subpixel units
80 /// squared.
81 pub area: i32,
82}
83
84impl Cell {
85 /// A fresh, empty cell at a position.
86 const fn at(x: i32, y: i32) -> Self {
87 Self {
88 x,
89 y,
90 cover: 0,
91 area: 0,
92 }
93 }
94
95 /// Whether this cell would contribute nothing to the sweep.
96 const fn is_empty(self) -> bool {
97 self.cover == 0 && self.area == 0
98 }
99}
100
101/// Accumulates a path's boundary into cells.
102///
103/// The lifecycle is: [`Rasterizer::move_to`] and [`Rasterizer::line_to`] for
104/// each flattened subpath, [`Rasterizer::close_polygon`] to close it, then
105/// [`Rasterizer::sweep`] to turn the cells into spans. Curves are flattened by
106/// the caller — this type sees only straight segments, which is also all the
107/// oracle's scan converter sees.
108///
109/// A caller that will throw away the spans outside a band of rows should say
110/// which band with [`Rasterizer::keep_rows`], and pay for the rows it keeps
111/// rather than for the rows the path reaches.
112///
113/// ```
114/// use pdfrum_render::scanline::{Coverage, FillRule, Rasterizer};
115///
116/// let mut rasterizer = Rasterizer::new();
117/// rasterizer.move_to(0.0, 0.0);
118/// rasterizer.line_to(4.0, 0.0);
119/// rasterizer.line_to(4.0, 2.0);
120/// rasterizer.line_to(0.0, 2.0);
121/// rasterizer.close_polygon();
122///
123/// let mut spans = Vec::new();
124/// rasterizer.sweep(FillRule::NonZero, Coverage::Exact, |x, len, y, alpha| {
125/// spans.push((x, y, len, alpha));
126/// });
127///
128/// // A 4x2 rectangle: two full rows, no partial coverage anywhere.
129/// assert_eq!(spans, [(0, 0, 4, 255), (0, 1, 4, 255)]);
130/// ```
131#[derive(Debug, Default)]
132pub struct Rasterizer {
133 store: CellStore,
134 /// The cell currently being accumulated into, held out of the store so a
135 /// run of segments crossing one pixel costs no lookup.
136 current: Option<Cell>,
137 /// The rows the caller keeps, as a half-open range of pixel rows; `None`
138 /// keeps every row. See [`Rasterizer::keep_rows`].
139 keep: Option<Range<i32>>,
140 /// Where the pen is, in subpixel coordinates.
141 x: i32,
142 y: i32,
143 /// Where the current subpath started, for `close_polygon`.
144 start_x: i32,
145 start_y: i32,
146 /// Whether a subpath is open — a `line_to` without a preceding `move_to`
147 /// is ignored rather than treated as starting at the origin.
148 open: bool,
149}
150
151/// The largest device coordinate [`to_subpixel`] will carry, in pixels.
152///
153/// The subpixel grid must hold `coordinate * 256` without overflowing `i32`,
154/// and the sweep additionally sums covers across a scanline, so the bound is
155/// set well inside `i32::MAX / SUBPIXEL_SCALE` to leave headroom for both. The
156/// engine's own ±32000 clamp is two orders of magnitude tighter, so this is
157/// reached only by a coordinate that clamp did not see.
158const COORDINATE_LIMIT: f64 = (1i32 << 22) as f64;
159
160/// A device coordinate as a subpixel one.
161///
162/// Truncating toward zero, matching the oracle's `int(c * 256)` rather than
163/// rounding. The clamp keeps a coordinate the engine's own ±32000 bound
164/// somehow missed from overflowing the multiply; it can only be reached by a
165/// non-finite value, which becomes zero.
166#[must_use]
167pub(crate) fn to_subpixel(v: f64) -> i32 {
168 if !v.is_finite() {
169 return 0;
170 }
171 let scaled = v.clamp(-COORDINATE_LIMIT, COORDINATE_LIMIT) * f64::from(SUBPIXEL_SCALE);
172 #[expect(
173 clippy::cast_possible_truncation,
174 reason = "the clamp bounds the product to +/-2^30; truncation toward \
175 zero is the ported conversion, not an accident"
176 )]
177 let out = scaled as i32;
178 out
179}
180
181impl Rasterizer {
182 /// A rasterizer with no cells.
183 ///
184 /// ```
185 /// use pdfrum_render::scanline::Rasterizer;
186 ///
187 /// assert!(Rasterizer::new().is_empty());
188 /// ```
189 #[must_use]
190 pub fn new() -> Self {
191 Self::default()
192 }
193
194 /// Forget every cell, keeping the allocation and the kept row range.
195 ///
196 /// ```
197 /// use pdfrum_render::scanline::{Coverage, FillRule, Rasterizer};
198 ///
199 /// let mut rasterizer = Rasterizer::new();
200 /// rasterizer.move_to(0.0, 0.0);
201 /// rasterizer.line_to(4.0, 0.0);
202 /// rasterizer.line_to(4.0, 2.0);
203 /// rasterizer.line_to(0.0, 2.0);
204 /// rasterizer.close_polygon();
205 ///
206 /// let mut spans = Vec::new();
207 /// rasterizer.sweep(FillRule::NonZero, Coverage::Exact, |x, len, y, alpha| {
208 /// spans.push((x, y, len, alpha));
209 /// });
210 ///
211 /// // The allocation and the kept row range survive; the cells do not.
212 /// rasterizer.reset();
213 /// assert!(rasterizer.is_empty());
214 /// ```
215 pub fn reset(&mut self) {
216 self.store.clear();
217 self.current = None;
218 self.open = false;
219 }
220
221 /// Keep only the cells on rows in `rows`, a half-open range of pixel rows.
222 ///
223 /// This is a promise about the *caller*, not a change to the geometry: it
224 /// says the caller will discard every span outside `rows` anyway, so the
225 /// rasterizer may drop those cells rather than sort and sweep them. The
226 /// spans that do come out are byte-for-byte the ones an unrestricted
227 /// rasterizer emits, because [`Rasterizer::sweep`] resolves each row from
228 /// that row's cells alone — the running cover is reset at every row
229 /// boundary, so a dropped row can change no other.
230 ///
231 /// It is worth setting whenever the path may extend far outside the
232 /// target: a path whose bounding box is tens of thousands of rows tall on
233 /// an 842-row page otherwise pays for every row it crosses.
234 ///
235 /// Only rows are restricted. A span too far left or right still costs its
236 /// cells, because the horizontal extent is bounded by the path's own
237 /// segment count rather than by the rows it crosses.
238 ///
239 /// ```
240 /// use pdfrum_render::scanline::{Coverage, FillRule, Rasterizer};
241 ///
242 /// // A promise about the caller: it will discard every span outside
243 /// // row 0 anyway, so those cells need not be sorted or swept.
244 /// let mut rasterizer = Rasterizer::new();
245 /// rasterizer.keep_rows(0..1);
246 /// rasterizer.move_to(0.0, 0.0);
247 /// rasterizer.line_to(4.0, 0.0);
248 /// rasterizer.line_to(4.0, 2.0);
249 /// rasterizer.line_to(0.0, 2.0);
250 /// rasterizer.close_polygon();
251 ///
252 /// let mut rows = Vec::new();
253 /// rasterizer.sweep(FillRule::NonZero, Coverage::Exact, |_, _, y, _| rows.push(y));
254 /// // Byte-for-byte the spans an unrestricted rasterizer emits for row 0.
255 /// assert_eq!(rows, [0]);
256 /// ```
257 pub fn keep_rows(&mut self, rows: Range<i32>) {
258 self.keep = Some(rows);
259 }
260
261 /// Whether a row would survive [`Rasterizer::keep_rows`].
262 fn kept(&self, y: i32) -> bool {
263 self.keep.as_ref().is_none_or(|rows| rows.contains(&y))
264 }
265
266 /// Bank a cell, unless its row is one the caller discards.
267 fn bank(&mut self, cell: Cell) {
268 if !cell.is_empty() && self.kept(cell.y) {
269 self.store.push(cell);
270 }
271 }
272
273 /// Whether any boundary has been accumulated.
274 ///
275 /// A path entirely outside [`Rasterizer::keep_rows`]'s range is empty by
276 /// this test once it has been swept, which is what it means for the caller
277 /// to have said those rows do not matter.
278 ///
279 /// ```
280 /// use pdfrum_render::scanline::Rasterizer;
281 ///
282 /// let mut rasterizer = Rasterizer::new();
283 /// assert!(rasterizer.is_empty());
284 /// rasterizer.move_to(0.0, 0.0);
285 /// rasterizer.line_to(4.0, 2.0);
286 /// rasterizer.close_polygon();
287 /// assert!(!rasterizer.is_empty());
288 /// ```
289 #[must_use]
290 pub fn is_empty(&self) -> bool {
291 self.store.is_empty() && self.current.is_none_or(Cell::is_empty)
292 }
293
294 /// Start a new subpath at a device-space point.
295 ///
296 /// ```
297 /// use pdfrum_render::scanline::{Coverage, FillRule, Rasterizer};
298 ///
299 /// let mut rasterizer = Rasterizer::new();
300 /// rasterizer.move_to(0.0, 0.0);
301 /// rasterizer.line_to(4.0, 0.0);
302 /// rasterizer.line_to(4.0, 2.0);
303 /// rasterizer.line_to(0.0, 2.0);
304 /// rasterizer.close_polygon();
305 ///
306 /// let mut spans = Vec::new();
307 /// rasterizer.sweep(FillRule::NonZero, Coverage::Exact, |x, len, y, alpha| {
308 /// spans.push((x, y, len, alpha));
309 /// });
310 ///
311 /// assert_eq!(spans.len(), 2);
312 /// ```
313 pub fn move_to(&mut self, x: f64, y: f64) {
314 self.close_polygon();
315 let (x, y) = (to_subpixel(x), to_subpixel(y));
316 self.set_current(x >> SUBPIXEL_SHIFT, y >> SUBPIXEL_SHIFT);
317 self.x = x;
318 self.y = y;
319 self.start_x = x;
320 self.start_y = y;
321 self.open = true;
322 }
323
324 /// Extend the current subpath to a device-space point.
325 ///
326 /// ```
327 /// use pdfrum_render::scanline::Rasterizer;
328 ///
329 /// // A `line_to` with no `move_to` before it is ignored rather than
330 /// // treated as starting at the origin.
331 /// let mut rasterizer = Rasterizer::new();
332 /// rasterizer.line_to(4.0, 2.0);
333 /// assert!(rasterizer.is_empty());
334 /// ```
335 pub fn line_to(&mut self, x: f64, y: f64) {
336 if !self.open {
337 return;
338 }
339 let (x, y) = (to_subpixel(x), to_subpixel(y));
340 self.render_line(self.x, self.y, x, y);
341 self.x = x;
342 self.y = y;
343 }
344
345 /// Close the current subpath back to its start.
346 ///
347 /// A fill's boundary is closed by definition, so this runs implicitly
348 /// before every `move_to` and before the sweep: an unclosed subpath in the
349 /// input is filled as though the caller had closed it, which is what both
350 /// PDF's `f` and the oracle's scan converter do.
351 ///
352 /// ```
353 /// use pdfrum_render::scanline::{Coverage, FillRule, Rasterizer};
354 ///
355 /// // An unclosed subpath fills as though the caller had closed it,
356 /// // which is what `f` and the oracle both do -- so closing explicitly
357 /// // and leaving it open give the same spans.
358 /// let build = |close: bool| {
359 /// let mut rasterizer = Rasterizer::new();
360 /// rasterizer.move_to(0.0, 0.0);
361 /// rasterizer.line_to(4.0, 0.0);
362 /// rasterizer.line_to(4.0, 2.0);
363 /// rasterizer.line_to(0.0, 2.0);
364 /// if close {
365 /// rasterizer.close_polygon();
366 /// }
367 /// let mut spans = Vec::new();
368 /// rasterizer.sweep(FillRule::NonZero, Coverage::Exact, |x, len, y, a| {
369 /// spans.push((x, y, len, a));
370 /// });
371 /// spans
372 /// };
373 /// assert_eq!(build(true), build(false));
374 /// ```
375 pub fn close_polygon(&mut self) {
376 if !self.open {
377 return;
378 }
379 self.render_line(self.x, self.y, self.start_x, self.start_y);
380 self.x = self.start_x;
381 self.y = self.start_y;
382 self.open = false;
383 }
384
385 /// Add a whole flattened path, in device space.
386 ///
387 /// ```
388 /// use pdfrum_render::scanline::{Coverage, FillRule, Rasterizer};
389 ///
390 /// // Curves are flattened here; the rasterizer sees only segments.
391 /// use kurbo::Shape;
392 ///
393 /// let path = kurbo::Rect::new(0.0, 0.0, 4.0, 2.0).to_path(0.1);
394 /// let mut rasterizer = Rasterizer::new();
395 /// rasterizer.add_path(&path, 0.25);
396 ///
397 /// let mut spans = Vec::new();
398 /// rasterizer.sweep(FillRule::NonZero, Coverage::Exact, |x, len, y, a| {
399 /// spans.push((x, y, len, a));
400 /// });
401 /// assert_eq!(spans, [(0, 0, 4, 255), (0, 1, 4, 255)]);
402 /// ```
403 pub fn add_path(&mut self, path: &kurbo::BezPath, tolerance: f64) {
404 // `flatten` emits only MoveTo/LineTo/ClosePath, so the match below is
405 // total over what can actually arrive; the curve arms are unreachable
406 // and say so rather than silently dropping geometry.
407 kurbo::flatten(path.iter(), tolerance, |el| match el {
408 kurbo::PathEl::MoveTo(p) => self.move_to(p.x, p.y),
409 kurbo::PathEl::LineTo(p) => self.line_to(p.x, p.y),
410 kurbo::PathEl::ClosePath => self.close_polygon(),
411 kurbo::PathEl::QuadTo(..) | kurbo::PathEl::CurveTo(..) => {
412 debug_assert!(false, "kurbo::flatten emits no curves");
413 }
414 });
415 self.close_polygon();
416 }
417
418 /// Switch the cell being accumulated into, banking the old one.
419 fn set_current(&mut self, x: i32, y: i32) {
420 match self.current {
421 Some(cell) if cell.x == x && cell.y == y => {}
422 Some(cell) => {
423 self.bank(cell);
424 self.current = Some(Cell::at(x, y));
425 }
426 None => self.current = Some(Cell::at(x, y)),
427 }
428 }
429
430 /// Add cover and area to the cell being accumulated into.
431 fn add_cover(&mut self, cover: i32, area: i32) {
432 if let Some(cell) = self.current.as_mut() {
433 cell.cover = cell.cover.saturating_add(cover);
434 cell.area = cell.area.saturating_add(area);
435 }
436 }
437
438 /// Accumulate one segment that stays within a single scanline.
439 ///
440 /// `y1`/`y2` are *fractional* y within the row `ey`, so this integrates
441 /// the segment's horizontal travel across whatever pixels it crosses.
442 /// Splitting the general case into this makes the area integral a
443 /// trapezoid per pixel, which is where the exactness comes from: each
444 /// pixel's contribution is `(fx_in + fx_out) * dy`, twice the
445 /// trapezoid's area, with no sampling anywhere.
446 fn render_hline(&mut self, ey: i32, x1: i32, y1: i32, x2: i32, y2: i32) {
447 let ex1 = x1 >> SUBPIXEL_SHIFT;
448 let ex2 = x2 >> SUBPIXEL_SHIFT;
449 let fx1 = x1 & SUBPIXEL_MASK;
450 let fx2 = x2 & SUBPIXEL_MASK;
451
452 // Horizontal: no vertical travel, so no cover and no area — but the
453 // pen still moves, so the current cell follows it.
454 if y1 == y2 {
455 self.set_current(ex2, ey);
456 return;
457 }
458
459 // Within one pixel: one trapezoid, closed form.
460 if ex1 == ex2 {
461 let delta = y2 - y1;
462 self.add_cover(delta, (fx1 + fx2).saturating_mul(delta));
463 return;
464 }
465
466 // Crossing pixels: split the segment at each vertical pixel boundary
467 // and give each pixel its own trapezoid. The integer division carries
468 // its remainder forward (`mod`/`rem`) so the pieces sum to exactly the
469 // whole rather than drifting by a rounding step per pixel.
470 let (p, first, incr, dx) = if x2 > x1 {
471 (
472 (SUBPIXEL_SCALE - fx1) * (y2 - y1),
473 SUBPIXEL_SCALE,
474 1,
475 x2 - x1,
476 )
477 } else {
478 (fx1 * (y2 - y1), 0, -1, x1 - x2)
479 };
480 if dx == 0 {
481 return;
482 }
483
484 let mut delta = p / dx;
485 let mut modulo = p % dx;
486 if modulo < 0 {
487 delta -= 1;
488 modulo += dx;
489 }
490 self.add_cover(delta, (fx1 + first).saturating_mul(delta));
491
492 let mut ex = ex1 + incr;
493 self.set_current(ex, ey);
494 let mut y = y1 + delta;
495
496 if ex != ex2 {
497 let step = SUBPIXEL_SCALE * (y2 - y + delta);
498 let mut lift = step / dx;
499 let mut rem = step % dx;
500 if rem < 0 {
501 lift -= 1;
502 rem += dx;
503 }
504 modulo -= dx;
505 // A malformed segment cannot make this unbounded: `ex` steps
506 // toward `ex2` by one every iteration.
507 while ex != ex2 {
508 delta = lift;
509 modulo += rem;
510 if modulo >= 0 {
511 modulo -= dx;
512 delta += 1;
513 }
514 self.add_cover(delta, SUBPIXEL_SCALE.saturating_mul(delta));
515 y += delta;
516 ex += incr;
517 self.set_current(ex, ey);
518 }
519 }
520
521 delta = y2 - y;
522 self.add_cover(delta, (fx2 + SUBPIXEL_SCALE - first).saturating_mul(delta));
523 }
524
525 /// Accumulate one straight segment in subpixel coordinates.
526 ///
527 /// Splits the segment at every horizontal pixel boundary and hands each
528 /// piece to [`Rasterizer::render_hline`], so the whole integral is a sum
529 /// of per-pixel trapezoids.
530 fn render_line(&mut self, x1: i32, y1: i32, x2: i32, y2: i32) {
531 // A segment long enough to overflow the area products is bisected
532 // until it is not. The oracle does the same at the same threshold.
533 const DX_LIMIT: i32 = 16384 << SUBPIXEL_SHIFT;
534 let dx_total = x2.saturating_sub(x1);
535 if dx_total >= DX_LIMIT || dx_total <= -DX_LIMIT {
536 let cx = x1.saturating_add(x2) / 2;
537 let cy = y1.saturating_add(y2) / 2;
538 self.render_line(x1, y1, cx, cy);
539 self.render_line(cx, cy, x2, y2);
540 return;
541 }
542
543 let dy = y2 - y1;
544 let ey1 = y1 >> SUBPIXEL_SHIFT;
545 let ey2 = y2 >> SUBPIXEL_SHIFT;
546 let fy1 = y1 & SUBPIXEL_MASK;
547 let fy2 = y2 & SUBPIXEL_MASK;
548
549 // Within one scanline: the whole segment is one horizontal pass.
550 if ey1 == ey2 {
551 self.render_hline(ey1, x1, fy1, x2, fy2);
552 return;
553 }
554
555 // Exactly vertical: every scanline it crosses gets the same rectangle,
556 // so the loop writes a constant rather than dividing per row.
557 if dx_total == 0 {
558 let ex = x1 >> SUBPIXEL_SHIFT;
559 let two_fx = (x1 - (ex << SUBPIXEL_SHIFT)) << 1;
560 let (first, incr) = if dy < 0 { (0, -1) } else { (SUBPIXEL_SCALE, 1) };
561
562 let mut delta = first - fy1;
563 self.add_cover(delta, two_fx.saturating_mul(delta));
564 let mut ey = ey1 + incr;
565 self.set_current(ex, ey);
566
567 delta = first + first - SUBPIXEL_SCALE;
568 let area = two_fx.saturating_mul(delta);
569 while ey != ey2 {
570 if let Some(cell) = self.current.as_mut() {
571 cell.cover = delta;
572 cell.area = area;
573 }
574 ey += incr;
575 self.set_current(ex, ey);
576 }
577 delta = fy2 - SUBPIXEL_SCALE + first;
578 self.add_cover(delta, two_fx.saturating_mul(delta));
579 return;
580 }
581
582 // The general case: walk scanline by scanline, carrying the division
583 // remainder so the x positions of the row crossings sum exactly.
584 // The products are formed in `i64` because `(256 - fy) * dx` can
585 // exceed `i32` on a long near-horizontal segment; the quotients are
586 // bounded by `dx_total` and so fit back.
587 let (p, first, incr, dy_abs) = if dy < 0 {
588 (i64::from(fy1) * i64::from(dx_total), 0, -1, -dy)
589 } else {
590 (
591 i64::from(SUBPIXEL_SCALE - fy1) * i64::from(dx_total),
592 SUBPIXEL_SCALE,
593 1,
594 dy,
595 )
596 };
597 if dy_abs == 0 {
598 return;
599 }
600 let dy64 = i64::from(dy_abs);
601 let narrow = |v: i64| -> i32 { i32::try_from(v).unwrap_or(0) };
602
603 let mut delta = narrow(p / dy64);
604 let mut modulo = narrow(p % dy64);
605 if modulo < 0 {
606 delta -= 1;
607 modulo += dy_abs;
608 }
609
610 let mut x_from = x1.saturating_add(delta);
611 self.render_hline(ey1, x1, fy1, x_from, first);
612 let mut ey = ey1 + incr;
613 self.set_current(x_from >> SUBPIXEL_SHIFT, ey);
614
615 if ey != ey2 {
616 let step = i64::from(SUBPIXEL_SCALE) * i64::from(dx_total);
617 let mut lift = narrow(step / dy64);
618 let mut rem = narrow(step % dy64);
619 if rem < 0 {
620 lift -= 1;
621 rem += dy_abs;
622 }
623 modulo -= dy_abs;
624 while ey != ey2 {
625 delta = lift;
626 modulo += rem;
627 if modulo >= 0 {
628 modulo -= dy_abs;
629 delta += 1;
630 }
631 let x_to = x_from.saturating_add(delta);
632 self.render_hline(ey, x_from, SUBPIXEL_SCALE - first, x_to, first);
633 x_from = x_to;
634 ey += incr;
635 self.set_current(x_from >> SUBPIXEL_SHIFT, ey);
636 }
637 }
638 self.render_hline(ey, x_from, SUBPIXEL_SCALE - first, x2, fy2);
639 }
640
641 /// Bank the in-progress cell and sort, readying the sweep.
642 fn finish(&mut self) {
643 self.close_polygon();
644 if let Some(cell) = self.current.take() {
645 self.bank(cell);
646 }
647 self.store.sort();
648 }
649
650 /// Turn the accumulated cells into horizontal spans of coverage.
651 ///
652 /// `emit` receives `(x, len, alpha)` for each run of equal coverage, in
653 /// increasing y then increasing x. Only non-zero alphas are emitted, so a
654 /// consumer can blend unconditionally.
655 ///
656 /// Each row is resolved from its own cells: the running cover starts at
657 /// zero on every row and is not carried into the next, because a closed
658 /// boundary crosses each scanline an even number of times and so returns
659 /// the winding count to zero by the row's end. That is what makes
660 /// [`Rasterizer::keep_rows`] exact rather than approximate.
661 ///
662 /// ```
663 /// use pdfrum_render::scanline::{Coverage, FillRule, Rasterizer};
664 ///
665 /// let mut rasterizer = Rasterizer::new();
666 /// rasterizer.move_to(0.0, 0.0);
667 /// rasterizer.line_to(4.0, 0.0);
668 /// rasterizer.line_to(4.0, 2.0);
669 /// rasterizer.line_to(0.0, 2.0);
670 /// rasterizer.close_polygon();
671 ///
672 /// let mut spans = Vec::new();
673 /// rasterizer.sweep(FillRule::NonZero, Coverage::Exact, |x, len, y, alpha| {
674 /// spans.push((x, y, len, alpha));
675 /// });
676 ///
677 /// // Increasing y then increasing x, and only non-zero alphas, so a
678 /// // consumer can blend unconditionally.
679 /// assert_eq!(spans, [(0, 0, 4, 255), (0, 1, 4, 255)]);
680 /// ```
681 pub fn sweep(
682 &mut self,
683 rule: FillRule,
684 coverage: Coverage,
685 mut emit: impl FnMut(i32, i32, i32, u8),
686 ) {
687 self.finish();
688 for (y, row) in self.store.rows() {
689 sweep_row(row, y, rule, coverage, &mut emit);
690 }
691 }
692}
693
694/// How an integrated coverage becomes an alpha byte — AGG's three modes.
695///
696/// See [`AntiAlias`](crate::device::AntiAlias), whose three variants these
697/// mirror one for one; this is the integrator's own spelling of the same
698/// choice, so `scanline` does not depend on the device vocabulary.
699///
700/// ```
701/// use pdfrum_render::scanline::{Coverage, FillRule, Rasterizer};
702///
703/// // A half-covered pixel: `Exact` keeps the partial alpha, `Full`
704/// // pushes any touched pixel to 255.
705/// let alpha = |coverage| {
706/// let mut rasterizer = Rasterizer::new();
707/// rasterizer.move_to(0.0, 0.0);
708/// rasterizer.line_to(0.5, 0.0);
709/// rasterizer.line_to(0.5, 1.0);
710/// rasterizer.line_to(0.0, 1.0);
711/// rasterizer.close_polygon();
712/// let mut out = 0u8;
713/// rasterizer.sweep(FillRule::NonZero, coverage, |_, _, _, a| out = a);
714/// out
715/// };
716/// assert!(alpha(Coverage::Exact) < 255);
717/// assert_eq!(alpha(Coverage::Full), 255);
718/// ```
719#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
720pub enum Coverage {
721 /// Coverage becomes alpha: `min(255, floor(cov * 256))`.
722 #[default]
723 Exact,
724 /// `aliased_path`: the same coverage thresholded at its midpoint.
725 Thresholded,
726 /// `full_cover`: any touched pixel at 255, whatever its coverage.
727 Full,
728}
729
730/// Which winding rule decides a path's interior.
731///
732/// ```
733/// use pdfrum_render::scanline::{Coverage, FillRule, Rasterizer};
734///
735/// // Two nested squares wound the same way: non-zero fills the hole,
736/// // even-odd leaves it clear.
737/// let covered = |rule| {
738/// let mut rasterizer = Rasterizer::new();
739/// for (lo, hi) in [(0.0, 6.0), (2.0, 4.0)] {
740/// rasterizer.move_to(lo, lo);
741/// rasterizer.line_to(hi, lo);
742/// rasterizer.line_to(hi, hi);
743/// rasterizer.line_to(lo, hi);
744/// rasterizer.close_polygon();
745/// }
746/// let mut len = 0;
747/// rasterizer.sweep(rule, Coverage::Exact, |_, l, y, _| {
748/// if y == 3 {
749/// len += l;
750/// }
751/// });
752/// len
753/// };
754/// assert_eq!(covered(FillRule::NonZero), 6);
755/// assert_eq!(covered(FillRule::EvenOdd), 4);
756/// ```
757#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
758pub enum FillRule {
759 /// Non-zero winding: covered where the signed crossing count is not zero.
760 #[default]
761 NonZero,
762 /// Even-odd: covered where the crossing count is odd.
763 EvenOdd,
764}
765
766/// Sweep one scanline's cells into spans.
767///
768/// `row` is sorted by x. Cells sharing an x are merged; between two cells the
769/// running `cover` is constant, which is the span.
770fn sweep_row(
771 row: &[Cell],
772 y: i32,
773 rule: FillRule,
774 coverage: Coverage,
775 emit: &mut impl FnMut(i32, i32, i32, u8),
776) {
777 let mut cover = 0i32;
778 let mut i = 0usize;
779 while let Some(&first) = row.get(i) {
780 let x = first.x;
781 let mut area = first.area;
782 cover = cover.saturating_add(first.cover);
783 i += 1;
784 // Merge every cell at this x.
785 while let Some(&next) = row.get(i) {
786 if next.x != x {
787 break;
788 }
789 area = area.saturating_add(next.area);
790 cover = cover.saturating_add(next.cover);
791 i += 1;
792 }
793
794 // The boundary pixel itself: the running cover minus the area the
795 // boundary swept inside it. `cover << (SHIFT + 1)` is the full-pixel
796 // area in the same doubled units `area` is measured in.
797 let mut next_x = x;
798 if area != 0 {
799 let alpha = coverage_to_alpha(
800 (cover << (SUBPIXEL_SHIFT + 1)).saturating_sub(area),
801 rule,
802 coverage,
803 );
804 if alpha != 0 {
805 emit(x, 1, y, alpha);
806 }
807 next_x = x + 1;
808 }
809
810 // The interior run up to the next cell, at constant coverage.
811 if let Some(&next) = row.get(i)
812 && next.x > next_x
813 {
814 let alpha = coverage_to_alpha(cover << (SUBPIXEL_SHIFT + 1), rule, coverage);
815 if alpha != 0 {
816 emit(next_x, next.x - next_x, y, alpha);
817 }
818 }
819 }
820}
821
822/// The oracle's coverage-to-alpha mapping, measured and ported.
823///
824/// `area` is twice the covered area in subpixel units squared; shifting it
825/// down by `2*SHIFT + 1 - 8` renormalises it to 0..=256, and the clamp at 255
826/// is the only thing keeping 256 out. So the mapping is
827///
828/// ```text
829/// alpha = min(255, floor(coverage * 256))
830/// ```
831///
832/// — a **×256 scale clamped at the top**, not ×255, and truncating rather than
833/// rounding. It was measured before it was ported: a shallow-slope fill
834/// rendered through the oracle at 64x64 gives the edge ramp `223 159 95 31` on
835/// grays whose true coverages are exactly ⅛, ⅜, ⅝ and ⅞, which is that
836/// formula and no other. There is **no gamma table anywhere on this path**;
837/// the text path's is not reachable from here.
838///
839/// [`Coverage::Thresholded`] is the oracle's `aliased_path`, which does not
840/// turn the rasterizer off but thresholds the same coverage at the midpoint.
841/// That is why a hard-edged rect clip still goes through the identical
842/// integrator. [`Coverage::Full`] is `full_cover`, which keeps the same choice
843/// of covered pixels and discards the value: every pixel this integrator
844/// reaches at all is written opaque.
845#[must_use]
846pub(crate) fn coverage_to_alpha(area: i32, rule: FillRule, coverage: Coverage) -> u8 {
847 /// The renormalised coverage's full-cover value, 256.
848 const COVER_FULL: i32 = 1 << 8;
849 /// The largest alpha byte, 255 — the clamp that keeps 256 out.
850 const COVER_MASK: i32 = COVER_FULL - 1;
851
852 let mut cover = area >> (SUBPIXEL_SHIFT * 2 + 1 - 8);
853 if cover < 0 {
854 cover = cover.saturating_neg();
855 }
856 if rule == FillRule::EvenOdd {
857 // Fold the winding count into 0..=256: an odd number of crossings is
858 // covered, an even one is not, and the fold makes that continuous.
859 cover &= (COVER_FULL * 2) - 1;
860 if cover > COVER_FULL {
861 cover = COVER_FULL * 2 - cover;
862 }
863 }
864 match coverage {
865 Coverage::Exact => {}
866 Coverage::Thresholded => {
867 cover = if cover > COVER_MASK / 2 {
868 COVER_MASK
869 } else {
870 0
871 };
872 }
873 // Not a threshold: the test is against zero, so a pixel the span
874 // touches at all is opaque. Two cells that each half-cover a pixel
875 // both paint it, which is the seam suppression `full_cover` exists
876 // for; thresholding would drop it from both.
877 Coverage::Full => {
878 if cover > 0 {
879 cover = COVER_MASK;
880 }
881 }
882 }
883 if cover > COVER_MASK {
884 cover = COVER_MASK;
885 }
886 #[expect(
887 clippy::cast_possible_truncation,
888 clippy::cast_sign_loss,
889 reason = "the clamp above bounds cover to 0..=255"
890 )]
891 let byte = cover as u8;
892 byte
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 /// Fill a path into a width x height coverage plane.
900 ///
901 /// This is the *specification*: the rasterizer records every row the path
902 /// crosses and the callback discards the ones outside the plane, which is
903 /// what every consumer did before `keep_rows` existed.
904 /// [`banded_coverage`] is the same plane taken the cheap way, and
905 /// [`the_band_reproduces_the_unbanded_plane`] is what pins them together.
906 fn coverage(path: &kurbo::BezPath, w: i32, h: i32, rule: FillRule, mode: Coverage) -> Vec<u8> {
907 let cells = usize::try_from(w * h).expect("a test plane fits");
908 let mut out = vec![0u8; cells];
909 let mut raster = Rasterizer::new();
910 raster.add_path(path, 0.1);
911 raster.sweep(rule, mode, |x, len, y, alpha| {
912 if y < 0 || y >= h {
913 return;
914 }
915 for col in x.max(0)..(x + len).min(w) {
916 let Ok(index) = usize::try_from(y * w + col) else {
917 continue;
918 };
919 if let Some(slot) = out.get_mut(index) {
920 *slot = alpha;
921 }
922 }
923 });
924 out
925 }
926
927 /// The same plane as [`coverage`], with the rasterizer told the rows.
928 ///
929 /// The callback is byte-for-byte [`coverage`]'s, kept rather than
930 /// simplified: the point of the comparison is that the *only* difference
931 /// between the two is where the discard happens.
932 fn banded_coverage(
933 path: &kurbo::BezPath,
934 w: i32,
935 h: i32,
936 rule: FillRule,
937 mode: Coverage,
938 ) -> Vec<u8> {
939 let cells = usize::try_from(w * h).expect("a test plane fits");
940 let mut out = vec![0u8; cells];
941 let mut raster = Rasterizer::new();
942 raster.keep_rows(0..h);
943 raster.add_path(path, 0.1);
944 raster.sweep(rule, mode, |x, len, y, alpha| {
945 if y < 0 || y >= h {
946 return;
947 }
948 for col in x.max(0)..(x + len).min(w) {
949 let Ok(index) = usize::try_from(y * w + col) else {
950 continue;
951 };
952 if let Some(slot) = out.get_mut(index) {
953 *slot = alpha;
954 }
955 }
956 });
957 out
958 }
959
960 /// How many cells a path banks under a row range — the cost the band cuts.
961 fn banked(path: &kurbo::BezPath, rows: Option<core::ops::Range<i32>>) -> usize {
962 let mut raster = Rasterizer::new();
963 if let Some(rows) = rows {
964 raster.keep_rows(rows);
965 }
966 raster.add_path(path, 0.1);
967 let mut cells = 0usize;
968 raster.sweep(FillRule::NonZero, Coverage::Exact, |_, _, _, _| {});
969 for (_, row) in raster.store.rows() {
970 cells += row.len();
971 }
972 cells
973 }
974
975 fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> kurbo::BezPath {
976 let mut p = kurbo::BezPath::new();
977 p.move_to((x0, y0));
978 p.line_to((x1, y0));
979 p.line_to((x1, y1));
980 p.line_to((x0, y1));
981 p.close_path();
982 p
983 }
984
985 #[test]
986 fn a_whole_pixel_rect_is_fully_covered() {
987 let cov = coverage(
988 &rect(1.0, 1.0, 3.0, 3.0),
989 4,
990 4,
991 FillRule::NonZero,
992 Coverage::Exact,
993 );
994 assert_eq!(cov.first().copied(), Some(0), "outside");
995 assert_eq!(cov.get(5).copied(), Some(255), "inside (1,1)");
996 assert_eq!(cov.get(10).copied(), Some(255), "inside (2,2)");
997 assert_eq!(cov.get(15).copied(), Some(0), "outside (3,3)");
998 }
999
1000 #[test]
1001 fn a_half_covered_pixel_is_exactly_half() {
1002 // The whole point of an analytic rasterizer: half a pixel is 128,
1003 // not the nearest of seventeen supersampled levels.
1004 let cov = coverage(
1005 &rect(0.0, 0.0, 0.5, 1.0),
1006 1,
1007 1,
1008 FillRule::NonZero,
1009 Coverage::Exact,
1010 );
1011 assert_eq!(cov.first().copied(), Some(128));
1012 }
1013
1014 #[test]
1015 fn the_alpha_mapping_is_times_256_truncating() {
1016 // The measured ramp: coverages of 1/8, 3/8, 5/8, 7/8 over a pixel
1017 // give 32, 96, 160, 224 -- floor(cov * 256), not round(cov * 255).
1018 for (num, expected) in [(1, 32u8), (3, 96), (5, 160), (7, 224)] {
1019 let frac = f64::from(num) / 8.0;
1020 let cov = coverage(
1021 &rect(0.0, 0.0, frac, 1.0),
1022 1,
1023 1,
1024 FillRule::NonZero,
1025 Coverage::Exact,
1026 );
1027 assert_eq!(
1028 cov.first().copied(),
1029 Some(expected),
1030 "coverage {num}/8 must map to {expected}"
1031 );
1032 }
1033 }
1034
1035 #[test]
1036 fn full_coverage_clamps_to_255_not_256() {
1037 // `floor(1.0 * 256)` is 256, which does not fit a byte; the clamp is
1038 // the only thing keeping it out, and it is why full cover is 255.
1039 assert_eq!(
1040 coverage_to_alpha(1 << 17, FillRule::NonZero, Coverage::Exact),
1041 255
1042 );
1043 }
1044
1045 #[test]
1046 fn even_odd_punches_out_an_overlap() {
1047 // Two concentric squares: even-odd leaves the inner one empty where
1048 // non-zero would fill it.
1049 let mut p = rect(0.0, 0.0, 6.0, 6.0);
1050 p.extend(rect(2.0, 2.0, 4.0, 4.0).iter());
1051 let eo = coverage(&p, 6, 6, FillRule::EvenOdd, Coverage::Exact);
1052 let nz = coverage(&p, 6, 6, FillRule::NonZero, Coverage::Exact);
1053 // (3,3) is inside both squares.
1054 assert_eq!(eo.get(3 * 6 + 3).copied(), Some(0), "even-odd punches out");
1055 assert_eq!(nz.get(3 * 6 + 3).copied(), Some(255), "non-zero fills");
1056 // (1,1) is inside only the outer one, so both fill it.
1057 assert_eq!(eo.get(6 + 1).copied(), Some(255));
1058 assert_eq!(nz.get(6 + 1).copied(), Some(255));
1059 }
1060
1061 #[test]
1062 fn aliasing_thresholds_at_the_midpoint() {
1063 // `aa = false` is the oracle's aliased_path: it thresholds the same
1064 // analytic coverage rather than turning the integrator off, so a
1065 // just-over-half pixel is solid and a just-under-half one is empty.
1066 let over = coverage(
1067 &rect(0.0, 0.0, 0.6, 1.0),
1068 1,
1069 1,
1070 FillRule::NonZero,
1071 Coverage::Thresholded,
1072 );
1073 let under = coverage(
1074 &rect(0.0, 0.0, 0.4, 1.0),
1075 1,
1076 1,
1077 FillRule::NonZero,
1078 Coverage::Thresholded,
1079 );
1080 assert_eq!(over.first().copied(), Some(255));
1081 assert_eq!(under.first().copied(), Some(0));
1082 }
1083
1084 #[test]
1085 fn full_cover_tests_against_zero_rather_than_the_midpoint() {
1086 // `full_cover` keeps the integrator's choice of covered pixels and
1087 // discards the coverage *value*, so the test is `> 0` and not
1088 // `> 127`. The distinction is the whole point: two Coons cells that
1089 // each cover a shared pixel by 40% both paint it here, where
1090 // thresholding drops it from both and leaves a white pin-hole along
1091 // every internal seam of a subdivided patch.
1092 for frac in [0.4, 0.6, 0.05] {
1093 let cov = coverage(
1094 &rect(0.0, 0.0, frac, 1.0),
1095 1,
1096 1,
1097 FillRule::NonZero,
1098 Coverage::Full,
1099 );
1100 assert_eq!(
1101 cov.first().copied(),
1102 Some(255),
1103 "coverage {frac} is non-zero, so full_cover writes it opaque"
1104 );
1105 }
1106 // A pixel the path does not reach at all stays empty — the mode does
1107 // not flood, it only flattens.
1108 let miss = coverage(
1109 &rect(2.0, 2.0, 3.0, 3.0),
1110 1,
1111 1,
1112 FillRule::NonZero,
1113 Coverage::Full,
1114 );
1115 assert_eq!(miss.first().copied(), Some(0));
1116 }
1117
1118 #[test]
1119 fn an_exact_45_degree_edge_halves_every_boundary_pixel() {
1120 // A diagonal through pixel corners cuts each boundary pixel into two
1121 // equal triangles, so 128 is the *only* partial level and any other
1122 // value would be a bug. This is the case a supersampler also gets
1123 // right, which is why it is not the one the next test measures.
1124 let mut p = kurbo::BezPath::new();
1125 p.move_to((0.0, 0.0));
1126 p.line_to((32.0, 0.0));
1127 p.line_to((0.0, 32.0));
1128 p.close_path();
1129 let cov = coverage(&p, 32, 32, FillRule::NonZero, Coverage::Exact);
1130 let mut levels: Vec<u8> = cov
1131 .iter()
1132 .copied()
1133 .filter(|&a| a != 0 && a != 255)
1134 .collect();
1135 levels.sort_unstable();
1136 levels.dedup();
1137 assert_eq!(levels, vec![128], "a 45 degree edge halves its pixels");
1138 }
1139
1140 #[test]
1141 fn a_shallow_edge_has_more_than_seventeen_levels() {
1142 // The band this backend exists to close. `tiny-skia` supersamples at
1143 // four subsamples per axis, so a shallow edge can only take one of
1144 // seventeen coverages and a run of pixels along it steps in jumps of
1145 // sixteen counts. An analytic integrator produces the continuum the
1146 // geometry actually implies, which is what the oracle writes.
1147 let mut p = kurbo::BezPath::new();
1148 p.move_to((0.0, 0.0));
1149 p.line_to((64.0, 0.0));
1150 p.line_to((64.0, 5.0));
1151 p.close_path();
1152 let cov = coverage(&p, 64, 8, FillRule::NonZero, Coverage::Exact);
1153 let mut levels: Vec<u8> = cov
1154 .iter()
1155 .copied()
1156 .filter(|&a| a != 0 && a != 255)
1157 .collect();
1158 levels.sort_unstable();
1159 levels.dedup();
1160 assert!(
1161 levels.len() > 17,
1162 "only {} partial levels along a shallow edge",
1163 levels.len()
1164 );
1165 }
1166
1167 #[test]
1168 fn winding_direction_does_not_change_coverage() {
1169 // A clockwise and a counter-clockwise square fill identically under
1170 // non-zero: the cover sum's sign is taken as magnitude.
1171 let cw = coverage(
1172 &rect(0.0, 0.0, 4.0, 4.0),
1173 4,
1174 4,
1175 FillRule::NonZero,
1176 Coverage::Exact,
1177 );
1178 let mut ccw = kurbo::BezPath::new();
1179 ccw.move_to((0.0, 0.0));
1180 ccw.line_to((0.0, 4.0));
1181 ccw.line_to((4.0, 4.0));
1182 ccw.line_to((4.0, 0.0));
1183 ccw.close_path();
1184 assert_eq!(cw, coverage(&ccw, 4, 4, FillRule::NonZero, Coverage::Exact));
1185 }
1186
1187 #[test]
1188 fn an_unclosed_subpath_fills_as_though_closed() {
1189 // PDF's `f` closes every open subpath before filling, and so does the
1190 // oracle's scan converter.
1191 let mut open = kurbo::BezPath::new();
1192 open.move_to((0.0, 0.0));
1193 open.line_to((4.0, 0.0));
1194 open.line_to((4.0, 4.0));
1195 open.line_to((0.0, 4.0));
1196 let closed = coverage(
1197 &rect(0.0, 0.0, 4.0, 4.0),
1198 4,
1199 4,
1200 FillRule::NonZero,
1201 Coverage::Exact,
1202 );
1203 assert_eq!(
1204 coverage(&open, 4, 4, FillRule::NonZero, Coverage::Exact),
1205 closed
1206 );
1207 }
1208
1209 #[test]
1210 fn coordinates_truncate_toward_zero() {
1211 // `int(c * 256)`, not `round`: the oracle's own conversion.
1212 assert_eq!(to_subpixel(1.0), 256);
1213 assert_eq!(to_subpixel(1.5), 384);
1214 assert_eq!(to_subpixel(-1.5), -384);
1215 // 0.999... truncates down rather than rounding up to the next pixel.
1216 assert_eq!(to_subpixel(0.999), 255);
1217 }
1218
1219 #[test]
1220 fn a_non_finite_coordinate_becomes_zero_rather_than_panicking() {
1221 assert_eq!(to_subpixel(f64::NAN), 0);
1222 assert_eq!(to_subpixel(f64::INFINITY), 0);
1223 }
1224
1225 #[test]
1226 fn an_empty_path_sweeps_nothing() {
1227 let mut r = Rasterizer::new();
1228 r.add_path(&kurbo::BezPath::new(), 0.1);
1229 let mut spans = 0;
1230 r.sweep(FillRule::NonZero, Coverage::Exact, |_, _, _, _| spans += 1);
1231 assert_eq!(spans, 0);
1232 }
1233
1234 #[test]
1235 fn a_line_to_without_a_move_to_is_ignored() {
1236 let mut r = Rasterizer::new();
1237 r.line_to(4.0, 4.0);
1238 assert!(r.is_empty());
1239 }
1240
1241 #[test]
1242 fn total_coverage_matches_the_area_of_a_slanted_quad() {
1243 // The integral property an analytic rasterizer must have: summed
1244 // coverage equals the geometric area, to within the byte quantisation.
1245 let mut p = kurbo::BezPath::new();
1246 p.move_to((2.0, 1.0));
1247 p.line_to((14.0, 3.0));
1248 p.line_to((13.0, 14.0));
1249 p.line_to((1.0, 12.0));
1250 p.close_path();
1251 let cov = coverage(&p, 16, 16, FillRule::NonZero, Coverage::Exact);
1252 let painted: f64 = cov.iter().map(|&a| f64::from(a) / 256.0).sum();
1253 // Shoelace over the four vertices.
1254 let pts = [(2.0, 1.0), (14.0, 3.0), (13.0, 14.0), (1.0, 12.0)];
1255 let mut area = 0.0f64;
1256 for i in 0..4 {
1257 let (Some(&(x0, y0)), Some(&(x1, y1))) = (pts.get(i), pts.get((i + 1) % 4)) else {
1258 continue;
1259 };
1260 area += x0 * y1 - x1 * y0;
1261 }
1262 let area = (area / 2.0).abs();
1263 assert!(
1264 (painted - area).abs() < 1.0,
1265 "painted {painted:.3} vs geometric {area:.3}"
1266 );
1267 }
1268
1269 /// The paths a clip on a real page produces when its geometry leaves the
1270 /// target: above it, below it, both, and out to the engine's own clamp.
1271 ///
1272 /// The last is the case §18.3 measured — `hard_clip`'s +/-32000 is a
1273 /// deliberate artefact of the oracle's 16-bit truncation, so a path
1274 /// carrying 32000 device units of height reaches the integrator by design
1275 /// and must be answered rather than avoided.
1276 fn off_target_paths() -> Vec<(&'static str, kurbo::BezPath)> {
1277 vec![
1278 ("above", rect(2.0, -900.0, 6.0, 5.0)),
1279 ("below", rect(2.0, 3.0, 6.0, 900.0)),
1280 ("both", rect(2.0, -900.0, 6.0, 900.0)),
1281 ("clamped", rect(2.0, -32000.0, 6.0, 32000.0)),
1282 ("clamped-slanted", {
1283 let mut p = kurbo::BezPath::new();
1284 p.move_to((1.5, -32000.0));
1285 p.line_to((6.5, 32000.0));
1286 p.line_to((7.5, 32000.0));
1287 p.line_to((2.5, -32000.0));
1288 p.close_path();
1289 p
1290 }),
1291 ("left", rect(-32000.0, 2.0, 3.5, 6.0)),
1292 ("right", rect(4.5, 2.0, 32000.0, 6.0)),
1293 ("every-side", rect(-32000.0, -32000.0, 32000.0, 32000.0)),
1294 ]
1295 }
1296
1297 #[test]
1298 fn the_band_reproduces_the_unbanded_plane() {
1299 // The invariant the whole change rests on: telling the rasterizer
1300 // which rows survive changes no byte on a row that does. Both fill
1301 // rules and all three coverage modes, because the band is upstream of
1302 // every one of them.
1303 for (name, path) in off_target_paths() {
1304 for rule in [FillRule::NonZero, FillRule::EvenOdd] {
1305 for mode in [Coverage::Exact, Coverage::Thresholded, Coverage::Full] {
1306 let spec = coverage(&path, 8, 8, rule, mode);
1307 let banded = banded_coverage(&path, 8, 8, rule, mode);
1308 assert_eq!(spec, banded, "{name} under {rule:?}/{mode:?}");
1309 }
1310 }
1311 }
1312 }
1313
1314 #[test]
1315 fn a_path_that_stays_inside_the_band_is_untouched_by_it() {
1316 // The control for the test above: a path with nothing to discard must
1317 // still agree, or the band would be hiding a difference behind the
1318 // rows it drops.
1319 let path = rect(1.25, 1.75, 6.5, 5.5);
1320 for rule in [FillRule::NonZero, FillRule::EvenOdd] {
1321 for mode in [Coverage::Exact, Coverage::Thresholded, Coverage::Full] {
1322 assert_eq!(
1323 coverage(&path, 8, 8, rule, mode),
1324 banded_coverage(&path, 8, 8, rule, mode),
1325 "{rule:?}/{mode:?}"
1326 );
1327 }
1328 }
1329 }
1330
1331 #[test]
1332 fn the_band_keeps_the_targets_last_row() {
1333 // A half-open range read as closed, or closed as half-open, moves the
1334 // boundary by one and the last row is where that shows. The path is
1335 // painted across rows 6 and 7 of an eight-row plane, so row 7 is both
1336 // the last kept row and the one an off-by-one drops.
1337 let path = rect(1.0, 6.0, 7.0, 8.0);
1338 let banded = banded_coverage(&path, 8, 8, FillRule::NonZero, Coverage::Exact);
1339 assert_eq!(banded.get(8 * 7 + 3).copied(), Some(255), "the last row");
1340 assert_eq!(
1341 coverage(&path, 8, 8, FillRule::NonZero, Coverage::Exact),
1342 banded
1343 );
1344 }
1345
1346 #[test]
1347 fn the_band_keeps_the_targets_first_row() {
1348 // The other end, for the same reason.
1349 let path = rect(1.0, -4.0, 7.0, 1.0);
1350 let banded = banded_coverage(&path, 8, 8, FillRule::NonZero, Coverage::Exact);
1351 assert_eq!(banded.first().copied(), Some(0), "column 0 is outside");
1352 assert_eq!(banded.get(3).copied(), Some(255), "the first row");
1353 assert_eq!(
1354 coverage(&path, 8, 8, FillRule::NonZero, Coverage::Exact),
1355 banded
1356 );
1357 }
1358
1359 #[test]
1360 fn the_band_drops_the_rows_it_says_it_drops() {
1361 // The measurement the change exists for. A path 64 000 rows tall over
1362 // an eight-row target banks tens of thousands of cells unbanded and a
1363 // handful banded, which is the 530 073 rows of a real render in
1364 // miniature.
1365 let path = rect(2.0, -32000.0, 6.0, 32000.0);
1366 let unbanded = banked(&path, None);
1367 let banded = banked(&path, Some(0..8));
1368 assert!(
1369 unbanded > 60_000,
1370 "the unbanded store holds a cell per crossed row, got {unbanded}"
1371 );
1372 assert!(
1373 banded <= 32,
1374 "the banded store holds only the target's rows, got {banded}"
1375 );
1376 }
1377
1378 #[test]
1379 fn a_band_is_only_about_rows() {
1380 // Stated as a test because it is a limit of the fix rather than an
1381 // oversight: a path 64 000 columns wide costs its cells either way,
1382 // because the cells a segment banks are bounded by the pixels it
1383 // crosses and a horizontal one crosses them all on a single row.
1384 let path = rect(-32000.0, 2.0, 32000.0, 6.0);
1385 assert_eq!(banked(&path, None), banked(&path, Some(0..8)));
1386 }
1387
1388 #[test]
1389 fn a_band_outside_the_path_leaves_nothing() {
1390 // The degenerate end of the range: a target the path misses entirely
1391 // paints nothing, which is what the unbanded spelling's callback did
1392 // by returning on every row.
1393 let path = rect(2.0, -900.0, 6.0, -100.0);
1394 assert_eq!(
1395 banded_coverage(&path, 8, 8, FillRule::NonZero, Coverage::Exact),
1396 vec![0u8; 64]
1397 );
1398 assert_eq!(banked(&path, Some(0..8)), 0);
1399 }
1400
1401 #[test]
1402 fn every_rows_cover_returns_to_zero_by_its_end() {
1403 // The proof `keep_rows` rests on, checked rather than argued: the
1404 // sweep starts each row's running cover at zero because a closed
1405 // boundary crosses a scanline an even number of times, so the cover
1406 // one row leaves behind is nothing the next one needs. If it were
1407 // not, dropping a row would corrupt the rows below it.
1408 for (name, path) in off_target_paths() {
1409 let mut raster = Rasterizer::new();
1410 raster.add_path(&path, 0.1);
1411 raster.sweep(FillRule::NonZero, Coverage::Exact, |_, _, _, _| {});
1412 for (y, row) in raster.store.rows() {
1413 let cover: i32 = row.iter().map(|c| c.cover).sum();
1414 assert_eq!(cover, 0, "{name} leaves cover on row {y}");
1415 }
1416 }
1417 }
1418
1419 #[test]
1420 fn a_reset_keeps_the_band_the_caller_set() {
1421 // `AggDevice` sets the band once per sweep, but `reset` is what a
1422 // reused rasterizer calls between paths and it must not quietly widen
1423 // the range back to everything.
1424 let mut raster = Rasterizer::new();
1425 raster.keep_rows(0..8);
1426 raster.add_path(&rect(2.0, -900.0, 6.0, 900.0), 0.1);
1427 raster.sweep(FillRule::NonZero, Coverage::Exact, |_, _, _, _| {});
1428 raster.reset();
1429 raster.add_path(&rect(2.0, -900.0, 6.0, 900.0), 0.1);
1430 raster.sweep(FillRule::NonZero, Coverage::Exact, |_, _, _, _| {});
1431 let cells: usize = raster.store.rows().map(|(_, row)| row.len()).sum();
1432 assert!(cells <= 32, "the band survives a reset, got {cells}");
1433 }
1434}