pdfrum_render/pixmap.rs
1//! The two raster buffers the engine and the backends share: [`Pixmap`], a
2//! premultiplied RGBA8 image, and [`AlphaMask`], an 8-bit coverage plane.
3//!
4//! **Part of the backend seam.** Both are in
5//! [`RasterBackend`](crate::RasterBackend)'s own signatures; the alpha
6//! arithmetic beside them — [`mul255`], [`alpha_merge`],
7//! [`alpha_byte_truncating`] — is here because a backend must round its
8//! alpha the way the oracle rounds it. The oracle's own buffers are
9//! *straight* (non-premultiplied) and ours are premultiplied, and the
10//! conversions that forces are owned here rather than in a backend, so the
11//! engine's arithmetic stays bit-identical whichever rasterizer is in use.
12
13use crate::color::rgb_to_gray;
14
15/// A premultiplied RGBA8 image: four bytes per pixel, row-major, no padding.
16///
17/// Premultiplied means each colour byte has already been scaled by the alpha
18/// byte, which is what both rasterizers produce and what makes a source-over
19/// blit a plain weighted sum. [`Pixmap::to_straight_bgra`] undoes it at the
20/// output boundary, because the oracle's PNGs and MD5s are taken over a
21/// straight-alpha BGRA buffer.
22///
23/// ```
24/// use pdfrum_render::Pixmap;
25///
26/// let red = Pixmap::filled(2, 2, peniko::Color::from_rgba8(255, 0, 0, 255));
27/// assert_eq!((red.width(), red.height()), (2, 2));
28///
29/// // Premultiplied on the inside; straight at the output boundary.
30/// assert_eq!(red.pixel(0, 0), Some([255, 0, 0, 255]));
31/// assert_eq!(&red.to_straight_rgb()[..3], &[255, 0, 0]);
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Pixmap {
35 width: u32,
36 height: u32,
37 data: Vec<u8>,
38}
39
40impl Pixmap {
41 /// A fully transparent pixmap of the given size.
42 ///
43 /// A zero in either axis is legal and yields an empty buffer, because the
44 /// engine reaches this with clipped-away geometry all the time.
45 ///
46 /// A size whose buffer cannot be allocated yields an **empty** pixmap
47 /// rather than ending the process — see [`Pixmap::try_new`], which is this
48 /// without the fallback. Check [`Pixmap::width`] rather than assuming the
49 /// size you asked for.
50 ///
51 /// ```
52 /// use pdfrum_render::Pixmap;
53 ///
54 /// let empty = Pixmap::new(2, 2);
55 /// assert_eq!(empty.pixel(0, 0), Some([0, 0, 0, 0]));
56 /// // A zero in either axis is legal: clipped-away geometry reaches this.
57 /// assert!(Pixmap::new(0, 5).data().is_empty());
58 /// // So is a size no allocator can meet; it comes back empty.
59 /// assert_eq!(Pixmap::new(131_071, 131_071).width(), 0);
60 /// ```
61 #[must_use]
62 pub fn new(width: u32, height: u32) -> Self {
63 Self::try_new(width, height).unwrap_or(Self {
64 width: 0,
65 height: 0,
66 data: Vec::new(),
67 })
68 }
69
70 /// A fully transparent pixmap of the given size, or `None` when the buffer
71 /// it needs cannot be allocated.
72 ///
73 /// `width * height * 4` is the whole of the request, and both axes can
74 /// come from a file: an image declares its own `/Width` and `/Height`, and
75 /// the largest pair the sample unpacker accepts asks for 68 GB. Two
76 /// refusals, in order: the area cap first
77 /// ([`pdfrum_page::image_area_is_workable`]), because Linux overcommit
78 /// lets `try_reserve_exact` succeed for tens of gigabytes and the
79 /// `resize` below then zeros them; then the fallible reserve, for a size
80 /// inside the cap that this allocator still cannot meet. A `Vec` that
81 /// cannot be grown calls `handle_alloc_error`, which **aborts** — no
82 /// unwind, so no `catch_unwind` above it helps and no `Result` can carry
83 /// it.
84 ///
85 /// [`Pixmap::new`] is this with the answer taken as an empty pixmap, which
86 /// is what a caller that has no way to report the failure wants.
87 ///
88 /// ```
89 /// use pdfrum_render::Pixmap;
90 ///
91 /// assert!(Pixmap::try_new(2, 2).is_some());
92 /// // Nothing has this much memory, and asking for it must not end the
93 /// // process.
94 /// assert!(Pixmap::try_new(131_071, 131_071).is_none());
95 /// ```
96 #[must_use]
97 pub fn try_new(width: u32, height: u32) -> Option<Self> {
98 if !pdfrum_page::image_area_is_workable(width, height) {
99 return None;
100 }
101 let len = (width as usize)
102 .saturating_mul(height as usize)
103 .saturating_mul(4);
104 let mut data = Vec::new();
105 data.try_reserve_exact(len).ok()?;
106 data.resize(len, 0);
107 Some(Self {
108 width,
109 height,
110 data,
111 })
112 }
113
114 /// A pixmap of the given size, every pixel set to `color`.
115 ///
116 /// ```
117 /// use pdfrum_render::Pixmap;
118 ///
119 /// let red = Pixmap::filled(2, 1, peniko::Color::from_rgba8(255, 0, 0, 255));
120 /// assert_eq!(red.data(), &[255, 0, 0, 255, 255, 0, 0, 255]);
121 /// ```
122 #[must_use]
123 pub fn filled(width: u32, height: u32, color: peniko::Color) -> Self {
124 // Built in one pass rather than `new` + `fill`: a zeroing allocation
125 // followed by a full overwrite writes every byte of a page-sized
126 // buffer twice, and at A4/150 DPI that is 8.7 MB of `memset` per
127 // target with nothing to show for it. The doubling `extend_from_within`
128 // keeps the write bulk — a per-pixel loop here measured *slower* than
129 // the two `memset`s it replaced.
130 let len = (width as usize)
131 .saturating_mul(height as usize)
132 .saturating_mul(4);
133 let px = premultiply(color);
134 let mut data = Vec::new();
135 if !pdfrum_page::image_area_is_workable(width, height)
136 || data.try_reserve_exact(len).is_err()
137 {
138 // As `new`: a buffer the allocator cannot meet comes back empty
139 // rather than aborting. Both axes can come from a file.
140 return Self {
141 width: 0,
142 height: 0,
143 data,
144 };
145 }
146 if len >= 4 {
147 data.extend_from_slice(&px);
148 while data.len() * 2 <= len {
149 data.extend_from_within(..);
150 }
151 let rest = len - data.len();
152 data.extend_from_within(..rest);
153 }
154 Self {
155 width,
156 height,
157 data,
158 }
159 }
160
161 /// Wrap an existing premultiplied RGBA8 buffer.
162 ///
163 /// Returns `None` when `data` is not exactly `width * height * 4` bytes.
164 ///
165 /// ```
166 /// use pdfrum_render::Pixmap;
167 ///
168 /// assert!(Pixmap::from_vec(1, 1, vec![255, 0, 0, 255]).is_some());
169 /// // Not exactly `width * height * 4` bytes.
170 /// assert!(Pixmap::from_vec(1, 1, vec![255, 0, 0]).is_none());
171 /// ```
172 #[must_use]
173 pub fn from_vec(width: u32, height: u32, data: Vec<u8>) -> Option<Self> {
174 let len = (width as usize)
175 .checked_mul(height as usize)?
176 .checked_mul(4)?;
177 (data.len() == len).then_some(Self {
178 width,
179 height,
180 data,
181 })
182 }
183
184 /// The pixmap's width in pixels.
185 ///
186 /// ```
187 /// use pdfrum_render::Pixmap;
188 ///
189 /// assert_eq!(Pixmap::new(3, 2).width(), 3);
190 /// ```
191 #[must_use]
192 pub fn width(&self) -> u32 {
193 self.width
194 }
195
196 /// The pixmap's height in pixels.
197 ///
198 /// ```
199 /// use pdfrum_render::Pixmap;
200 ///
201 /// assert_eq!(Pixmap::new(3, 2).height(), 2);
202 /// ```
203 #[must_use]
204 pub fn height(&self) -> u32 {
205 self.height
206 }
207
208 /// The premultiplied RGBA8 bytes, row-major.
209 ///
210 /// ```
211 /// use pdfrum_render::Pixmap;
212 ///
213 /// // Four bytes per pixel, row-major, no padding.
214 /// assert_eq!(Pixmap::new(2, 2).data().len(), 16);
215 /// ```
216 #[must_use]
217 pub fn data(&self) -> &[u8] {
218 &self.data
219 }
220
221 /// The premultiplied RGBA8 bytes, mutably.
222 ///
223 /// ```
224 /// use pdfrum_render::Pixmap;
225 ///
226 /// let mut p = Pixmap::new(1, 1);
227 /// p.data_mut().copy_from_slice(&[255, 0, 0, 255]);
228 /// assert_eq!(p.pixel(0, 0), Some([255, 0, 0, 255]));
229 /// ```
230 pub fn data_mut(&mut self) -> &mut [u8] {
231 &mut self.data
232 }
233
234 /// Consume the pixmap, yielding its premultiplied RGBA8 bytes.
235 ///
236 /// ```
237 /// use pdfrum_render::Pixmap;
238 ///
239 /// assert_eq!(Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 255)).into_data(), vec![255, 0, 0, 255]);
240 /// ```
241 #[must_use]
242 pub fn into_data(self) -> Vec<u8> {
243 self.data
244 }
245
246 /// A sub-rectangle of this pixmap.
247 ///
248 /// Bytes outside this pixmap are left transparent. Used to lift a
249 /// non-isolated group's backdrop out of a parent snapshot without a
250 /// second GPU round trip's worth of host pixels.
251 ///
252 /// ```
253 /// use pdfrum_render::Pixmap;
254 ///
255 /// let mut src = Pixmap::new(4, 2);
256 /// src.set_pixel(1, 0, [1, 2, 3, 255]);
257 /// let out = src.cropped(1, 0, 2, 1);
258 /// assert_eq!(out.pixel(0, 0), Some([1, 2, 3, 255]));
259 /// assert_eq!(out.pixel(1, 0), Some([0, 0, 0, 0]));
260 /// ```
261 #[must_use]
262 pub fn cropped(&self, x: u32, y: u32, width: u32, height: u32) -> Self {
263 let mut out = Self::new(width, height);
264 if width == 0 || height == 0 || x >= self.width || y >= self.height {
265 return out;
266 }
267 let copy_w = width.min(self.width - x);
268 let copy_h = height.min(self.height - y);
269 let n = (copy_w as usize).saturating_mul(4);
270 for row in 0..copy_h {
271 let src_i = ((y.saturating_add(row) as usize) * (self.width as usize) + (x as usize))
272 .saturating_mul(4);
273 let dst_i = (row as usize)
274 .saturating_mul(width as usize)
275 .saturating_mul(4);
276 if let (Some(src), Some(dst)) = (
277 self.data.get(src_i..src_i.saturating_add(n)),
278 out.data.get_mut(dst_i..dst_i.saturating_add(n)),
279 ) {
280 dst.copy_from_slice(src);
281 }
282 }
283 out
284 }
285
286 /// The premultiplied RGBA bytes of one pixel, or `None` when out of range.
287 ///
288 /// ```
289 /// use pdfrum_render::Pixmap;
290 ///
291 /// let red = Pixmap::filled(2, 2, peniko::Color::from_rgba8(255, 0, 0, 255));
292 /// assert_eq!(red.pixel(1, 1), Some([255, 0, 0, 255]));
293 /// // Out of range, not a panic.
294 /// assert_eq!(red.pixel(2, 0), None);
295 /// ```
296 #[must_use]
297 pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> {
298 let i = self.index(x, y)?;
299 let px = self.data.get(i..i + 4)?;
300 Some([*px.first()?, *px.get(1)?, *px.get(2)?, *px.get(3)?])
301 }
302
303 /// Reshape this pixmap to `width` x `height`, keeping the allocation and
304 /// **whatever bytes were already in it**.
305 ///
306 /// Deliberately not named for a cleared buffer, because it does not
307 /// produce one: the pixels are left as they were, and only a grow past
308 /// the old length appends zeroes. The one caller is the per-glyph blit,
309 /// which writes all four bytes of every pixel of the new extent before
310 /// anything reads them, so zeroing here would be a second pass over
311 /// bytes that are about to be overwritten. **A caller that does not write
312 /// every pixel would blit the previous glyph's ink**; `Pixmap::new` is
313 /// the spelling that starts transparent.
314 pub(crate) fn reshape_keeping_pixels(&mut self, width: u32, height: u32) {
315 let len = (width as usize)
316 .saturating_mul(height as usize)
317 .saturating_mul(4);
318 self.width = width;
319 self.height = height;
320 // Grow without zeroing what is already there: the caller writes every
321 // byte of the new extent before reading it, so re-zeroing would be a
322 // second pass over the same pixels. `resize` only fills the bytes
323 // beyond the current length, and `truncate` keeps the capacity.
324 if len <= self.data.len() {
325 self.data.truncate(len);
326 } else {
327 self.data.resize(len, 0);
328 }
329 }
330
331 /// Overwrite one pixel with premultiplied RGBA bytes. Out of range is a
332 /// no-op — the shading rasterizers clip by construction and a stray write
333 /// must not panic on a crafted file.
334 ///
335 /// ```
336 /// use pdfrum_render::Pixmap;
337 ///
338 /// let mut p = Pixmap::new(2, 2);
339 /// p.set_pixel(1, 0, [255, 0, 0, 255]);
340 /// assert_eq!(p.pixel(1, 0), Some([255, 0, 0, 255]));
341 /// // Out of range is a no-op: a crafted file must not panic here.
342 /// p.set_pixel(9, 9, [255, 255, 255, 255]);
343 /// ```
344 pub fn set_pixel(&mut self, x: u32, y: u32, px: [u8; 4]) {
345 let Some(i) = self.index(x, y) else { return };
346 if let Some(slot) = self.data.get_mut(i..i + 4) {
347 slot.copy_from_slice(&px);
348 }
349 }
350
351 /// Set every pixel to `color`.
352 ///
353 /// ```
354 /// use pdfrum_render::Pixmap;
355 ///
356 /// let mut p = Pixmap::new(2, 2);
357 /// p.fill(peniko::Color::from_rgba8(255, 0, 0, 255));
358 /// assert_eq!(p.pixel(0, 0), Some([255, 0, 0, 255]));
359 /// ```
360 pub fn fill(&mut self, color: peniko::Color) {
361 let px = premultiply(color);
362 for chunk in self.data.as_chunks_mut::<4>().0 {
363 *chunk = px;
364 }
365 }
366
367 /// Scale every channel by `alpha`.
368 ///
369 /// The scalar is **truncated** to a byte (`0.5` becomes `127`, not `128`)
370 /// and the per-pixel product truncates too. On a premultiplied buffer all
371 /// four channels scale, where the oracle scales only the alpha byte of a
372 /// straight one — the same image either way.
373 ///
374 /// ```
375 /// use pdfrum_render::Pixmap;
376 ///
377 /// let mut p = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 255));
378 /// // The scalar truncates to a byte: `0.5` is 127, not 128.
379 /// p.multiply_alpha(0.5);
380 /// assert_eq!(p.pixel(0, 0), Some([127, 0, 0, 127]));
381 /// ```
382 pub fn multiply_alpha(&mut self, alpha: f32) {
383 if alpha >= 1.0 {
384 return;
385 }
386 let a = alpha_byte_truncating(alpha);
387 for b in &mut self.data {
388 *b = mul255(*b, a);
389 }
390 }
391
392 /// `[oracle-bug]` Remove a non-isolated group's initial backdrop from its
393 /// finished pixels (ISO 32000 §11.4.6, §11.6.6).
394 ///
395 /// The group's buffer starts as a copy of the page beneath it, so that
396 /// copy would be counted a **second** time when the group is composited
397 /// back. The spec's formula, per channel and premultiplied:
398 ///
399 /// ```text
400 /// C = Cn + (Cn - C0) * (a0 / agn - a0)
401 /// ```
402 ///
403 /// `C0`/`a0` are the backdrop's and `Cn`/`agn` the group's. A pixel that
404 /// still equals the backdrop is empty. Over an **opaque** backdrop alpha
405 /// cannot rise, so a pixel the group *replaced* keeps the group's colour
406 /// — otherwise a later `ca < 1` multiply has nothing to fade. `backdrop`
407 /// must match this pixmap's dimensions; a mismatch is a no-op, as for
408 /// [`Self::multiply_alpha_mask`].
409 ///
410 /// ```
411 /// use pdfrum_render::Pixmap;
412 ///
413 /// let backdrop = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 255));
414 /// let mut group = backdrop.clone();
415 ///
416 /// // The group painted nothing over its backdrop, so removing it
417 /// // leaves a transparent pixel -- the same image once composited back.
418 /// group.remove_backdrop(&backdrop);
419 /// assert_eq!(group.pixel(0, 0), Some([0, 0, 0, 0]));
420 /// ```
421 pub fn remove_backdrop(&mut self, backdrop: &Self) {
422 // The oracle never removes the backdrop: it copies the page beneath
423 // into the group buffer, then composites that buffer back with no
424 // removal step anywhere between, so the backdrop is counted twice.
425 // The error is invisible at group alpha 1 under Normal blending and
426 // grows with both.
427 if backdrop.width != self.width || backdrop.height != self.height {
428 return;
429 }
430 for (chunk, base) in self
431 .data
432 .as_chunks_mut::<4>()
433 .0
434 .iter_mut()
435 .zip(backdrop.data.as_chunks::<4>().0)
436 {
437 let (agn, a0) = (chunk[3], base[3]);
438 // Over an opaque backdrop alpha cannot rise, so "agn <= a0" is
439 // true of every pixel — including ones the group replaced. Those
440 // keep the group's colour; only a pixel that still *is* the
441 // backdrop is empty. Without this, a form at `ca < 1` on a white
442 // page (example_062's templates) composites as nothing.
443 if agn <= a0 {
444 if a0 == 255
445 && (chunk.first() != base.first()
446 || chunk.get(1) != base.get(1)
447 || chunk.get(2) != base.get(2))
448 {
449 continue;
450 }
451 for b in chunk.iter_mut() {
452 *b = 0;
453 }
454 continue;
455 }
456 // Un-premultiply, apply the formula, re-premultiply. The result's
457 // alpha is the group's own contribution, `(agn - a0) / (1 - a0)`.
458 let (fa0, fagn) = (f32::from(a0) / 255.0, f32::from(agn) / 255.0);
459 let out_alpha = ((fagn - fa0) / (1.0 - fa0)).clamp(0.0, 1.0);
460 for index in 0..3 {
461 let (Some(&cn), Some(&c0)) = (chunk.get(index), base.get(index)) else {
462 continue;
463 };
464 let un = |v: u8, a: f32| {
465 if a > 0.0 {
466 f32::from(v) / 255.0 / a
467 } else {
468 0.0
469 }
470 };
471 let (ucn, uc0) = (un(cn, fagn), un(c0, fa0));
472 let colour = uc0.mul_add(-(fa0 / fagn - fa0), ucn.mul_add(fa0 / fagn - fa0, ucn));
473 #[expect(
474 clippy::cast_possible_truncation,
475 clippy::cast_sign_loss,
476 reason = "the clamp bounds the value to 0.0..=255.0"
477 )]
478 let byte = (colour.clamp(0.0, 1.0) * out_alpha * 255.0).round() as u8;
479 if let Some(slot) = chunk.get_mut(index) {
480 *slot = byte;
481 }
482 }
483 #[expect(
484 clippy::cast_possible_truncation,
485 clippy::cast_sign_loss,
486 reason = "the clamp bounds the value to 0.0..=255.0"
487 )]
488 let alpha_byte = (out_alpha * 255.0).round() as u8;
489 if let Some(slot) = chunk.get_mut(3) {
490 *slot = alpha_byte;
491 }
492 }
493 }
494
495 /// `[oracle-bug]` Lay `next` over this pixmap under **knockout**
496 /// composition (ISO 32000 §11.6.6): where `next` has any coverage it
497 /// *replaces* what is here, rather than blending over it.
498 ///
499 /// That is the whole of the knockout rule stated pixel-wise. Each object
500 /// in a knockout group composites against the group's initial backdrop,
501 /// so an earlier object's contribution at a pixel a later one also covers
502 /// never reaches the result; only the coverage-weighted mix at the edges
503 /// of the later object's own antialiasing keeps any of it.
504 ///
505 /// A mismatch in dimensions is a no-op, on the same invariant as
506 /// [`Self::multiply_alpha_mask`].
507 ///
508 /// ```
509 /// use pdfrum_render::Pixmap;
510 ///
511 /// let mut base = Pixmap::filled(1, 1, peniko::Color::from_rgba8(0, 0, 255, 255));
512 /// let next = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 255));
513 ///
514 /// // Full coverage replaces rather than blending.
515 /// base.knockout_over(&next);
516 /// assert_eq!(base.pixel(0, 0), Some([255, 0, 0, 255]));
517 /// ```
518 pub fn knockout_over(&mut self, next: &Self) {
519 if next.width != self.width || next.height != self.height {
520 return;
521 }
522 for (chunk, over) in self
523 .data
524 .as_chunks_mut::<4>()
525 .0
526 .iter_mut()
527 .zip(next.data.as_chunks::<4>().0)
528 {
529 let a = over[3];
530 if a == 0 {
531 continue;
532 }
533 if a == u8::MAX {
534 chunk.copy_from_slice(over);
535 continue;
536 }
537 // Partial coverage at the new object's own edge: mix toward it by
538 // its alpha, which is `replace` weighted by coverage.
539 for (slot, &value) in chunk.iter_mut().zip(over.iter()) {
540 *slot = value.saturating_add(mul255(*slot, 255 - a));
541 }
542 }
543 }
544
545 /// Lay `next` over this pixmap under the **fill-and-stroke knockout**:
546 /// wherever `next` has coverage it replaces what is here outright, its
547 /// own alpha included.
548 ///
549 /// This is [`Self::knockout_over`]'s sibling, and the one place they
550 /// differ is what happens to the destination's *alpha*. `knockout_over`
551 /// mixes toward `next` by coverage, so a translucent `next` over an
552 /// opaque `self` stays opaque — right for a knockout *group*, whose
553 /// members all composite against one already-painted backdrop that is
554 /// itself part of the buffer.
555 ///
556 /// It is wrong for `CFX_RenderDevice::DrawFillStrokePath`
557 /// (`core/fxge/cfx_renderdevice.cpp:832-889`). There the buffer is seeded
558 /// with a **copy of the backdrop** (`:864-871`) before being handed to a
559 /// device built with `group_knockout=true` (`:873-875`), so each paint
560 /// composites against the page and the stroke never sees the fill at all.
561 /// Our buffer is seeded transparent instead — the engine draws into a
562 /// sub-target and blits — so the equivalent is to carry the stroke's own
563 /// premultiplied value out of the buffer and let the blit composite it
564 /// against the page exactly once.
565 ///
566 /// `fx/path/transparent1.pdf` is the witness, and the arithmetic is
567 /// decisive. Its stroke is red at `/CA 0.58`, premultiplied
568 /// `(148, 0, 0, 148)`. Blended over the opaque black fill the alpha comes
569 /// back to 255 and the colour to 148 — `#930000`, which the oracle never
570 /// produces anywhere on the page. Replaced outright it stays
571 /// `(148, 0, 0, 148)` and the blit over the white page gives `#ff6c6c`,
572 /// the single uniform ring the oracle paints.
573 ///
574 /// A mismatch in dimensions is a no-op, on the same invariant as
575 /// [`Self::multiply_alpha_mask`].
576 pub(crate) fn knockout_replace(&mut self, next: &Self) {
577 if next.width != self.width || next.height != self.height {
578 return;
579 }
580 for (chunk, over) in self
581 .data
582 .as_chunks_mut::<4>()
583 .0
584 .iter_mut()
585 .zip(next.data.as_chunks::<4>().0)
586 {
587 let a = over[3];
588 if a == 0 {
589 continue;
590 }
591 // Any coverage at all knocks the destination out: the stroke's
592 // premultiplied value *is* the result, alpha included. There is
593 // no blend term, which is exactly what separates this from
594 // `knockout_over`.
595 chunk.copy_from_slice(over);
596 }
597 }
598
599 /// Multiply every channel by a coverage mask, PDFium's
600 /// `MultiplyAlphaMask`. The mask must match the pixmap's dimensions
601 /// exactly — an invariant, not a preference: a mismatched mask silently
602 /// disables masking on both backends, so the engine never emits one.
603 ///
604 /// ```
605 /// use pdfrum_render::Pixmap;
606 ///
607 /// use pdfrum_render::AlphaMask;
608 ///
609 /// let mut p = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 255));
610 /// p.multiply_alpha_mask(&AlphaMask::filled(1, 1, 128));
611 /// assert_eq!(p.pixel(0, 0), Some([128, 0, 0, 128]));
612 ///
613 /// // A mismatched mask is a no-op, which is why the engine never emits one.
614 /// p.multiply_alpha_mask(&AlphaMask::filled(4, 4, 0));
615 /// assert_eq!(p.pixel(0, 0), Some([128, 0, 0, 128]));
616 /// ```
617 pub fn multiply_alpha_mask(&mut self, mask: &AlphaMask) {
618 if mask.width() != self.width || mask.height() != self.height {
619 return;
620 }
621 for (chunk, &m) in self.data.as_chunks_mut::<4>().0.iter_mut().zip(mask.data()) {
622 for b in chunk {
623 *b = mul255(*b, m);
624 }
625 }
626 }
627
628 /// The 8-bit luminosity of every pixel under the oracle's gray weights,
629 /// for a soft mask's luminosity readback (ISO 32000 §11.6.5.2).
630 ///
631 /// Deliberately *not* either backend's luminance helper: both use BT.709
632 /// coefficients and the oracle uses NTSC ones on a 0..100 integer scale.
633 ///
634 /// ```
635 /// use pdfrum_render::Pixmap;
636 ///
637 /// // The oracle's NTSC weights on a 0..100 integer scale, not BT.709.
638 /// let blue = Pixmap::filled(1, 1, peniko::Color::from_rgba8(0, 0, 255, 255));
639 /// assert_eq!(blue.luminosity_mask().data(), &[28]);
640 /// ```
641 #[must_use]
642 pub fn luminosity_mask(&self) -> AlphaMask {
643 let mut out = Vec::with_capacity(self.data.len() / 4);
644 for &[r, g, b, a] in self.data.as_chunks::<4>().0 {
645 // The oracle's luminosity buffer is opaque (24bpp BGR cleared to
646 // the /BC backdrop), so un-premultiplying is the identity there.
647 // Ours can carry alpha where a group did not paint over the
648 // backdrop clear, so undo the premultiplication first.
649 let [r, g, b] = unpremultiply_rgb(r, g, b, a);
650 out.push(rgb_to_gray(r, g, b));
651 }
652 AlphaMask::from_vec(self.width, self.height, out)
653 .unwrap_or_else(|| AlphaMask::new(self.width, self.height))
654 }
655
656 /// The alpha channel on its own, for an alpha-type soft mask.
657 ///
658 /// ```
659 /// use pdfrum_render::Pixmap;
660 ///
661 /// let red = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 255));
662 /// assert_eq!(red.alpha_mask().data(), &[255]);
663 /// ```
664 #[must_use]
665 pub fn alpha_mask(&self) -> AlphaMask {
666 let out: Vec<u8> = self.data.as_chunks::<4>().0.iter().map(|c| c[3]).collect();
667 AlphaMask::from_vec(self.width, self.height, out)
668 .unwrap_or_else(|| AlphaMask::new(self.width, self.height))
669 }
670
671 /// The straight-alpha BGRA bytes the oracle hashes and encodes.
672 ///
673 /// Straight-alpha BGRA bytes, four per pixel.
674 ///
675 /// ```
676 /// use pdfrum_render::Pixmap;
677 ///
678 /// let red = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 255));
679 /// assert_eq!(red.to_straight_bgra(), vec![0, 0, 255, 255]);
680 /// ```
681 #[must_use]
682 pub fn to_straight_bgra(&self) -> Vec<u8> {
683 self.bgra(false)
684 }
685
686 /// Straight BGRA with the alpha byte forced to `0xFF`.
687 ///
688 /// A page with no transparency is rendered into a 32bpp buffer whose
689 /// fourth byte is padding, and a hash taken over these bytes sees that
690 /// padding as `0xFF` rather than as whatever the render left there.
691 ///
692 /// ```
693 /// use pdfrum_render::Pixmap;
694 ///
695 /// let clear = Pixmap::new(1, 1);
696 /// assert_eq!(clear.to_opaque_bgra(), vec![0, 0, 0, 255]);
697 /// ```
698 #[must_use]
699 pub fn to_opaque_bgra(&self) -> Vec<u8> {
700 self.bgra(true)
701 }
702
703 fn bgra(&self, opaque: bool) -> Vec<u8> {
704 let mut out = Vec::with_capacity(self.data.len());
705 for &[r, g, b, a] in self.data.as_chunks::<4>().0 {
706 let [r, g, b] = unpremultiply_rgb(r, g, b, a);
707 out.extend_from_slice(&[b, g, r, if opaque { 0xFF } else { a }]);
708 }
709 out
710 }
711
712 /// The straight-alpha RGB bytes, three per pixel — the shape the oracle's
713 /// PNG encoder writes for a page with no transparency.
714 ///
715 /// ```
716 /// use pdfrum_render::Pixmap;
717 ///
718 /// let red = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 255));
719 /// assert_eq!(red.to_straight_rgb(), vec![255, 0, 0]);
720 /// ```
721 #[must_use]
722 pub fn to_straight_rgb(&self) -> Vec<u8> {
723 let mut out = Vec::with_capacity(self.data.len() / 4 * 3);
724 for &[r, g, b, a] in self.data.as_chunks::<4>().0 {
725 out.extend_from_slice(&unpremultiply_rgb(r, g, b, a));
726 }
727 out
728 }
729
730 /// The straight-alpha RGBA bytes, four per pixel — the shape the oracle's
731 /// PNG encoder writes for a page that has transparency.
732 ///
733 /// ```
734 /// use pdfrum_render::Pixmap;
735 ///
736 /// let red = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 255));
737 /// assert_eq!(red.to_straight_rgba(), vec![255, 0, 0, 255]);
738 /// ```
739 #[must_use]
740 pub fn to_straight_rgba(&self) -> Vec<u8> {
741 let mut out = Vec::with_capacity(self.data.len());
742 for &[r, g, b, a] in self.data.as_chunks::<4>().0 {
743 let [r, g, b] = unpremultiply_rgb(r, g, b, a);
744 out.extend_from_slice(&[r, g, b, a]);
745 }
746 out
747 }
748
749 fn index(&self, x: u32, y: u32) -> Option<usize> {
750 (x < self.width && y < self.height)
751 .then(|| (y as usize).checked_mul(self.width as usize))
752 .flatten()?
753 .checked_add(x as usize)?
754 .checked_mul(4)
755 }
756}
757
758/// An 8-bit coverage plane: one byte per pixel, `255` fully opaque.
759///
760/// This is both a soft mask (ISO 32000 §11.6.5) and a clip mask. It is always
761/// sized and aligned to the device it applies to, because a mismatched mask
762/// is silently ignored by `vello_cpu` and merely warned about by
763/// `tiny-skia` — it fails *open*.
764///
765/// ```
766/// use pdfrum_render::AlphaMask;
767///
768/// let mask = AlphaMask::filled(2, 2, 128);
769/// assert_eq!((mask.width(), mask.height()), (2, 2));
770/// // One byte per pixel; 255 is fully opaque.
771/// assert_eq!(mask.data(), &[128; 4]);
772/// ```
773#[derive(Debug, Clone, PartialEq, Eq)]
774pub struct AlphaMask {
775 width: u32,
776 height: u32,
777 data: Vec<u8>,
778}
779
780impl AlphaMask {
781 /// A fully transparent (all-zero) mask.
782 ///
783 /// ```
784 /// use pdfrum_render::AlphaMask;
785 ///
786 /// // Fully transparent: all zero.
787 /// assert_eq!(AlphaMask::new(2, 1).data(), &[0, 0]);
788 /// ```
789 #[must_use]
790 pub fn new(width: u32, height: u32) -> Self {
791 let len = (width as usize).saturating_mul(height as usize);
792 Self {
793 width,
794 height,
795 data: vec![0; len],
796 }
797 }
798
799 /// A mask with every byte set to `value`.
800 ///
801 /// ```
802 /// use pdfrum_render::AlphaMask;
803 ///
804 /// assert_eq!(AlphaMask::filled(2, 1, 255).data(), &[255, 255]);
805 /// ```
806 #[must_use]
807 pub fn filled(width: u32, height: u32, value: u8) -> Self {
808 let len = (width as usize).saturating_mul(height as usize);
809 Self {
810 width,
811 height,
812 data: vec![value; len],
813 }
814 }
815
816 /// Wrap an existing coverage plane, or `None` on a size mismatch.
817 ///
818 /// ```
819 /// use pdfrum_render::AlphaMask;
820 ///
821 /// assert!(AlphaMask::from_vec(2, 1, vec![0, 255]).is_some());
822 /// // Not exactly `width * height` bytes.
823 /// assert!(AlphaMask::from_vec(2, 1, vec![0]).is_none());
824 /// ```
825 #[must_use]
826 pub fn from_vec(width: u32, height: u32, data: Vec<u8>) -> Option<Self> {
827 let len = (width as usize).checked_mul(height as usize)?;
828 (data.len() == len).then_some(Self {
829 width,
830 height,
831 data,
832 })
833 }
834
835 /// The mask's width in pixels.
836 ///
837 /// ```
838 /// use pdfrum_render::AlphaMask;
839 ///
840 /// assert_eq!(AlphaMask::new(3, 2).width(), 3);
841 /// ```
842 #[must_use]
843 pub fn width(&self) -> u32 {
844 self.width
845 }
846
847 /// The mask's height in pixels.
848 ///
849 /// ```
850 /// use pdfrum_render::AlphaMask;
851 ///
852 /// assert_eq!(AlphaMask::new(3, 2).height(), 2);
853 /// ```
854 #[must_use]
855 pub fn height(&self) -> u32 {
856 self.height
857 }
858
859 /// The coverage bytes, row-major.
860 ///
861 /// ```
862 /// use pdfrum_render::AlphaMask;
863 ///
864 /// // One byte per pixel, row-major.
865 /// assert_eq!(AlphaMask::new(2, 2).data().len(), 4);
866 /// ```
867 #[must_use]
868 pub fn data(&self) -> &[u8] {
869 &self.data
870 }
871
872 /// The coverage bytes, mutably.
873 ///
874 /// ```
875 /// use pdfrum_render::AlphaMask;
876 ///
877 /// let mut mask = AlphaMask::new(1, 1);
878 /// mask.data_mut()[0] = 255;
879 /// assert_eq!(mask.data(), &[255]);
880 /// ```
881 pub fn data_mut(&mut self) -> &mut [u8] {
882 &mut self.data
883 }
884
885 /// Consume the mask, yielding its coverage bytes.
886 ///
887 /// ```
888 /// use pdfrum_render::AlphaMask;
889 ///
890 /// assert_eq!(AlphaMask::filled(1, 1, 200).into_data(), vec![200]);
891 /// ```
892 #[must_use]
893 pub fn into_data(self) -> Vec<u8> {
894 self.data
895 }
896
897 /// Intersect with another mask of the same size: the truncating integer
898 /// product `old * new / 255`.
899 ///
900 /// ```
901 /// use pdfrum_render::AlphaMask;
902 ///
903 /// let mut mask = AlphaMask::filled(1, 1, 255);
904 /// // The truncating integer product `old * new / 255`.
905 /// mask.intersect(&AlphaMask::filled(1, 1, 128));
906 /// assert_eq!(mask.data(), &[128]);
907 /// ```
908 pub fn intersect(&mut self, other: &Self) {
909 if other.width != self.width || other.height != self.height {
910 return;
911 }
912 for (a, &b) in self.data.iter_mut().zip(&other.data) {
913 *a = mul255(*a, b);
914 }
915 }
916
917 /// Map every byte through a 256-entry lookup — a soft mask's `/TR`.
918 ///
919 /// ```
920 /// use pdfrum_render::AlphaMask;
921 ///
922 /// let mut mask = AlphaMask::filled(1, 1, 10);
923 /// // A soft mask's `/TR`, as a 256-entry lookup.
924 /// let mut table = [0u8; 256];
925 /// table[10] = 200;
926 /// mask.apply_transfer(&table);
927 /// assert_eq!(mask.data(), &[200]);
928 /// ```
929 pub fn apply_transfer(&mut self, table: &[u8; 256]) {
930 for b in &mut self.data {
931 *b = *table.get(*b as usize).unwrap_or(b);
932 }
933 }
934
935 /// Place `src` at `(x, y)` in a device-sized mask, zero everywhere else.
936 ///
937 /// This is E8's padding: a soft mask is produced at the clip rect's size
938 /// and must be handed to `push_layer` device-sized.
939 ///
940 /// ```
941 /// use pdfrum_render::AlphaMask;
942 ///
943 /// // A mask produced at a clip rect's size, padded out to the device.
944 /// let small = AlphaMask::filled(1, 1, 255);
945 /// let placed = small.placed_in(2, 2, 1, 1);
946 /// assert_eq!(placed.data(), &[0, 0, 0, 255]);
947 /// ```
948 #[must_use]
949 pub fn placed_in(&self, width: u32, height: u32, x: i32, y: i32) -> Self {
950 let mut out = Self::new(width, height);
951 // `0 <= v < limit` with `limit: u32` puts the value inside `usize` on
952 // every target, so `try_into` cannot fail once the guard has passed.
953 let in_range = |v: i64, limit: u32| -> Option<usize> {
954 (v >= 0 && v < i64::from(limit))
955 .then(|| usize::try_from(v).ok())
956 .flatten()
957 };
958 for row in 0..self.height {
959 let Some(dy) = in_range(i64::from(row) + i64::from(y), height) else {
960 continue;
961 };
962 for col in 0..self.width {
963 let Some(dx) = in_range(i64::from(col) + i64::from(x), width) else {
964 continue;
965 };
966 let src = self
967 .data
968 .get((row as usize * self.width as usize) + col as usize);
969 let dst = out.data.get_mut((dy * width as usize) + dx);
970 if let (Some(&src), Some(dst)) = (src, dst) {
971 *dst = src;
972 }
973 }
974 }
975 out
976 }
977}
978
979/// `alpha * 255` cast to an integer: **truncating**, so `ca 0.5` is `127`.
980///
981/// `alpha_byte_rounding` is the other conversion; the two differ on a large
982/// fraction of real `ca` values.
983///
984/// ```
985/// use pdfrum_render::pixmap::alpha_byte_truncating;
986///
987/// // Truncating, so a `ca 0.5` is 127 rather than 128.
988/// assert_eq!(alpha_byte_truncating(0.5), 127);
989/// assert_eq!(alpha_byte_truncating(1.0), 255);
990/// ```
991#[must_use]
992pub fn alpha_byte_truncating(alpha: f32) -> u8 {
993 if alpha.is_nan() {
994 return 0;
995 }
996 #[expect(
997 clippy::cast_possible_truncation,
998 clippy::cast_sign_loss,
999 reason = "the NaN guard and the clamp bound the product to 0.0..=255.0; \
1000 the truncation *is* the ported behaviour, not an accident"
1001 )]
1002 let byte = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
1003 byte
1004}
1005
1006/// `round(255 * alpha)` — the *other* alpha conversion, used where a shading
1007/// pattern resolves its painting object's alpha. Half-away-from-zero, unlike
1008/// [`alpha_byte_truncating`]; the two differ on a large fraction of real `ca`
1009/// values.
1010#[must_use]
1011pub(crate) fn alpha_byte_rounding(alpha: f32) -> u8 {
1012 if alpha.is_nan() {
1013 return 0;
1014 }
1015 #[expect(
1016 clippy::cast_possible_truncation,
1017 clippy::cast_sign_loss,
1018 reason = "the NaN guard and the clamp bound the rounded product to 0..=255"
1019 )]
1020 let byte = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
1021 byte
1022}
1023
1024/// `a * b / 255`, truncating — the oracle's ubiquitous 8-bit product.
1025///
1026/// ```
1027/// use pdfrum_render::pixmap::mul255;
1028///
1029/// assert_eq!(mul255(255, 128), 128);
1030/// // Truncating: 128 * 128 / 255 is 64, not 64.25 rounded.
1031/// assert_eq!(mul255(128, 128), 64);
1032/// ```
1033#[must_use]
1034pub fn mul255(a: u8, b: u8) -> u8 {
1035 #[expect(
1036 clippy::cast_possible_truncation,
1037 reason = "255*255/255 == 255 is the maximum, so the quotient always fits u8"
1038 )]
1039 let byte = ((u32::from(a) * u32::from(b)) / 255) as u8;
1040 byte
1041}
1042
1043/// `AlphaMerge(d, s, a) = (d*(255-a) + s*a) / 255`, truncating and unclamped.
1044///
1045/// ```
1046/// use pdfrum_render::pixmap::alpha_merge;
1047///
1048/// // Full alpha takes the source; none of it keeps the destination.
1049/// assert_eq!(alpha_merge(0, 255, 255), 255);
1050/// assert_eq!(alpha_merge(0, 255, 0), 0);
1051/// ```
1052#[must_use]
1053pub fn alpha_merge(dest: u8, src: u8, alpha: u8) -> u8 {
1054 let a = u32::from(alpha);
1055 #[expect(
1056 clippy::cast_possible_truncation,
1057 reason = "the numerator is a convex combination of two 0..=255 bytes \
1058 scaled by 255, so the quotient is itself 0..=255"
1059 )]
1060 let byte = ((u32::from(dest) * (255 - a) + u32::from(src) * a) / 255) as u8;
1061 byte
1062}
1063
1064/// `AlphaUnion(d, s) = d + s - d*s/255`.
1065///
1066/// The truncating product makes this *not* the exact `1-(1-d)(1-s)` a float
1067/// composite would give, and it never quite reaches 255 from two partial
1068/// alphas — `AlphaUnion(128, 128) == 192`, where the float form gives 191.75.
1069#[must_use]
1070pub(crate) fn alpha_union(dest: u8, src: u8) -> u8 {
1071 let merged = u32::from(dest) + u32::from(src) - (u32::from(dest) * u32::from(src)) / 255;
1072 // Bounded by 255 for every byte pair, but the C++'s `uint8_t` return would
1073 // wrap rather than saturate if it were not, so say so rather than cast.
1074 u8::try_from(merged).unwrap_or(u8::MAX)
1075}
1076
1077/// A `peniko::Color` as premultiplied RGBA8.
1078#[must_use]
1079pub(crate) fn premultiply(color: peniko::Color) -> [u8; 4] {
1080 let [r, g, b, a] = color.to_rgba8().to_u8_array();
1081 [mul255(r, a), mul255(g, a), mul255(b, a), a]
1082}
1083
1084/// Undo premultiplication on one pixel's colour channels.
1085///
1086/// Rounds the quotient rather than truncating (`+ alpha / 2`), which keeps
1087/// the round trip through [`premultiply`] stable for opaque pixels.
1088#[must_use]
1089pub(crate) fn unpremultiply_rgb(r: u8, g: u8, b: u8, a: u8) -> [u8; 3] {
1090 if a == 0 {
1091 return [0, 0, 0];
1092 }
1093 if a == 255 {
1094 return [r, g, b];
1095 }
1096 let a32 = u32::from(a);
1097 let up = |c: u8| -> u8 { ((u32::from(c) * 255 + a32 / 2) / a32).min(255) as u8 };
1098 [up(r), up(g), up(b)]
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104
1105 /// A buffer no allocator can meet is an answer, not an abort.
1106 ///
1107 /// `/Width` and `/Height` are the file's, and `ImageDict` accepts each up
1108 /// to 131071 — so `to_pixmap` reached `vec![0; 131071 * 131071 * 4]`, and
1109 /// `handle_alloc_error` ends the process without unwinding. The area cap
1110 /// is what turns that into `None` without touching the allocator:
1111 /// `try_reserve_exact` of 68 GB often succeeds on overcommit, and the
1112 /// `resize` that follows then zeros 68 GB.
1113 #[test]
1114 fn a_size_no_allocator_can_meet_comes_back_empty_rather_than_aborting() {
1115 // 68 GB at four bytes a pixel, from a dictionary that fits in 700
1116 // bytes of PDF.
1117 assert_eq!(Pixmap::try_new(131_071, 131_071), None);
1118 assert_eq!(Pixmap::new(131_071, 131_071).width(), 0);
1119 assert!(Pixmap::new(131_071, 131_071).data().is_empty());
1120 assert_eq!(
1121 Pixmap::filled(131_071, 131_071, peniko::Color::BLACK).width(),
1122 0
1123 );
1124 // 65536 square is inside each axis cap and is 4.3 Gpx / 17 GB of
1125 // pixmap — the size that would still pass a reserve-only check.
1126 assert_eq!(Pixmap::try_new(65_536, 65_536), None);
1127
1128 // A size that *can* be met is untouched by any of it.
1129 assert_eq!(Pixmap::try_new(3, 2).map(|p| p.data().len()), Some(24));
1130 assert_eq!(Pixmap::new(3, 2).data().len(), 24);
1131 }
1132
1133 /// Knockout composition, stated pixel-wise: where a
1134 /// later object has coverage it *replaces* the earlier one rather than
1135 /// blending over it. That is the whole of §11.6.6's rule.
1136 // The oracle does not implement it: SetGroupKnockout is an empty body at
1137 // renderdevicedriver_iface.cpp:132 that the AGG driver never overrides.
1138 #[test]
1139 fn knockout_replaces_rather_than_blending() {
1140 let red = peniko::Color::from_rgba8(255, 0, 0, 255);
1141 let blue = peniko::Color::from_rgba8(0, 0, 255, 255);
1142
1143 // Full coverage replaces outright — under Normal compositing a
1144 // translucent blue over red would mix; here an opaque one wins whole.
1145 let mut base = Pixmap::filled(1, 1, red);
1146 base.knockout_over(&Pixmap::filled(1, 1, blue));
1147 assert_eq!(base.pixel(0, 0), Some([0, 0, 255, 255]));
1148
1149 // No coverage leaves the earlier object alone: knockout replaces
1150 // where the later object *is*, not everywhere.
1151 let mut base = Pixmap::filled(1, 1, red);
1152 base.knockout_over(&Pixmap::new(1, 1));
1153 assert_eq!(base.pixel(0, 0), Some([255, 0, 0, 255]));
1154 }
1155
1156 /// The fill-and-stroke knockout keeps the stroke's own alpha, which is
1157 /// what separates it from [`Pixmap::knockout_over`].
1158 ///
1159 /// `fx/path/transparent1.pdf`'s numbers exactly: a red stroke at
1160 /// `/CA 0.58` is premultiplied `(148, 0, 0, 148)`, and it lands on the
1161 /// opaque black fill the same path already painted. Blending returns the
1162 /// pixel to opaque and yields `#930000` once blitted; replacing keeps
1163 /// `(148, 0, 0, 148)`, which composites against the white page as
1164 /// `#ff6c6c` — the one colour the oracle paints there.
1165 #[test]
1166 fn the_fill_stroke_knockout_keeps_the_strokes_alpha() {
1167 let black_fill = Pixmap::filled(1, 1, peniko::Color::from_rgba8(0, 0, 0, 255));
1168 let translucent_stroke =
1169 Pixmap::from_vec(1, 1, vec![148, 0, 0, 148]).expect("one premultiplied pixel");
1170
1171 let mut blended = black_fill.clone();
1172 blended.knockout_over(&translucent_stroke);
1173 assert_eq!(
1174 blended.pixel(0, 0),
1175 Some([148, 0, 0, 255]),
1176 "knockout_over promotes the overlap to opaque, which is the defect"
1177 );
1178
1179 let mut replaced = black_fill;
1180 replaced.knockout_replace(&translucent_stroke);
1181 assert_eq!(
1182 replaced.pixel(0, 0),
1183 Some([148, 0, 0, 148]),
1184 "the stroke's own alpha must survive so the blit composites it once"
1185 );
1186 }
1187
1188 /// A mismatched overlay is a no-op, on the same
1189 /// invariant `multiply_alpha_mask` keeps.
1190 #[test]
1191 fn a_mismatched_knockout_overlay_changes_nothing() {
1192 let mut base = Pixmap::filled(2, 2, peniko::Color::from_rgba8(7, 8, 9, 255));
1193 let before = base.clone();
1194 base.knockout_over(&Pixmap::filled(3, 3, peniko::Color::BLACK));
1195 assert_eq!(base, before);
1196 }
1197
1198 /// The two properties the backdrop-removal formula
1199 /// has to have.
1200 // The oracle gives it neither: cpdf_renderstatus.cpp does not implement
1201 // backdrop removal at all.
1202 #[test]
1203 fn removing_the_backdrop_leaves_only_the_groups_own_contribution() {
1204 // 1. A pixel nothing was drawn over is pure backdrop, so the group
1205 // contributes nothing there and must come back transparent —
1206 // otherwise compositing draws the page over itself.
1207 let backdrop = Pixmap::filled(1, 1, peniko::Color::from_rgba8(90, 120, 150, 255));
1208 let mut group = backdrop.clone();
1209 group.remove_backdrop(&backdrop);
1210 assert_eq!(group.pixel(0, 0), Some([0, 0, 0, 0]));
1211
1212 // 2. Over a *translucent* backdrop the group's own contribution is
1213 // recoverable, and comes back at the alpha the group added. Here
1214 // the backdrop is half-opaque and the group finished opaque, so
1215 // the group's own alpha is `(1 - 0.5) / (1 - 0.5) = 1`.
1216 let half = Pixmap::filled(1, 1, peniko::Color::from_rgba8(45, 60, 75, 128));
1217 let mut group = Pixmap::filled(1, 1, peniko::Color::from_rgba8(200, 40, 10, 255));
1218 group.remove_backdrop(&half);
1219 let after = group.pixel(0, 0).expect("one pixel");
1220 assert_eq!(after[3], 255, "the group finished opaque over the backdrop");
1221 // And it is not the backdrop: the removal changed the colour.
1222 assert_ne!(after, [45, 60, 75, 128]);
1223
1224 // 3. Over a fully opaque backdrop alpha cannot rise, so the group's
1225 // contribution is recovered by comparing colour: a pixel that still
1226 // equals the backdrop is empty, a pixel the group replaced keeps
1227 // the group's colour so a later `ca < 1` multiply has something
1228 // to fade.
1229 let mut group = Pixmap::filled(1, 1, peniko::Color::from_rgba8(200, 40, 10, 255));
1230 group.remove_backdrop(&backdrop);
1231 assert_eq!(group.pixel(0, 0), Some([200, 40, 10, 255]));
1232 }
1233
1234 /// A mismatched backdrop is a no-op, on the same
1235 /// invariant `multiply_alpha_mask` keeps.
1236 #[test]
1237 fn removing_a_mismatched_backdrop_changes_nothing() {
1238 let mut group = Pixmap::filled(2, 2, peniko::Color::from_rgba8(1, 2, 3, 200));
1239 let before = group.clone();
1240 group.remove_backdrop(&Pixmap::filled(3, 3, peniko::Color::BLACK));
1241 assert_eq!(group, before);
1242 }
1243
1244 #[test]
1245 fn cropped_lifts_a_sub_rectangle_and_pads_the_rest() {
1246 let mut src = Pixmap::new(4, 2);
1247 src.set_pixel(1, 0, [1, 2, 3, 255]);
1248 src.set_pixel(2, 0, [4, 5, 6, 255]);
1249 let out = src.cropped(1, 0, 3, 1);
1250 assert_eq!(out.pixel(0, 0), Some([1, 2, 3, 255]));
1251 assert_eq!(out.pixel(1, 0), Some([4, 5, 6, 255]));
1252 assert_eq!(out.pixel(2, 0), Some([0, 0, 0, 0]));
1253 assert_eq!(src.cropped(8, 0, 1, 1).pixel(0, 0), Some([0, 0, 0, 0]));
1254 }
1255
1256 #[test]
1257 fn multiply_alpha_truncates() {
1258 // cfx_dibitmap.cpp:382-410 casts, it does not round: 0.5 -> 127.
1259 assert_eq!(alpha_byte_truncating(0.5), 127);
1260 assert_eq!(alpha_byte_rounding(0.5), 128);
1261
1262 let mut p = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 255, 255, 255));
1263 p.multiply_alpha(0.5);
1264 assert_eq!(p.pixel(0, 0), Some([127, 127, 127, 127]));
1265 }
1266
1267 #[test]
1268 fn an_opaque_dest_times_a_mask_replaces_alpha_then_premultiplies() {
1269 // An already-opaque pixel: RGB and A both scale by the mask.
1270 let mut p = Pixmap::filled(1, 1, peniko::Color::from_rgba8(10, 20, 30, 255));
1271 p.multiply_alpha_mask(&AlphaMask::filled(1, 1, 128));
1272 assert_eq!(
1273 p.pixel(0, 0),
1274 Some([mul255(10, 128), mul255(20, 128), mul255(30, 128), 128])
1275 );
1276 }
1277
1278 #[test]
1279 fn multiply_alpha_one_is_exact_early_return() {
1280 let mut p = Pixmap::filled(2, 2, peniko::Color::from_rgba8(10, 20, 30, 200));
1281 let before = p.clone();
1282 p.multiply_alpha(1.0);
1283 assert_eq!(p, before);
1284 }
1285
1286 #[test]
1287 fn premul_roundtrip() {
1288 // Ports CFXDIBitmapTest.{Pre,Un}Multiply*: a straight colour through
1289 // premultiply and back is itself for every alpha that can represent it.
1290 for a in [0u8, 1, 64, 128, 254, 255] {
1291 for c in [0u8, 1, 127, 128, 254, 255] {
1292 let color = peniko::Color::from_rgba8(c, c, c, a);
1293 let [pr, pg, pb, pa] = premultiply(color);
1294 assert_eq!(pa, a);
1295 let [ur, _, _] = unpremultiply_rgb(pr, pg, pb, pa);
1296 if a == 0 {
1297 assert_eq!(ur, 0);
1298 } else {
1299 // Premultiplication is lossy below full alpha; the round
1300 // trip must stay within the quantisation step.
1301 let step = 255_u32.div_ceil(u32::from(a).max(1));
1302 assert!(
1303 u32::from(ur).abs_diff(u32::from(c)) <= step,
1304 "a={a} c={c} ur={ur}"
1305 );
1306 }
1307 }
1308 }
1309 }
1310
1311 #[test]
1312 fn alpha_merge_truncates_and_does_not_clamp() {
1313 assert_eq!(alpha_merge(0, 255, 128), 128);
1314 assert_eq!(alpha_merge(255, 0, 128), 127);
1315 assert_eq!(alpha_merge(100, 200, 0), 100);
1316 assert_eq!(alpha_merge(100, 200, 255), 200);
1317 }
1318
1319 #[test]
1320 fn mask_intersect_is_truncating_product() {
1321 let mut a = AlphaMask::filled(2, 1, 128);
1322 let b = AlphaMask::filled(2, 1, 128);
1323 a.intersect(&b);
1324 // 128*128/255 = 64.25 -> 64
1325 assert_eq!(a.data(), &[64, 64]);
1326 }
1327
1328 #[test]
1329 fn mask_placed_in_pads_with_zero() {
1330 let m = AlphaMask::filled(2, 2, 200);
1331 let placed = m.placed_in(4, 4, 1, 1);
1332 assert_eq!(placed.data().first().copied(), Some(0));
1333 assert_eq!(placed.data().get(5).copied(), Some(200));
1334 assert_eq!(placed.data().get(10).copied(), Some(200));
1335 assert_eq!(placed.data().get(15).copied(), Some(0));
1336 }
1337
1338 #[test]
1339 fn opaque_output_forces_alpha_ff() {
1340 // pdfium_test asks for FPDFBitmap_BGRx on a page with no transparency
1341 // and the golden MD5 is over that buffer, padding byte included.
1342 let p = Pixmap::filled(1, 1, peniko::Color::from_rgba8(1, 2, 3, 255));
1343 assert_eq!(p.to_opaque_bgra(), vec![3, 2, 1, 0xFF]);
1344 }
1345
1346 #[test]
1347 fn luminosity_uses_ntsc_not_bt709() {
1348 // Pure blue: FXRGB2GRAY gives 255*11/100 = 28; BT.709 would give 18.
1349 let p = Pixmap::filled(1, 1, peniko::Color::from_rgba8(0, 0, 255, 255));
1350 assert_eq!(p.luminosity_mask().data(), &[28]);
1351 }
1352}
1353
1354/// Encoding to PNG, behind the `png` feature.
1355#[cfg(feature = "png")]
1356impl Pixmap {
1357 /// The pixmap as a PNG file's bytes: eight-bit RGBA, unpremultiplied.
1358 ///
1359 /// The alpha is undone on the way out because PNG's is *straight*
1360 /// (ISO 15948 §6.2) and this buffer's is premultiplied. A viewer
1361 /// multiplies by alpha when it composites, so writing the premultiplied
1362 /// bytes would darken every partly transparent pixel a second time --
1363 /// a 50% red would leave here as `128,0,0,128` and land as `64,0,0`.
1364 /// Fully opaque pixels are identical either way, which is why this is
1365 /// invisible on most pages.
1366 ///
1367 /// # Errors
1368 ///
1369 /// [`Error::Png`](crate::Error::Png) when the encoder refuses the dimensions.
1370 pub fn encode_png(&self) -> Result<Vec<u8>, crate::Error> {
1371 let mut out = Vec::new();
1372 let mut encoder = png::Encoder::new(&mut out, self.width, self.height);
1373 encoder.set_color(png::ColorType::Rgba);
1374 encoder.set_depth(png::BitDepth::Eight);
1375 let mut writer = encoder.write_header()?;
1376 writer.write_image_data(&self.to_straight_rgba())?;
1377 writer.finish()?;
1378 Ok(out)
1379 }
1380
1381 /// Writes the pixmap to `path` as a PNG file.
1382 ///
1383 /// # Errors
1384 ///
1385 /// [`Error::Png`](crate::Error::Png) as [`Pixmap::encode_png`], and [`Error::Io`](crate::Error::Io) when the
1386 /// file cannot be written.
1387 pub fn save_png(&self, path: impl AsRef<std::path::Path>) -> Result<(), crate::Error> {
1388 std::fs::write(path, self.encode_png()?)?;
1389 Ok(())
1390 }
1391}
1392
1393#[cfg(all(test, feature = "png"))]
1394mod png_tests {
1395 use super::Pixmap;
1396
1397 #[test]
1398 fn a_pixmap_round_trips_through_png() {
1399 let pixmap = Pixmap::from_vec(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 128]).unwrap();
1400 let bytes = pixmap.encode_png().unwrap();
1401 assert_eq!(bytes.get(..8), Some(b"\x89PNG\r\n\x1a\n".as_slice()));
1402 let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
1403 let mut reader = decoder.read_info().unwrap();
1404 let mut out = vec![0; reader.output_buffer_size().unwrap()];
1405 let info = reader.next_frame(&mut out).unwrap();
1406 assert_eq!(
1407 (info.width, info.height, info.color_type),
1408 (2, 1, png::ColorType::Rgba)
1409 );
1410 // Straight alpha, not the buffer's own premultiplied bytes: the
1411 // second pixel is stored `0,0,255,128` and must land `0,0,255,128`
1412 // only because its colour is already at full intensity under that
1413 // alpha. See `unpremultiplied_alpha_is_what_reaches_the_file`.
1414 assert_eq!(
1415 out.get(..info.buffer_size()),
1416 Some(pixmap.to_straight_rgba().as_slice())
1417 );
1418 }
1419
1420 /// PNG's alpha is straight and this buffer's is premultiplied, so the
1421 /// conversion has to happen on the way out.
1422 ///
1423 /// The regression: `encode_png` wrote `self.data` verbatim while its own
1424 /// documentation said "unpremultiplied", so every partly transparent
1425 /// pixel came out darkened -- a viewer multiplies by alpha again when it
1426 /// composites. Opaque pixels are identical either way, which is why no
1427 /// golden caught it.
1428 #[test]
1429 fn unpremultiplied_alpha_is_what_reaches_the_file() {
1430 // Half-alpha red: premultiplied storage is 128,0,0,128; the file must
1431 // carry 255,0,0,128.
1432 let pixmap = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 128));
1433 assert_eq!(pixmap.data(), &[128, 0, 0, 128], "stored premultiplied");
1434
1435 let decoder = png::Decoder::new(std::io::Cursor::new(pixmap.encode_png().unwrap()));
1436 let mut reader = decoder.read_info().unwrap();
1437 let mut out = vec![0; reader.output_buffer_size().unwrap()];
1438 let info = reader.next_frame(&mut out).unwrap();
1439 assert_eq!(
1440 out.get(..info.buffer_size()),
1441 Some([255, 0, 0, 128].as_slice()),
1442 "the file must carry straight alpha"
1443 );
1444 }
1445}