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