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