Skip to main content

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}