pdfrum-page 0.1.0

Content-stream interpreter, graphics state, colorspaces, shadings
//! Transfer functions: `/TR` and `/TR2` (ISO 32000-1 §10.4).
//!
//! Sampled once at load into three 256-entry byte tables, one per channel.
//! Three rules govern which functions get sampled:
//!
//! - **`/TR` is skipped entirely when `/TR2` is also present** in the same
//!   dictionary; it is not merged or overridden, it is simply not read.
//! - **A `/TR2` that is a Name stores nothing** — so `/TR2 /Identity` and
//!   `/TR2 /Default` alike disable any transfer function rather than
//!   installing one.
//! - The array form needs **at least three elements**. `[oracle-bug]` element
//!   `i` drives channel `i`, per table 58's `[red green blue gray]` — the
//!   oracle reverses it, see the note on the body below. Any element failing
//!   to load makes the whole transfer function null.
//!
//! # Element `i` drives channel `i`
//!
//! §8.6.5.9 / table 58 give the array as `[red green blue gray]`, so
//! `array[0]` is red and `array[3]` is gray, which we read when present. The
//! oracle reverses the first three and requires exactly three; the
//! derivation is a `//` note below.

// [oracle-bug] A9. `cpdf_docrenderdata.cpp:90` is
// `pFuncs[2 - i] = Load(array[i])` while `:113-114` names `samples[0]` as
// `samples_r`. The consumption side was traced end to end and there is no
// second reversal, so PDFium renders `/TR [fR fG fB]` as `[fB fG fR]` —
// invisible whenever the three functions are equal, which is why it has
// survived. Table 58 also specifies **four** functions, where PDFium requires
// `size() >= 3` and never reads `array[3]`. pdf.js preserves the order
// (`evaluator.js:944-959` pushes in order, `filter_factory.js:212`
// destructures `[tableR, tableG, tableB]`).
//
// It is easy to conclude the opposite from the oracle's own unit test.
// `CPDFDocRenderDataTest.TransferFunctionArray` builds the array
// `[Type0, Type2, Type4]` and asserts `GetSamplesR() ==
// kExpectedType0FunctionSamples` — which reads as "element 0 drives red".
// **The constants are misnamed.** `kExpectedType0FunctionSamples` begins
// `0, 3, 6, 9, 13, 16, …` and ends `…, 250, 253, 0`, which is the *type 4*
// program `{ 360 mul sin 2 div }` sampled at `v / 255` — a full sine period,
// verified to match all 256 entries exactly. `kExpectedType4FunctionSamples`
// is flat at 25/26, which is the *type 0* sampled function's ramp over its
// `/Range [0 0.5]`. Only `kExpectedType2FunctionSamples` is named for the
// function it actually holds.
//
// The test's ten `TranslateColor` pairs settle it independently, without
// having to name any function: `TranslateColor(0x00FFFFFF)` yields
// `0x001A0D00`, and `FX_COLORREF` packs as `(b << 16) | (g << 8) | r`, so
// `samples_r[255] == 0`, `samples_g[255] == 13`, `samples_b[255] == 26`.
// Zero is the type 4 program's last sample, 13 the type 2 function's, and 26
// the type 0 function's — i.e. in the oracle `array[2]` is red and `array[0]`
// is blue, exactly what `pFuncs[2 - i] = Load(array[i])` reads like
// literally.

use crate::function::{Function, FunctionCache};
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{Object, Resolve};

/// Entries per channel.
pub const CHANNEL_SAMPLES: usize = 256;

/// The most outputs a function feeding a transfer function may have.
///
/// A function above this is **skipped and the identity used for that
/// channel**, on both the array and the single-function path.
//
// [oracle-bug] cpdf_docrenderdata.cpp:132-137 guards the `Call` on the
// single-function path — `if (pFuncs[0]->OutputCount() <= kMaxOutputs)` — but
// not the read that follows it, so `FXSYS_roundf(output[0] * 255)` runs over
// a buffer nothing ever wrote. `OutputCount()` is loop-invariant, so the
// guard fails on every one of the 256 iterations and `output[0]` is never
// written at all: the curve comes out all-black. That it is an oversight
// rather than a policy is settled by the **array** branch twelve lines above,
// `:119-122`, which meets the identical condition with `samples[i][v] = v;
// continue;` — the identity, which is what we do on both paths. §8.6.5.9
// gives no reading under which a transfer function whose function is
// unusable should black the channel out. pdf.js has no equivalent: its
// transfer functions are built per array element with no output-count cap
// (`evaluator.js:944-959`), so the case cannot arise there.
pub const MAX_OUTPUTS: usize = 16;

/// Three 256-entry byte tables, one per channel.
#[derive(Debug, Clone, PartialEq)]
pub struct TransferFunc {
    /// The samples, indexed `[channel][input]` with channel 0 red, 1 green
    /// and 2 blue.
    ///
    /// A consumer indexes by channel and nothing else. `[oracle-bug]`
    /// channel 0 holds `/TR`'s **first** element (table 58's `red`), not its
    /// last; see the module note.
    pub samples: Box<[[u8; CHANNEL_SAMPLES]; 3]>,
    /// Whether every entry is its own index, in which case the function is a
    /// no-op and a renderer may skip it entirely.
    pub identity: bool,
}

impl TransferFunc {
    /// Sample a `/TR` or `/TR2` object.
    ///
    /// Returns `None` for every shape PDFium refuses: a name, a
    /// three-or-more-element array with a bad entry, or an object that is
    /// neither a function nor an array of them.
    #[must_use]
    pub fn load<R: Resolve>(
        obj: &Object,
        r: &R,
        cache: &mut FunctionCache,
        limits: &Limits,
        diags: &mut Diagnostics,
    ) -> Option<Self> {
        let resolved = obj.resolve(r).ok()?;
        // A name — `/Identity`, `/Default`, anything — stores nothing.
        if resolved.as_name().is_some() {
            return None;
        }
        let mut samples = Box::new([[0u8; CHANNEL_SAMPLES]; 3]);
        if let Some(array) = resolved.as_array() {
            // `[oracle-bug]` The array form needs three elements and is *not*
            // reversed: element 0 drives red, per table 58's
            // `[red green blue gray]`.
            if array.len() < 3 {
                return None;
            }
            for i in 0..3 {
                let element = array.raw_at(i)?;
                let func = cache.load(element, r, limits, diags)?;
                let channel = samples.get_mut(i)?;
                sample_channel(&func, channel);
            }
        } else {
            let func = cache.load(&resolved, r, limits, diags)?;
            let mut one = [0u8; CHANNEL_SAMPLES];
            sample_channel(&func, &mut one);
            for channel in samples.iter_mut() {
                *channel = one;
            }
        }
        let identity = samples.iter().all(|channel| {
            channel
                .iter()
                .enumerate()
                .all(|(i, v)| usize::from(*v) == i)
        });
        Some(Self { samples, identity })
    }

    /// Apply the function to one colour byte on `channel` — 0 red, 1 green,
    /// 2 blue.
    #[must_use]
    pub fn apply(&self, channel: usize, value: u8) -> u8 {
        self.samples
            .get(channel.min(2))
            .and_then(|c| c.get(usize::from(value)))
            .copied()
            .unwrap_or(value)
    }
}

/// Sample one channel: 256 inputs from `i / 255`, rounded and **saturated**
/// into a byte.
///
/// `[oracle-bug]` A10. §7.10.1 requires the output be clipped to `/Range`
/// before use, which `eval_into` already does, and the byte store here
/// **saturates** rather than wrapping — so a function whose `/Range` admits
/// negatives gives `0x00` at its bottom, not a value folded onto the top of
/// the byte range. The oracle wraps; see the note on the body.
///
/// A function with too many outputs is skipped and the identity used — the
/// `[oracle-bug]` on [`MAX_OUTPUTS`], at the line where it bites.
// [oracle-bug] A10. `cpdf_docrenderdata.cpp:124` is
// `size_t o = FXSYS_roundf(output[0] * 255); samples[i][v] = o;` — no clamp,
// so a function whose `/Range` admits negatives folds its lower half onto the
// **top** of the byte range. The oracle's own type 4 fixture,
// `{ 360 mul sin 2 div }` over `[-1 1]`, is one:
// `CPDFDocRenderDataTest.TransferFunctionArray` pins `-121.26` arriving at
// `0xCC` as `0x87`. It is a bug twice over — a negative float converted to an
// unsigned integer type is undefined behaviour in C++. The clip to `/Range`
// is in `function/mod.rs`'s `eval_into`; the remaining step is that the byte
// store saturate rather than wrap. pdf.js clamps to `/Range`
// (`function.js:265`) and then to the byte range (`evaluator.js:888-896`).
// We saturate, so `-121.26` is `0x00`.
fn sample_channel(func: &Function, out: &mut [u8; CHANNEL_SAMPLES]) {
    if func.output_count() > MAX_OUTPUTS {
        for (i, slot) in out.iter_mut().enumerate() {
            *slot = u8::try_from(i).unwrap_or(u8::MAX);
        }
        return;
    }
    let mut results = vec![0.0f32; func.output_count().max(1)];
    for (i, slot) in out.iter_mut().enumerate() {
        #[expect(
            clippy::cast_precision_loss,
            reason = "an index below 256 is exact in f32"
        )]
        let input = (i as f32) / 255.0;
        let identity = u8::try_from(i).unwrap_or(u8::MAX);
        if func.eval_into(&[input], &mut results) == 0 {
            *slot = identity;
            continue;
        }
        let value = results.first().copied().unwrap_or(0.0);
        *slot = saturate_to_byte(value * 255.0);
    }
}

/// Round to the nearest integer and saturate into a byte.
///
/// `[oracle-bug]` A10 — see [`sample_channel`]. A non-finite sample has no
/// meaningful byte and becomes zero.
fn saturate_to_byte(value: f32) -> u8 {
    let rounded = value.round();
    if !rounded.is_finite() {
        return 0;
    }
    #[expect(
        clippy::cast_possible_truncation,
        clippy::cast_sign_loss,
        reason = "the clamp bounds the value to 0.0..=255.0"
    )]
    let saturated = rounded.clamp(0.0, 255.0) as u8;
    saturated
}

#[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,
        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
    )]

    use super::{CHANNEL_SAMPLES, TransferFunc};
    use crate::function::FunctionCache;
    use pdfrum_common::{Diagnostics, Limits};
    use pdfrum_object::{Array, Dict, Name, NoResolve, Object};

    fn nums(values: &[f32]) -> Object {
        Object::Array(Array::of(values.iter().copied().map(Object::Real)))
    }

    /// A type 2 function mapping `t` to `1 - t`.
    fn invert() -> Object {
        Object::Dict(Dict::from_pairs([
            (Name::from("FunctionType"), Object::Int(2)),
            (Name::from("Domain"), nums(&[0.0, 1.0])),
            (Name::from("N"), Object::Int(1)),
            (Name::from("C0"), nums(&[1.0])),
            (Name::from("C1"), nums(&[0.0])),
        ]))
    }

    fn load(obj: &Object) -> Option<TransferFunc> {
        let mut cache = FunctionCache::new();
        let mut diags = Diagnostics::default();
        TransferFunc::load(obj, &NoResolve, &mut cache, &Limits::default(), &mut diags)
    }

    #[test]
    fn a_single_function_applies_to_every_channel() {
        let tr = load(&invert()).expect("should load");
        assert_eq!(tr.apply(0, 0), 255);
        assert_eq!(tr.apply(1, 255), 0);
        assert_eq!(tr.apply(2, 128), 127);
        assert!(!tr.identity);
    }

    #[test]
    fn a_name_stores_nothing() {
        for name in ["Identity", "Default", "Anything"] {
            assert!(
                load(&Object::Name(Name::from(name))).is_none(),
                "/{name} should disable the transfer function"
            );
        }
    }

    #[test]
    fn the_array_form_needs_three_elements() {
        let two = Object::Array(Array::of([invert(), invert()]));
        assert!(load(&two).is_none());
        let three = Object::Array(Array::of([invert(), invert(), invert()]));
        assert!(load(&three).is_some());
    }

    #[test]
    fn a_bad_element_makes_the_whole_function_null() {
        let bad = Object::Array(Array::of([invert(), Object::Int(7), invert()]));
        assert!(load(&bad).is_none());
    }

    /// A type 2 function that is constant at `v` for every input.
    fn constant(v: f32) -> Object {
        Object::Dict(Dict::from_pairs([
            (Name::from("FunctionType"), Object::Int(2)),
            (Name::from("Domain"), nums(&[0.0, 1.0])),
            (Name::from("N"), Object::Int(1)),
            (Name::from("C0"), nums(&[v])),
            (Name::from("C1"), nums(&[v])),
        ]))
    }

    /// Table 58 gives the array as `[red green blue gray]`, so element `i`
    /// drives channel `i`. The oracle reverses it — `array[2]` red,
    /// `array[0]` blue.
    #[test]
    fn the_first_array_element_drives_red() {
        // Three constants no two of which collide, so the mapping of array
        // position to channel reads straight off the output bytes.
        let array = Object::Array(Array::of([
            constant(10.0 / 255.0),
            constant(100.0 / 255.0),
            constant(200.0 / 255.0),
        ]));
        let tr = load(&array).expect("should load");
        // Read through the public accessor, not the raw slots: this is the
        // observable the render crate consumes.
        assert_eq!(tr.apply(0, 0), 10, "array[0] must drive red");
        assert_eq!(tr.apply(1, 0), 100, "array[1] must drive green");
        assert_eq!(tr.apply(2, 0), 200, "array[2] must drive blue");
    }

    /// The array order survives to the bytes a renderer reads. The inverting
    /// function is in the **first** slot, because table 58 makes that one red.
    #[test]
    fn the_array_order_survives_to_the_output_bytes() {
        let identity = constant_ramp();
        let array = Object::Array(Array::of([invert(), identity.clone(), identity]));
        let tr = load(&array).expect("should load");
        assert!(!tr.identity);
        assert_eq!(tr.apply(0, 0), 255, "red inverts");
        assert_eq!(tr.apply(1, 0), 0, "green is the identity");
        assert_eq!(tr.apply(2, 0), 0, "blue is the identity");
    }

    /// A type 2 function mapping `t` to `t`.
    fn constant_ramp() -> Object {
        Object::Dict(Dict::from_pairs([
            (Name::from("FunctionType"), Object::Int(2)),
            (Name::from("Domain"), nums(&[0.0, 1.0])),
            (Name::from("N"), Object::Int(1)),
            (Name::from("C0"), nums(&[0.0])),
            (Name::from("C1"), nums(&[1.0])),
        ]))
    }

    #[test]
    fn an_identity_function_is_recognised_as_a_no_op() {
        let tr = load(&constant_ramp()).expect("should load");
        assert!(tr.identity);
        assert_eq!(tr.samples[0].len(), CHANNEL_SAMPLES);
    }
}