pdfrum_page/image/packed.rs
1//! Samples that are decoded but **not yet unpacked**, and the stage that
2//! unpacks them one row at a time.
3//!
4//! The image ladder's fourth rung produces bytes: whatever the filter chain
5//! left, in the depth the dictionary declares. Widening those to eight bits
6//! per component is a row-at-a-time pass: [`Unpacked`] walks the packed
7//! samples and yields one widened row per call.
8//!
9//! [`Packed`] is the same samples in the state the codec left them, plus the
10//! geometry needed to walk them: a [`Depth`], a component count, a pitch and
11//! the `/Decode` mapping already collapsed into one byte table per component.
12//! [`Unpacked`] is the row stage that reads it. Nothing is materialized until
13//! a caller asks for whole-image [`Pixels`], which
14//! [`crate::image::Samples::to_pixels`] is the one function that does.
15//!
16//! # Why the decode is a table
17//!
18//! `DecodeMap::apply` is a float multiply-add, a clamp, a round and a cast,
19//! and the mapping's answer depends only on the component index and the raw
20//! sample. At every depth the sample space is at most 65 536 wide, so the
21//! whole mapping tabulates. This is the same reasoning — and the same
22//! rounding — the codec path's own `decode_table` already applies on that
23//! path; here it also means the row stage's inner loop is a lookup rather
24//! than arithmetic.
25
26use crate::image::decode_array::DecodeMap;
27use crate::image::scanline;
28
29/// Bits per component, as the sample stream carries them.
30///
31/// The five values `/BitsPerComponent` is allowed to take. A type rather than
32/// a `u32` because the unpack loop dispatches on it once per image and then
33/// runs a loop that cannot be handed a depth the extractor does not know.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Depth {
36 /// One bit: eight samples to the byte, MSB first.
37 One,
38 /// Two bits.
39 Two,
40 /// Four bits: two samples to the byte.
41 Four,
42 /// Eight bits: one sample to the byte.
43 Eight,
44 /// Sixteen bits, big-endian.
45 Sixteen,
46}
47
48impl Depth {
49 /// The depth `bpc` names, or `None` when it is not one PDF allows.
50 #[must_use]
51 pub const fn new(bpc: u32) -> Option<Self> {
52 match bpc {
53 1 => Some(Self::One),
54 2 => Some(Self::Two),
55 4 => Some(Self::Four),
56 8 => Some(Self::Eight),
57 16 => Some(Self::Sixteen),
58 _ => None,
59 }
60 }
61
62 /// Bits per sample.
63 #[must_use]
64 pub const fn bits(self) -> u32 {
65 match self {
66 Self::One => 1,
67 Self::Two => 2,
68 Self::Four => 4,
69 Self::Eight => 8,
70 Self::Sixteen => 16,
71 }
72 }
73
74 /// How many distinct raw values this depth can express — the width of one
75 /// row of the decode table.
76 #[must_use]
77 pub const fn levels(self) -> usize {
78 1usize << self.bits()
79 }
80}
81
82/// Decoded-but-not-unpacked samples, with everything needed to walk them.
83///
84/// This is the state the filter chain leaves an image in for every path that
85/// does not go through a codec of its own: raw samples at [`Depth`], in the
86/// colour space's own component order, still packed. The `/Decode` array is
87/// already folded into a byte table, so unpacking a sample is a lookup.
88#[derive(Debug, Clone, PartialEq)]
89pub struct Packed {
90 /// The sample bytes, exactly as the filter chain left them.
91 data: Box<[u8]>,
92 /// Bits per component.
93 depth: Depth,
94 /// Components per pixel.
95 components: usize,
96 /// Bytes per source row.
97 pitch: usize,
98 /// Samples across.
99 width: usize,
100 /// Rows down.
101 height: u32,
102 /// The `/Decode` mapping, one row of `depth.levels()` bytes per component.
103 table: Box<[u8]>,
104}
105
106impl Packed {
107 /// Hold `data` as `width` x `height` samples of `components` at `depth`,
108 /// with the `/Decode` mapping for `space` folded into the table.
109 ///
110 /// `pitch` is the dictionary's own row stride, which for a packed depth is
111 /// wider than `width * components * depth / 8` rounded down: a row is
112 /// byte-aligned, so the tail bits of the last byte belong to no pixel.
113 #[must_use]
114 #[expect(
115 clippy::too_many_arguments,
116 reason = "packed samples are exactly this geometry, and naming each \
117 piece is what keeps the row walk from re-deriving any of it"
118 )]
119 pub fn new(
120 data: Box<[u8]>,
121 depth: Depth,
122 components: usize,
123 pitch: usize,
124 width: u32,
125 height: u32,
126 space: &crate::color::ColorSpace,
127 decode: Option<&pdfrum_object::Array>,
128 ) -> Self {
129 Self::with_map(
130 data,
131 depth,
132 components,
133 pitch,
134 width,
135 height,
136 &DecodeMap::new(Some(space), components, depth.bits(), decode),
137 )
138 }
139
140 /// The same, from a mapping the image ladder has already built.
141 pub(crate) fn with_map(
142 data: Box<[u8]>,
143 depth: Depth,
144 components: usize,
145 pitch: usize,
146 width: u32,
147 height: u32,
148 decode: &DecodeMap,
149 ) -> Self {
150 let levels = depth.levels();
151 let mut table = vec![0u8; components.saturating_mul(levels)].into_boxed_slice();
152 for component in 0..components {
153 for raw in 0..levels {
154 #[expect(
155 clippy::cast_precision_loss,
156 reason = "a table index below 65 536 is exact in f32"
157 )]
158 let value = decode.apply(component, raw as f32);
159 // Rounded, not truncated — the same encode `decode_table`
160 // uses, and for the same reason: at 1, 2, 4 and 8 bits the two
161 // agree on every raw value, and at 16 they diverge on 32 648
162 // of the 65 536 samples with truncation a count low on each.
163 #[expect(
164 clippy::cast_possible_truncation,
165 clippy::cast_sign_loss,
166 reason = "the clamp bounds the product to 0..=255"
167 )]
168 let byte = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
169 if let Some(slot) = table.get_mut(component * levels + raw) {
170 *slot = byte;
171 }
172 }
173 }
174 Self {
175 data,
176 depth,
177 components,
178 pitch,
179 width: width as usize,
180 height,
181 table,
182 }
183 }
184
185 /// Components per pixel.
186 #[must_use]
187 pub const fn components(&self) -> usize {
188 self.components
189 }
190
191 /// Bytes held: the packed samples plus the decode table.
192 #[must_use]
193 pub fn byte_size(&self) -> usize {
194 self.data.len() + self.table.len()
195 }
196
197 /// Whether any row of the declared height is missing or short.
198 ///
199 /// The eager pass recorded a truncated-stream diagnostic
200 /// while it walked; a lazy one has to answer the same question without
201 /// walking, which the stream length does.
202 #[must_use]
203 pub fn truncated(&self) -> bool {
204 self.pitch
205 .checked_mul(self.height as usize)
206 .is_none_or(|want| self.data.len() < want)
207 }
208}
209
210/// The unpack stage: [`Packed`] samples widened to a byte per component, one
211/// row at a time into a buffer it owns and reuses.
212///
213/// The row this yields is exactly the row the eager pass wrote into its
214/// full-size buffer — same table, same rounding, same treatment of a short or
215/// absent row — so putting the stage in front of
216/// [`crate::image::Converted`] changes when the arithmetic runs and not what
217/// it produces.
218#[derive(Debug)]
219pub struct Unpacked<'a> {
220 packed: &'a Packed,
221 /// One row of `width * components` bytes, reused.
222 buf: Vec<u8>,
223 /// The current row's packed bytes, zero-padded to the pitch. Held rather
224 /// than allocated per row: the eager pass allocated one `Vec` per
225 /// scanline, which on a tall image is as many allocations as rows.
226 line: Vec<u8>,
227 y: u32,
228}
229
230impl<'a> Unpacked<'a> {
231 /// Start walking `packed` from its first row.
232 #[must_use]
233 pub fn new(packed: &'a Packed) -> Self {
234 Self {
235 buf: vec![0u8; packed.width.saturating_mul(packed.components)],
236 line: vec![0u8; packed.pitch],
237 packed,
238 y: 0,
239 }
240 }
241
242 /// The next row widened to a byte per component, or `None` past the
243 /// bottom.
244 ///
245 /// A row the stream never reached yields **zeroes**, not the byte
246 /// `/Decode` maps a zero sample to: PDFium returns a zeroed *output*
247 /// buffer for an absent row without ever running the decode, so the pixels
248 /// are literal black -- the distinction the scanline reader's
249 /// `Availability` carries.
250 pub fn next_row(&mut self) -> Option<&[u8]> {
251 if self.y >= self.packed.height {
252 return None;
253 }
254 let y = self.y as usize;
255 self.y += 1;
256 let p = self.packed;
257 let availability = scanline::scanline_into(&p.data, y, p.pitch, &mut self.line);
258 if availability == scanline::Availability::Absent {
259 self.buf.fill(0);
260 return Some(&self.buf);
261 }
262 let levels = p.depth.levels();
263 // The component index cycles `0, 1, .. components-1` across the row,
264 // so it is a counter that wraps, not `i % components` on every sample.
265 // On a one-bit image the division was the larger half of the loop.
266 let table = &*p.table;
267 let line = &*self.line;
268 match p.depth {
269 // The byte-aligned depths are a walk, not a bit extraction: the
270 // sample *is* the byte, so the row is a zip through the table.
271 Depth::Eight => {
272 let mut base = 0usize;
273 let mut component = 0usize;
274 for (slot, &raw) in self.buf.iter_mut().zip(line) {
275 *slot = table.get(base + usize::from(raw)).copied().unwrap_or(0);
276 component += 1;
277 base += levels;
278 if component == p.components {
279 component = 0;
280 base = 0;
281 }
282 }
283 // A row the stream could only partly supply keeps what it has
284 // and zeroes the rest, which is the eager pass's own tail.
285 if let Some(tail) = self.buf.get_mut(line.len()..) {
286 tail.fill(0);
287 }
288 }
289 // Sixteen bits is two bytes per sample, big-endian, and the table
290 // is indexed by the whole word.
291 Depth::Sixteen => {
292 let mut base = 0usize;
293 let mut component = 0usize;
294 for (i, slot) in self.buf.iter_mut().enumerate() {
295 let hi = line.get(i * 2).copied().unwrap_or(0);
296 let lo = line.get(i * 2 + 1).copied().unwrap_or(0);
297 let raw = usize::from(hi) * 256 + usize::from(lo);
298 *slot = table.get(base + raw).copied().unwrap_or(0);
299 component += 1;
300 base += levels;
301 if component == p.components {
302 component = 0;
303 base = 0;
304 }
305 }
306 }
307 // The sub-byte depths pack several samples into a byte, MSB
308 // first. Walking the byte and shifting down through it reads each
309 // byte once, where a `get_bits` per sample re-derived the byte
310 // index and the shift every time. On the one-bit images that is
311 // the whole of this stage's cost.
312 Depth::One | Depth::Two | Depth::Four => {
313 let bits = p.depth.bits();
314 let per_byte = (8 / bits) as usize;
315 let mask = u32::from(u8::MAX) >> (8 - bits);
316 let mut base = 0usize;
317 let mut component = 0usize;
318 for (chunk, byte_index) in self.buf.chunks_mut(per_byte).zip(0usize..) {
319 let byte = u32::from(line.get(byte_index).copied().unwrap_or(0));
320 // `k` is a position within one byte, so at most 7.
321 for (slot, k) in chunk.iter_mut().zip(0u32..) {
322 let shift = 8 - bits - k * bits;
323 let raw = ((byte >> shift) & mask) as usize;
324 *slot = table.get(base + raw).copied().unwrap_or(0);
325 component += 1;
326 base += levels;
327 if component == p.components {
328 component = 0;
329 base = 0;
330 }
331 }
332 }
333 }
334 }
335 Some(&self.buf)
336 }
337
338 /// The whole image unpacked, for the callers that genuinely want it.
339 ///
340 /// The one place the full-size buffer this stage exists to avoid is built:
341 /// [`crate::image::Samples::to_pixels`], reached by the CLI's image
342 /// export, the facade's edit path and the stencil check.
343 #[must_use]
344 pub fn collect_all(mut self) -> Box<[u8]> {
345 let p = self.packed;
346 let stride = p.width.saturating_mul(p.components);
347 let mut out =
348 Vec::with_capacity(stride.saturating_mul(usize::try_from(p.height).unwrap_or(0)));
349 while let Some(row) = self.next_row() {
350 out.extend_from_slice(row);
351 }
352 out.into_boxed_slice()
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::color::ColorSpace;
360
361 fn map(components: usize, bpc: u32) -> DecodeMap {
362 DecodeMap::new(Some(&ColorSpace::DeviceGray), components, bpc, None)
363 }
364
365 fn packed(data: &[u8], bpc: u32, components: usize, width: u32, height: u32) -> Packed {
366 let depth = Depth::new(bpc).expect("a real depth");
367 let pitch = (width as usize * components * bpc as usize).div_ceil(8);
368 Packed::with_map(
369 data.into(),
370 depth,
371 components,
372 pitch,
373 width,
374 height,
375 &map(components, bpc),
376 )
377 }
378
379 #[test]
380 fn eight_bit_samples_pass_through_unchanged() {
381 let p = packed(&[0, 128, 255, 7], 8, 1, 2, 2);
382 let mut u = Unpacked::new(&p);
383 assert_eq!(u.next_row(), Some(&[0, 128][..]));
384 assert_eq!(u.next_row(), Some(&[255, 7][..]));
385 assert_eq!(u.next_row(), None);
386 }
387
388 /// A one-bit sample widens to the two ends of the range, and the tail bits
389 /// of a byte-aligned row belong to no pixel.
390 #[test]
391 fn one_bit_samples_widen_to_black_and_white() {
392 // `0b1010_0000` across four declared pixels: set, clear, set, clear.
393 let p = packed(&[0b1010_0000], 1, 1, 4, 1);
394 let mut u = Unpacked::new(&p);
395 assert_eq!(u.next_row(), Some(&[255, 0, 255, 0][..]));
396 }
397
398 #[test]
399 fn four_bit_samples_span_the_range() {
400 let p = packed(&[0x0F, 0x80], 4, 1, 4, 1);
401 let mut u = Unpacked::new(&p);
402 // 0 -> 0, 15 -> 255, 8 -> 136, 0 -> 0.
403 assert_eq!(u.next_row(), Some(&[0, 255, 136, 0][..]));
404 }
405
406 /// Sixteen bits keeps the high byte, which is what the decode table's
407 /// rounding over 65 536 levels comes to for the identity mapping.
408 #[test]
409 fn sixteen_bit_samples_keep_their_high_byte() {
410 let p = packed(&[0xFF, 0xFF, 0x00, 0x00], 16, 1, 2, 1);
411 let mut u = Unpacked::new(&p);
412 assert_eq!(u.next_row(), Some(&[255, 0][..]));
413 }
414
415 /// A row that begins past the end of the stream is zeroes, not whatever
416 /// the decode maps a zero sample to. A row that begins inside it keeps the
417 /// samples it has and zero-pads the rest, which then *does* go through the
418 /// decode.
419 #[test]
420 fn an_absent_row_is_zero_and_a_short_one_is_padded() {
421 // Five bytes of a 2x4 image: rows 0 and 1 whole, row 2 half, row 3
422 // absent.
423 let p = packed(&[1, 2, 3, 4, 5], 8, 1, 2, 4);
424 let mut u = Unpacked::new(&p);
425 assert_eq!(u.next_row(), Some(&[1, 2][..]));
426 assert_eq!(u.next_row(), Some(&[3, 4][..]));
427 assert_eq!(u.next_row(), Some(&[5, 0][..]));
428 assert_eq!(u.next_row(), Some(&[0, 0][..]));
429 assert_eq!(u.next_row(), None);
430 assert!(p.truncated());
431 }
432
433 /// A `/Decode [1 0]` inversion reaches the row through the table.
434 #[test]
435 fn a_decode_inversion_is_in_the_table() {
436 let decode = pdfrum_object::Array::of([
437 pdfrum_object::Object::Real(1.0),
438 pdfrum_object::Object::Real(0.0),
439 ]);
440 let p = Packed::new(
441 vec![0, 255].into(),
442 Depth::Eight,
443 1,
444 2,
445 2,
446 1,
447 &ColorSpace::DeviceGray,
448 Some(&decode),
449 );
450 let mut u = Unpacked::new(&p);
451 assert_eq!(u.next_row(), Some(&[255, 0][..]));
452 }
453
454 #[test]
455 fn collecting_every_row_is_the_whole_image() {
456 let p = packed(&[1, 2, 3, 4], 8, 1, 2, 2);
457 assert_eq!(&*Unpacked::new(&p).collect_all(), &[1, 2, 3, 4]);
458 }
459}