pdfrum_page/transfer.rs
1//! Transfer functions: `/TR` and `/TR2` (ISO 32000-1 §10.4).
2//!
3//! Sampled once at load into three 256-entry byte tables, one per channel.
4//! Three rules govern which functions get sampled:
5//!
6//! - **`/TR` is skipped entirely when `/TR2` is also present** in the same
7//! dictionary; it is not merged or overridden, it is simply not read.
8//! - **A `/TR2` that is a Name stores nothing** — so `/TR2 /Identity` and
9//! `/TR2 /Default` alike disable any transfer function rather than
10//! installing one.
11//! - The array form needs **at least three elements**. `[oracle-bug]` element
12//! `i` drives channel `i`, per table 58's `[red green blue gray]` — the
13//! oracle reverses it, see the note on the body below. Any element failing
14//! to load makes the whole transfer function null.
15//!
16//! # Element `i` drives channel `i`
17//!
18//! §8.6.5.9 / table 58 give the array as `[red green blue gray]`, so
19//! `array[0]` is red and `array[3]` is gray, which we read when present. The
20//! oracle reverses the first three and requires exactly three; the
21//! derivation is a `//` note below.
22
23// [oracle-bug] A9. `cpdf_docrenderdata.cpp:90` is
24// `pFuncs[2 - i] = Load(array[i])` while `:113-114` names `samples[0]` as
25// `samples_r`. The consumption side was traced end to end and there is no
26// second reversal, so PDFium renders `/TR [fR fG fB]` as `[fB fG fR]` —
27// invisible whenever the three functions are equal, which is why it has
28// survived. Table 58 also specifies **four** functions, where PDFium requires
29// `size() >= 3` and never reads `array[3]`. pdf.js preserves the order
30// (`evaluator.js:944-959` pushes in order, `filter_factory.js:212`
31// destructures `[tableR, tableG, tableB]`).
32//
33// It is easy to conclude the opposite from the oracle's own unit test.
34// `CPDFDocRenderDataTest.TransferFunctionArray` builds the array
35// `[Type0, Type2, Type4]` and asserts `GetSamplesR() ==
36// kExpectedType0FunctionSamples` — which reads as "element 0 drives red".
37// **The constants are misnamed.** `kExpectedType0FunctionSamples` begins
38// `0, 3, 6, 9, 13, 16, …` and ends `…, 250, 253, 0`, which is the *type 4*
39// program `{ 360 mul sin 2 div }` sampled at `v / 255` — a full sine period,
40// verified to match all 256 entries exactly. `kExpectedType4FunctionSamples`
41// is flat at 25/26, which is the *type 0* sampled function's ramp over its
42// `/Range [0 0.5]`. Only `kExpectedType2FunctionSamples` is named for the
43// function it actually holds.
44//
45// The test's ten `TranslateColor` pairs settle it independently, without
46// having to name any function: `TranslateColor(0x00FFFFFF)` yields
47// `0x001A0D00`, and `FX_COLORREF` packs as `(b << 16) | (g << 8) | r`, so
48// `samples_r[255] == 0`, `samples_g[255] == 13`, `samples_b[255] == 26`.
49// Zero is the type 4 program's last sample, 13 the type 2 function's, and 26
50// the type 0 function's — i.e. in the oracle `array[2]` is red and `array[0]`
51// is blue, exactly what `pFuncs[2 - i] = Load(array[i])` reads like
52// literally.
53
54use crate::function::{Function, FunctionCache};
55use pdfrum_common::{Diagnostics, Limits};
56use pdfrum_object::{Object, Resolve};
57
58/// Entries per channel.
59pub const CHANNEL_SAMPLES: usize = 256;
60
61/// The most outputs a function feeding a transfer function may have.
62///
63/// A function above this is **skipped and the identity used for that
64/// channel**, on both the array and the single-function path.
65//
66// [oracle-bug] cpdf_docrenderdata.cpp:132-137 guards the `Call` on the
67// single-function path — `if (pFuncs[0]->OutputCount() <= kMaxOutputs)` — but
68// not the read that follows it, so `FXSYS_roundf(output[0] * 255)` runs over
69// a buffer nothing ever wrote. `OutputCount()` is loop-invariant, so the
70// guard fails on every one of the 256 iterations and `output[0]` is never
71// written at all: the curve comes out all-black. That it is an oversight
72// rather than a policy is settled by the **array** branch twelve lines above,
73// `:119-122`, which meets the identical condition with `samples[i][v] = v;
74// continue;` — the identity, which is what we do on both paths. §8.6.5.9
75// gives no reading under which a transfer function whose function is
76// unusable should black the channel out. pdf.js has no equivalent: its
77// transfer functions are built per array element with no output-count cap
78// (`evaluator.js:944-959`), so the case cannot arise there.
79pub const MAX_OUTPUTS: usize = 16;
80
81/// Three 256-entry byte tables, one per channel.
82#[derive(Debug, Clone, PartialEq)]
83pub struct TransferFunc {
84 /// The samples, indexed `[channel][input]` with channel 0 red, 1 green
85 /// and 2 blue.
86 ///
87 /// A consumer indexes by channel and nothing else. `[oracle-bug]`
88 /// channel 0 holds `/TR`'s **first** element (table 58's `red`), not its
89 /// last; see the module note.
90 pub samples: Box<[[u8; CHANNEL_SAMPLES]; 3]>,
91 /// Whether every entry is its own index, in which case the function is a
92 /// no-op and a renderer may skip it entirely.
93 pub identity: bool,
94}
95
96impl TransferFunc {
97 /// Sample a `/TR` or `/TR2` object.
98 ///
99 /// Returns `None` for every shape PDFium refuses: a name, a
100 /// three-or-more-element array with a bad entry, or an object that is
101 /// neither a function nor an array of them.
102 #[must_use]
103 pub fn load<R: Resolve>(
104 obj: &Object,
105 r: &R,
106 cache: &mut FunctionCache,
107 limits: &Limits,
108 diags: &mut Diagnostics,
109 ) -> Option<Self> {
110 let resolved = obj.resolve(r).ok()?;
111 // A name — `/Identity`, `/Default`, anything — stores nothing.
112 if resolved.as_name().is_some() {
113 return None;
114 }
115 let mut samples = Box::new([[0u8; CHANNEL_SAMPLES]; 3]);
116 if let Some(array) = resolved.as_array() {
117 // `[oracle-bug]` The array form needs three elements and is *not*
118 // reversed: element 0 drives red, per table 58's
119 // `[red green blue gray]`.
120 if array.len() < 3 {
121 return None;
122 }
123 for i in 0..3 {
124 let element = array.raw_at(i)?;
125 let func = cache.load(element, r, limits, diags)?;
126 let channel = samples.get_mut(i)?;
127 sample_channel(&func, channel);
128 }
129 } else {
130 let func = cache.load(&resolved, r, limits, diags)?;
131 let mut one = [0u8; CHANNEL_SAMPLES];
132 sample_channel(&func, &mut one);
133 for channel in samples.iter_mut() {
134 *channel = one;
135 }
136 }
137 let identity = samples.iter().all(|channel| {
138 channel
139 .iter()
140 .enumerate()
141 .all(|(i, v)| usize::from(*v) == i)
142 });
143 Some(Self { samples, identity })
144 }
145
146 /// Apply the function to one colour byte on `channel` — 0 red, 1 green,
147 /// 2 blue.
148 #[must_use]
149 pub fn apply(&self, channel: usize, value: u8) -> u8 {
150 self.samples
151 .get(channel.min(2))
152 .and_then(|c| c.get(usize::from(value)))
153 .copied()
154 .unwrap_or(value)
155 }
156}
157
158/// Sample one channel: 256 inputs from `i / 255`, rounded and **saturated**
159/// into a byte.
160///
161/// `[oracle-bug]` A10. §7.10.1 requires the output be clipped to `/Range`
162/// before use, which `eval_into` already does, and the byte store here
163/// **saturates** rather than wrapping — so a function whose `/Range` admits
164/// negatives gives `0x00` at its bottom, not a value folded onto the top of
165/// the byte range. The oracle wraps; see the note on the body.
166///
167/// A function with too many outputs is skipped and the identity used — the
168/// `[oracle-bug]` on [`MAX_OUTPUTS`], at the line where it bites.
169// [oracle-bug] A10. `cpdf_docrenderdata.cpp:124` is
170// `size_t o = FXSYS_roundf(output[0] * 255); samples[i][v] = o;` — no clamp,
171// so a function whose `/Range` admits negatives folds its lower half onto the
172// **top** of the byte range. The oracle's own type 4 fixture,
173// `{ 360 mul sin 2 div }` over `[-1 1]`, is one:
174// `CPDFDocRenderDataTest.TransferFunctionArray` pins `-121.26` arriving at
175// `0xCC` as `0x87`. It is a bug twice over — a negative float converted to an
176// unsigned integer type is undefined behaviour in C++. The clip to `/Range`
177// is in `function/mod.rs`'s `eval_into`; the remaining step is that the byte
178// store saturate rather than wrap. pdf.js clamps to `/Range`
179// (`function.js:265`) and then to the byte range (`evaluator.js:888-896`).
180// We saturate, so `-121.26` is `0x00`.
181fn sample_channel(func: &Function, out: &mut [u8; CHANNEL_SAMPLES]) {
182 if func.output_count() > MAX_OUTPUTS {
183 for (i, slot) in out.iter_mut().enumerate() {
184 *slot = u8::try_from(i).unwrap_or(u8::MAX);
185 }
186 return;
187 }
188 let mut results = vec![0.0f32; func.output_count().max(1)];
189 for (i, slot) in out.iter_mut().enumerate() {
190 #[expect(
191 clippy::cast_precision_loss,
192 reason = "an index below 256 is exact in f32"
193 )]
194 let input = (i as f32) / 255.0;
195 let identity = u8::try_from(i).unwrap_or(u8::MAX);
196 if func.eval_into(&[input], &mut results) == 0 {
197 *slot = identity;
198 continue;
199 }
200 let value = results.first().copied().unwrap_or(0.0);
201 *slot = saturate_to_byte(value * 255.0);
202 }
203}
204
205/// Round to the nearest integer and saturate into a byte.
206///
207/// `[oracle-bug]` A10 — see [`sample_channel`]. A non-finite sample has no
208/// meaningful byte and becomes zero.
209fn saturate_to_byte(value: f32) -> u8 {
210 let rounded = value.round();
211 if !rounded.is_finite() {
212 return 0;
213 }
214 #[expect(
215 clippy::cast_possible_truncation,
216 clippy::cast_sign_loss,
217 reason = "the clamp bounds the value to 0.0..=255.0"
218 )]
219 let saturated = rounded.clamp(0.0, 255.0) as u8;
220 saturated
221}
222
223#[cfg(test)]
224mod tests {
225 // Test fixtures quote the oracle's own vectors, compare floats exactly
226 // where the behaviour being pinned is exact, and index arrays whose
227 // length the fixture itself fixes.
228 #![allow(
229 clippy::unreadable_literal,
230 clippy::float_cmp,
231 clippy::indexing_slicing,
232 clippy::cast_precision_loss,
233 clippy::cast_possible_truncation,
234 reason = "test fixtures quote oracle vectors verbatim and compare exactly"
235 )]
236
237 use super::{CHANNEL_SAMPLES, TransferFunc};
238 use crate::function::FunctionCache;
239 use pdfrum_common::{Diagnostics, Limits};
240 use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
241
242 fn nums(values: &[f32]) -> Object {
243 Object::Array(Array::of(values.iter().copied().map(Object::Real)))
244 }
245
246 /// A type 2 function mapping `t` to `1 - t`.
247 fn invert() -> Object {
248 Object::Dict(Dict::from_pairs([
249 (Name::from("FunctionType"), Object::Int(2)),
250 (Name::from("Domain"), nums(&[0.0, 1.0])),
251 (Name::from("N"), Object::Int(1)),
252 (Name::from("C0"), nums(&[1.0])),
253 (Name::from("C1"), nums(&[0.0])),
254 ]))
255 }
256
257 fn load(obj: &Object) -> Option<TransferFunc> {
258 let mut cache = FunctionCache::new();
259 let mut diags = Diagnostics::default();
260 TransferFunc::load(obj, &NoResolve, &mut cache, &Limits::default(), &mut diags)
261 }
262
263 #[test]
264 fn a_single_function_applies_to_every_channel() {
265 let tr = load(&invert()).expect("should load");
266 assert_eq!(tr.apply(0, 0), 255);
267 assert_eq!(tr.apply(1, 255), 0);
268 assert_eq!(tr.apply(2, 128), 127);
269 assert!(!tr.identity);
270 }
271
272 #[test]
273 fn a_name_stores_nothing() {
274 for name in ["Identity", "Default", "Anything"] {
275 assert!(
276 load(&Object::Name(Name::from(name))).is_none(),
277 "/{name} should disable the transfer function"
278 );
279 }
280 }
281
282 #[test]
283 fn the_array_form_needs_three_elements() {
284 let two = Object::Array(Array::of([invert(), invert()]));
285 assert!(load(&two).is_none());
286 let three = Object::Array(Array::of([invert(), invert(), invert()]));
287 assert!(load(&three).is_some());
288 }
289
290 #[test]
291 fn a_bad_element_makes_the_whole_function_null() {
292 let bad = Object::Array(Array::of([invert(), Object::Int(7), invert()]));
293 assert!(load(&bad).is_none());
294 }
295
296 /// A type 2 function that is constant at `v` for every input.
297 fn constant(v: f32) -> Object {
298 Object::Dict(Dict::from_pairs([
299 (Name::from("FunctionType"), Object::Int(2)),
300 (Name::from("Domain"), nums(&[0.0, 1.0])),
301 (Name::from("N"), Object::Int(1)),
302 (Name::from("C0"), nums(&[v])),
303 (Name::from("C1"), nums(&[v])),
304 ]))
305 }
306
307 /// Table 58 gives the array as `[red green blue gray]`, so element `i`
308 /// drives channel `i`. The oracle reverses it — `array[2]` red,
309 /// `array[0]` blue.
310 #[test]
311 fn the_first_array_element_drives_red() {
312 // Three constants no two of which collide, so the mapping of array
313 // position to channel reads straight off the output bytes.
314 let array = Object::Array(Array::of([
315 constant(10.0 / 255.0),
316 constant(100.0 / 255.0),
317 constant(200.0 / 255.0),
318 ]));
319 let tr = load(&array).expect("should load");
320 // Read through the public accessor, not the raw slots: this is the
321 // observable the render crate consumes.
322 assert_eq!(tr.apply(0, 0), 10, "array[0] must drive red");
323 assert_eq!(tr.apply(1, 0), 100, "array[1] must drive green");
324 assert_eq!(tr.apply(2, 0), 200, "array[2] must drive blue");
325 }
326
327 /// The array order survives to the bytes a renderer reads. The inverting
328 /// function is in the **first** slot, because table 58 makes that one red.
329 #[test]
330 fn the_array_order_survives_to_the_output_bytes() {
331 let identity = constant_ramp();
332 let array = Object::Array(Array::of([invert(), identity.clone(), identity]));
333 let tr = load(&array).expect("should load");
334 assert!(!tr.identity);
335 assert_eq!(tr.apply(0, 0), 255, "red inverts");
336 assert_eq!(tr.apply(1, 0), 0, "green is the identity");
337 assert_eq!(tr.apply(2, 0), 0, "blue is the identity");
338 }
339
340 /// A type 2 function mapping `t` to `t`.
341 fn constant_ramp() -> Object {
342 Object::Dict(Dict::from_pairs([
343 (Name::from("FunctionType"), Object::Int(2)),
344 (Name::from("Domain"), nums(&[0.0, 1.0])),
345 (Name::from("N"), Object::Int(1)),
346 (Name::from("C0"), nums(&[0.0])),
347 (Name::from("C1"), nums(&[1.0])),
348 ]))
349 }
350
351 #[test]
352 fn an_identity_function_is_recognised_as_a_no_op() {
353 let tr = load(&constant_ramp()).expect("should load");
354 assert!(tr.identity);
355 assert_eq!(tr.samples[0].len(), CHANNEL_SAMPLES);
356 }
357}