Skip to main content

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}