1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
//! `/Decode`: remapping raw samples onto component values
//! (ISO 32000-1 §8.9.5.2).
//!
//! Each component gets a base and a step, and the value of a raw sample is
//! `base + step * raw`. Two rules matter:
//!
//! - **A short or malformed array is not rejected.** Out-of-range reads yield
//! `0.0`, so a missing pair makes that component a constant zero — and, by
//! differing from the default, flips `default_decode` to false.
//! - **`Indexed` uses the raw sample maximum as its high bound**, not the
//! base space's, because an indexed sample *is* an index.
use crate::color::ColorSpace;
use pdfrum_object::Array;
/// The per-component remapping.
#[derive(Debug, Clone, PartialEq)]
pub struct DecodeMap {
/// The value a raw sample of zero maps to, per component.
pub base: Box<[f32]>,
/// The value one raw step adds, per component.
pub step: Box<[f32]>,
/// Whether the mapping is the space's own default, which is what a
/// one-bit stencil mask's inversion keys on.
pub default: bool,
}
impl DecodeMap {
/// Build the mapping for `space` at `bpc` bits per component.
#[must_use]
pub fn new(
space: Option<&ColorSpace>,
components: usize,
bpc: u32,
decode: Option<&Array>,
) -> Self {
let max_data = if bpc >= 32 {
f32::from(u16::MAX)
} else {
#[expect(
clippy::cast_precision_loss,
reason = "bit depths cap at 16, so the value is exact in f32"
)]
let v = ((1u64 << bpc.min(31)) - 1) as f32;
v
};
let indexed = matches!(space, Some(ColorSpace::Indexed(_)));
let mut base = Vec::with_capacity(components);
let mut step = Vec::with_capacity(components);
let mut default = true;
for i in 0..components {
let (def_min, mut def_max) = space.map_or((0.0, 1.0), |s| {
let (_, lo, hi) = s.default_value(i);
(lo, hi)
});
// An indexed sample is an index, so its high bound is the largest
// raw value rather than the base space's maximum.
if indexed {
def_max = max_data;
}
let (lo, hi) = if let Some(a) = decode {
// Out-of-range reads yield zero rather than failing.
let stated = (a.number_at_or_zero(i * 2), a.number_at_or_zero(i * 2 + 1));
#[expect(
clippy::float_cmp,
reason = "PDFium compares these exactly, and a hair's \
difference really does flip `default_decode`"
)]
if def_min != stated.0 || def_max != stated.1 {
default = false;
}
stated
} else {
(def_min, def_max)
};
base.push(lo);
step.push(if max_data == 0.0 {
0.0
} else {
(hi - lo) / max_data
});
}
Self {
base: base.into(),
step: step.into(),
default,
}
}
/// Apply the mapping to one raw sample of component `index`.
#[must_use]
pub fn apply(&self, index: usize, raw: f32) -> f32 {
self.base.get(index).copied().unwrap_or(0.0)
+ self.step.get(index).copied().unwrap_or(0.0) * raw
}
}
#[cfg(test)]
mod tests {
// Test fixtures quote the oracle's own vectors, compare floats exactly
// where the behaviour being pinned is exact, and index arrays whose
// length the fixture itself fixes.
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::DecodeMap;
use crate::color::{ColorSpace, Indexed};
use pdfrum_object::{Array, Object};
fn array(values: &[f32]) -> Array {
Array::of(values.iter().copied().map(Object::Real))
}
#[test]
fn the_default_mapping_spans_zero_to_one() {
let m = DecodeMap::new(Some(&ColorSpace::DeviceRgb), 3, 8, None);
assert!(m.default);
assert!(m.apply(0, 0.0).abs() < 1e-6);
assert!((m.apply(0, 255.0) - 1.0).abs() < 1e-6);
assert!((m.apply(1, 128.0) - 128.0 / 255.0).abs() < 1e-6);
}
#[test]
fn an_inverted_decode_reverses_the_ramp_and_is_not_default() {
let m = DecodeMap::new(
Some(&ColorSpace::DeviceGray),
1,
8,
Some(&array(&[1.0, 0.0])),
);
assert!(!m.default);
assert!((m.apply(0, 0.0) - 1.0).abs() < 1e-6);
assert!(m.apply(0, 255.0).abs() < 1e-6);
}
#[test]
fn an_explicit_identity_decode_still_counts_as_default() {
let m = DecodeMap::new(
Some(&ColorSpace::DeviceGray),
1,
8,
Some(&array(&[0.0, 1.0])),
);
assert!(m.default);
}
#[test]
fn a_short_array_makes_the_missing_component_a_constant_zero() {
// Three components but only one pair stated.
let m = DecodeMap::new(
Some(&ColorSpace::DeviceRgb),
3,
8,
Some(&array(&[0.0, 1.0])),
);
assert!(!m.default, "the missing pairs differ from the default");
assert!((m.apply(0, 255.0) - 1.0).abs() < 1e-6);
// Components one and two read `[0, 0]`, a constant zero.
assert!(m.apply(1, 255.0).abs() < 1e-6);
assert!(m.apply(2, 255.0).abs() < 1e-6);
}
#[test]
fn an_indexed_space_maps_onto_the_raw_sample_range() {
let indexed = ColorSpace::Indexed(Box::new(Indexed {
base: Box::new(ColorSpace::DeviceRgb),
max_index: 3,
lookup: Box::from(&[0u8; 12][..]),
component_ranges: Box::from(&[(0.0f32, 1.0f32); 3][..]),
}));
// At four bits the top raw sample is 15, and it must map to 15 rather
// than to the base space's 1.0.
let m = DecodeMap::new(Some(&indexed), 1, 4, None);
assert!(m.default);
assert!((m.apply(0, 15.0) - 15.0).abs() < 1e-5);
assert!((m.apply(0, 3.0) - 3.0).abs() < 1e-5);
}
#[test]
fn a_zero_bit_depth_yields_a_flat_mapping_rather_than_a_division_by_zero() {
let m = DecodeMap::new(Some(&ColorSpace::DeviceGray), 1, 0, None);
assert!(m.apply(0, 99.0).is_finite());
}
/// A 16-bit sample is a value in `[0, 65535]` mapped linearly onto the
/// `/Decode` range (ISO 32000-1 §8.9.5) and then **rounded** onto a byte.
/// pdf.js computes exactly that (`DeviceRgbCS.getRgbBuffer`,
/// `src/core/colorspace.js`: `scale = 255 / ((1 << bits) - 1)` stored into
/// a rounding `Uint8ClampedArray`); the oracle approximates it by dropping
/// the low byte, which is within one count everywhere. We take the exact
/// map.
///
/// Two properties are asserted for all 65 536 samples, which is what makes
/// this a pin and not a spot check:
///
/// 1. the byte equals `round(sample * 255 / 65535)` computed in `f64`, so
/// the `f32` decode arithmetic is proven not to drift off it; and
/// 2. it is within one count of PDFium's `sample >> 8`, so a divergence
/// from the oracle can never grow past the rounding difference it is.
#[test]
fn sixteen_bit_samples_round_onto_the_byte_and_stay_within_a_count_of_the_oracle() {
let m = DecodeMap::new(Some(&ColorSpace::DeviceGray), 1, 16, None);
let mut differ_from_oracle = 0u32;
for raw in 0u32..=65535 {
let byte = (m.apply(0, raw as f32).clamp(0.0, 1.0) * 255.0).round() as u8;
let exact = (f64::from(raw) * 255.0 / 65535.0).round() as u8;
assert_eq!(byte, exact, "sample {raw} must be the exact rounded map");
// PDFium's fast path drops the low byte instead of rounding.
let oracle = (raw >> 8) as u8;
let gap = i32::from(byte) - i32::from(oracle);
assert!(gap.abs() <= 1, "sample {raw}: {byte} vs oracle {oracle}");
if gap != 0 {
differ_from_oracle += 1;
}
}
// 16 256 of 65 536 — the rest of the range is where dropping the low
// byte happens to land on the rounded answer anyway. Pinned so the
// count of samples that differ from the oracle is a number in the
// tree rather than a claim in a doc.
assert_eq!(differ_from_oracle, 16_256);
}
/// Below 16 bits, rounding, truncating and the oracle's integer scale
/// agree on every raw value.
///
/// `step` is exactly `1/max` and the products are small enough to be exact
/// in `f32`, so rounding and truncating agree on every raw value at 1, 2, 4
/// and 8 bits — and both agree with the oracle's integer `v * 255 / max`.
#[test]
fn below_sixteen_bits_rounding_truncating_and_the_oracles_integer_scale_agree() {
for bpc in [1u32, 2, 4, 8] {
let max = (1u32 << bpc) - 1;
let m = DecodeMap::new(Some(&ColorSpace::DeviceGray), 1, bpc, None);
for raw in 0..=max {
let value = m.apply(0, raw as f32).clamp(0.0, 1.0) * 255.0;
let integer = u8::try_from(raw * 255 / max).unwrap();
assert_eq!(value.round() as u8, integer, "bpc {bpc} raw {raw}");
assert_eq!(value as u8, integer, "bpc {bpc} raw {raw} truncated");
}
}
}
}