Merge pull request #575 from haranishi/fix/pdf-sampled-function-size

This commit is contained in:
Claude committed 2026-10-10 12:51:15 +00:00
commit 79003f7598
2 files changed
+639 -59

No files matched your search

+302 -59
View File
@@ -1,6 +1,9 @@
//! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled,
//! exponential, stitching and PostScript calculator functions).
use std::collections::HashMap;
use std::sync::Arc;
use crate::object::{Dict, File, Obj, decode_stream};
#[derive(Clone, Debug, PartialEq)]
@@ -79,9 +82,23 @@ fn lab_to_rgb(l: f64, a: f64, b: f64) -> [f64; 3] {
})
}
/// How deep colour spaces nest (an Indexed base, the alternate of an ICCBased, Separation or
/// DeviceN space) and, counted separately, functions (in arrays and stitching functions).
/// References can make either a cycle, while real files nest two or three levels; anything deeper
/// reads as unreadable (DeviceGray, an unsupported function).
const MAX_DEPTH: usize = 8;
/// Read a colour space (a name, or an array such as `[/ICCBased 5 0 R]`); named resources are
/// looked up in `res` (`/ColorSpace`).
pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
color_space_at(file, o, res, 0)
}
/// [`color_space`] nested `depth` levels deep: DeviceGray past [`MAX_DEPTH`].
fn color_space_at(file: &File, o: &Obj, res: Option<&Dict>, depth: usize) -> Cs {
if depth >= MAX_DEPTH {
return Cs::Gray;
}
let o = file.resolve(o);
match o {
Obj::Name(n) => match n.as_str() {
@@ -90,7 +107,7 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
"DeviceCMYK" | "CMYK" => Cs::Cmyk,
"Pattern" => Cs::Pattern,
other => match res.and_then(|r| file.get_dict(r, "ColorSpace")).and_then(|cs| file.get(cs, other)) {
Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space(file, x, None),
Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space_at(file, x, None, depth + 1),
_ => Cs::Gray,
},
},
@@ -102,7 +119,7 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
// The profile's alternate space when it names one (profiles are not
// evaluated), else the device space with the same number of components.
if let Some(alt) = d.and_then(|d| file.get(d, "Alternate")) {
return color_space(file, alt, res);
return color_space_at(file, alt, res, depth + 1);
}
let n = d.and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize;
match n {
@@ -116,8 +133,10 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
"Lab" => Cs::Lab,
"Pattern" => Cs::Pattern,
"Indexed" | "I" => {
let base = a.get(1).map(|b| color_space(file, b, res)).unwrap_or(Cs::Rgb);
let hival = a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize;
let base = a.get(1).map(|b| color_space_at(file, b, res, depth + 1)).unwrap_or(Cs::Rgb);
// An indexed palette holds at most 256 entries, so hival (its largest index) is
// 0 to 255; an image builds its palette from every entry up to hival.
let hival = (a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize).min(255);
let lookup = match a.get(3).map(|l| file.resolve(l)) {
Some(Obj::Str(s)) => s.clone(),
Some(Obj::Stream(d, raw)) => decode_stream(file, d, raw).unwrap_or_default(),
@@ -127,7 +146,7 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
}
"Separation" | "DeviceN" => {
let n = if head == "Separation" { 1 } else { a.get(1).map(|x| file.resolve(x)).and_then(Obj::array).map_or(1, <[Obj]>::len) };
let alt = a.get(2).map(|b| color_space(file, b, res)).unwrap_or(Cs::Gray);
let alt = a.get(2).map(|b| color_space_at(file, b, res, depth + 1)).unwrap_or(Cs::Gray);
let func = a.get(3).map(|f| Func::read(file, f)).unwrap_or(Func::Unsupported);
// An unreadable tint transform: tint 1 = black, 0 = white.
if func == Func::Unsupported {
@@ -158,14 +177,14 @@ pub enum Func {
bounds: Vec<f64>,
encode: Vec<f64>,
},
/// Type 0 with one input.
/// Type 0 with one input. Copies share the samples (a graphics state is copied at every `q`).
Sampled {
domain: [f64; 2],
size: usize,
outputs: usize,
encode: [f64; 2],
decode: Vec<f64>,
samples: Vec<f64>,
samples: Arc<[f64]>,
},
/// Type 0 with several inputs (function-based shadings): multilinear interpolation, the
/// first input varying fastest in the sample table.
@@ -175,7 +194,7 @@ pub enum Func {
outputs: usize,
encode: Vec<f64>,
decode: Vec<f64>,
samples: Vec<f64>,
samples: Arc<[f64]>,
},
/// An array of one-output functions.
Array(Vec<Func>),
@@ -215,6 +234,17 @@ fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec<PsOp> {
out
}
/// A calculator function's operand stack holds at most 100 values (the PDF specification's
/// implementation limits); a real program uses a handful.
const MAX_STACK: usize = 100;
/// Push onto a calculator's operand stack; a full stack takes nothing more.
fn ps_push(st: &mut Vec<f64>, v: f64) {
if st.len() < MAX_STACK {
st.push(v);
}
}
fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
if depth > 32 {
return;
@@ -222,7 +252,7 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
let mut procs: Vec<&[PsOp]> = vec![];
for op in code {
match op {
PsOp::Num(n) => st.push(*n),
PsOp::Num(n) => ps_push(st, *n),
PsOp::Proc(p) => procs.push(p),
PsOp::Op(o) => {
let pop = |st: &mut Vec<f64>| st.pop().unwrap_or(0.0);
@@ -243,14 +273,14 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
ps_run(p, st, depth + 1);
}
}
"true" => st.push(1.0),
"false" => st.push(0.0),
"true" => ps_push(st, 1.0),
"false" => ps_push(st, 0.0),
"pop" => {
st.pop();
}
"dup" => {
let v = st.last().copied().unwrap_or(0.0);
st.push(v);
ps_push(st, v);
}
"exch" => {
let n = st.len();
@@ -261,15 +291,16 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
"copy" => {
let k = pop(st).max(0.0) as usize;
let n = st.len();
if k <= n {
let tail = st[n - k..].to_vec();
// A copy that would pass the cap copies nothing.
if k <= n && n + k <= MAX_STACK {
let tail = st.get(n - k..).unwrap_or_default().to_vec();
st.extend(tail);
}
}
"index" => {
let k = pop(st).max(0.0) as usize;
let v = st.len().checked_sub(k + 1).map(|i| st[i]).unwrap_or(0.0);
st.push(v);
let v = k.checked_add(1).and_then(|k| st.len().checked_sub(k)).and_then(|i| st.get(i)).copied().unwrap_or(0.0);
ps_push(st, v);
}
"roll" => {
let j = pop(st) as i64;
@@ -309,7 +340,7 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
};
if let Some(f) = unary {
let x = pop(st);
st.push(f(x));
ps_push(st, f(x));
continue;
}
let y = pop(st);
@@ -325,20 +356,10 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
0.0
}
}
"idiv" => {
if y as i64 != 0 {
((x as i64) / (y as i64)) as f64
} else {
0.0
}
}
"mod" => {
if y as i64 != 0 {
((x as i64) % (y as i64)) as f64
} else {
0.0
}
}
// `checked_*` give 0 both for a zero divisor and for the one overflowing
// case, `i64::MIN / -1` (`/` and `%` panic on it in every build).
"idiv" => (x as i64).checked_div(y as i64).unwrap_or(0) as f64,
"mod" => (x as i64).checked_rem(y as i64).unwrap_or(0) as f64,
"exp" => x.powf(y),
"atan" => {
let a = x.atan2(y).to_degrees();
@@ -358,11 +379,11 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
(if s >= 0 { v << s.min(62) } else { v >> (-s).min(62) }) as f64
}
_ => {
st.push(x);
ps_push(st, x);
y
}
};
st.push(v);
ps_push(st, v);
}
}
}
@@ -370,11 +391,111 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
}
}
/// The most `f64`s one sampled table holds: 16 Mi (128 MiB), room for a 33 × 33 × 33 × 33 grid
/// with four outputs (4_743_684 samples). One table this large is allowed; what stops a file from
/// building many of them is [`Budget`], which caps the samples decoded across a whole
/// [`Func::read`] at the same figure.
const MAX_SAMPLES: usize = 1 << 24;
/// The most inputs or outputs of a function: no colour space has more than 32 components (the most
/// a DeviceN space may have), so a sampled function needs no more. Also the most one-output
/// functions a [`Func::Array`] holds (one per output). It is NOT a limit on the pieces of a
/// stitching function — a repeating gradient lists one piece once per repeat, often many more.
const MAX_IO: usize = 32;
/// The most `Func` nodes one top-level [`Func::read`] builds, across the whole structure of
/// references. A real function is a handful of nodes; a cyclic or fan-out reference graph would
/// build far more, so past this the whole function is unreadable. It is a soft limit, and the only
/// one on the pieces of a stitching function: past it a colour space takes its fallback (a
/// Separation tint reads as grey), nothing unsafe.
const MAX_NODES: usize = 10_000;
/// Samples in a table of `total` entries × `outputs`; `None` when that overflows or passes
/// [`MAX_SAMPLES`].
fn sample_count(total: usize, outputs: usize) -> Option<usize> {
total.checked_mul(outputs).filter(|n| *n <= MAX_SAMPLES)
}
/// What one top-level [`Func::read`] may build before the whole function is treated as unreadable:
/// a running cap on `Func` nodes and on sampled-table samples across the entire structure, so a
/// reference graph that is cyclic or fans out (one object listed many times at each of several
/// levels) cannot build an unbounded tree or decode unbounded memory. Samples of a table shared by
/// repeat references (one `Arc`) are charged once; its nodes are charged per copy.
struct Budget {
nodes: usize,
samples: usize,
/// Set once either cap is passed; the top-level read then yields [`Func::Unsupported`].
overrun: bool,
}
impl Budget {
fn new() -> Self {
Budget { nodes: MAX_NODES, samples: MAX_SAMPLES, overrun: false }
}
/// Charge `n` nodes; `false` (and the read is abandoned) once the cap is passed.
fn take_nodes(&mut self, n: usize) -> bool {
match self.nodes.checked_sub(n) {
Some(left) => {
self.nodes = left;
true
}
None => {
self.overrun = true;
false
}
}
}
/// Charge `n` samples; `false` once the cap is passed.
fn take_samples(&mut self, n: usize) -> bool {
match self.samples.checked_sub(n) {
Some(left) => {
self.samples = left;
true
}
None => {
self.overrun = true;
false
}
}
}
}
/// `v` limited to the range between `a` and `b`, in either order. Unlike [`f64::clamp`] this never
/// panics, whether a file gives the bounds reversed (`/Domain [1 0]`) or a bound is NaN.
fn clamp(v: f64, a: f64, b: f64) -> f64 {
let (lo, hi) = (a.min(b), a.max(b));
if v < lo {
lo
} else if v > hi {
hi
} else {
v
}
}
impl Func {
pub fn read(file: &File, o: &Obj) -> Func {
let mut budget = Budget::new();
let f = Func::read_at(file, o, 0, &mut budget);
// Either cap passed anywhere in the structure: the whole function is unreadable.
if budget.overrun { Func::Unsupported } else { f }
}
/// [`Func::read`] nested `depth` levels deep, charging `budget`: unsupported past [`MAX_DEPTH`]
/// or once the budget is spent.
fn read_at(file: &File, o: &Obj, depth: usize, budget: &mut Budget) -> Func {
if depth >= MAX_DEPTH || !budget.take_nodes(1) {
return Func::Unsupported;
}
let o = file.resolve(o);
if let Obj::Array(a) = o {
return Func::Array(a.iter().map(|f| Func::read(file, f)).collect());
// An array of one-output functions: one per output, so at most `MAX_IO`.
if a.len() > MAX_IO {
return Func::Unsupported;
}
return Func::read_all(file, a, depth + 1, budget).map_or(Func::Unsupported, Func::Array);
}
let Some(d) = o.dict() else { return Func::Unsupported };
let dom = file.get(d, "Domain").map(|x| file.nums(x)).unwrap_or_default();
@@ -386,12 +507,21 @@ impl Func {
c1: file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]),
n: file.get_num(d, "N").unwrap_or(1.0),
},
3 => Func::Stitch {
domain,
funcs: file.get(d, "Functions").and_then(Obj::array).map(|a| a.iter().map(|f| Func::read(file, f)).collect()).unwrap_or_default(),
bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(),
encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(),
},
3 => {
// A stitching function's pieces: no element cap (a repeating gradient lists one
// piece once per repeat); the budget bounds the total work.
let funcs = match file.get(d, "Functions").and_then(Obj::array) {
Some(a) => Func::read_all(file, a, depth + 1, budget),
None => Some(vec![]),
};
let Some(funcs) = funcs else { return Func::Unsupported };
Func::Stitch {
domain,
funcs,
bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(),
encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(),
}
}
0 => {
let Obj::Stream(sd, raw) = o else { return Func::Unsupported };
let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported };
@@ -400,26 +530,31 @@ impl Func {
let bps = file.get_num(d, "BitsPerSample").unwrap_or(8.0) as usize;
let range = file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default();
let outputs = range.len() / 2;
if size == 0 || outputs == 0 || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
if size == 0 || outputs == 0 || outputs > MAX_IO || sizes.len() > MAX_IO || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
return Func::Unsupported;
}
let total = sizes.iter().try_fold(1usize, |a, b| a.checked_mul(*b)).filter(|t| *t > 0 && *t <= 1 << 22);
let Some(total) = total else { return Func::Unsupported };
let Some(count) = sample_count(total, outputs) else { return Func::Unsupported };
if !budget.take_samples(count) {
return Func::Unsupported;
}
let enc = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default();
let encode = [enc.first().copied().unwrap_or(0.0), enc.get(1).copied().unwrap_or(size as f64 - 1.0)];
let decode = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= outputs * 2).unwrap_or_else(|| range.clone());
let max = ((1u64 << bps) - 1) as f64;
let mut samples = Vec::with_capacity(size * outputs);
let mut bit = 0usize;
for _ in 0..total * outputs {
let mut v = 0u64;
for _ in 0..bps {
let byte = data.get(bit / 8).copied().unwrap_or(0);
v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64;
bit += 1;
}
samples.push(v as f64 / max);
}
let samples: Arc<[f64]> = (0..count)
.map(|_| {
let mut v = 0u64;
for _ in 0..bps {
let byte = data.get(bit / 8).copied().unwrap_or(0);
v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64;
bit += 1;
}
v as f64 / max
})
.collect();
if sizes.len() > 1 {
let m = sizes.len();
let encode =
@@ -444,22 +579,59 @@ impl Func {
}
}
/// The functions of an array (of one-output functions, or a stitching function's
/// `/Functions`) read at `depth`, charging `budget`; `None` once the budget is spent. An object
/// the array refers to more than once is read once and copied: the copies share its samples
/// (one `Arc`, charged once), but each copy is charged its nodes again — so a reference listed
/// many times at each of several levels spends the node budget quickly rather than expanding.
fn read_all(file: &File, a: &[Obj], depth: usize, budget: &mut Budget) -> Option<Vec<Func>> {
let mut cache: HashMap<(u32, u16), (Func, usize)> = HashMap::new();
let mut funcs = Vec::with_capacity(a.len());
for o in a {
if budget.overrun {
return None;
}
let f = match o {
Obj::Ref(n, g) => {
if let Some((cached, cost)) = cache.get(&(*n, *g)) {
let (cached, cost) = (cached.clone(), *cost);
if !budget.take_nodes(cost) {
return None;
}
cached
} else {
let before = budget.nodes;
let f = Func::read_at(file, o, depth, budget);
let cost = before.saturating_sub(budget.nodes);
cache.insert((*n, *g), (f.clone(), cost));
f
}
}
_ => Func::read_at(file, o, depth, budget),
};
funcs.push(f);
}
Some(funcs)
}
/// Evaluate at the first input (the functions used by shadings and tints take one input
/// in practice; extra inputs of DeviceN tints are averaged in).
pub fn eval(&self, input: &[f64]) -> Vec<f64> {
if let Func::Calc { domain, range, code } = self {
// The inputs start the operand stack, which holds at most `MAX_STACK` values.
let mut st: Vec<f64> = input
.iter()
.take(MAX_STACK)
.enumerate()
.map(|(i, v)| match (domain.get(2 * i), domain.get(2 * i + 1)) {
(Some(a), Some(b)) => v.clamp(a.min(*b), a.max(*b)),
(Some(a), Some(b)) => clamp(*v, *a, *b),
_ => *v,
})
.collect();
ps_run(code, &mut st, 0);
let n = range.len() / 2;
let k = st.len().saturating_sub(n);
return st[k..].iter().enumerate().map(|(i, v)| v.clamp(range[2 * i], range[2 * i + 1])).collect();
return st.get(k..).unwrap_or_default().iter().zip(range.as_chunks::<2>().0).map(|(v, [a, b])| clamp(*v, *a, *b)).collect();
}
match self {
Func::SampledN { domain, size, outputs, encode, decode, samples } => {
@@ -470,9 +642,9 @@ impl Func {
for i in 0..m {
let x = input.get(i).copied().unwrap_or(0.0);
let (d0, d1) = (domain[2 * i], domain[2 * i + 1]);
let x = x.clamp(d0.min(d1), d0.max(d1));
let x = clamp(x, d0, d1);
let e = if d1 != d0 { encode[2 * i] + (x - d0) / (d1 - d0) * (encode[2 * i + 1] - encode[2 * i]) } else { encode[2 * i] };
let e = if e.is_finite() { e.clamp(0.0, size[i] as f64 - 1.0) } else { 0.0 };
let e = if e.is_finite() { clamp(e, 0.0, size[i] as f64 - 1.0) } else { 0.0 };
base[i] = e.floor() as usize;
frac[i] = e - e.floor();
}
@@ -510,7 +682,7 @@ impl Func {
pub fn eval1(&self, t: f64) -> Vec<f64> {
match self {
Func::Exp { domain, c0, c1, n } => {
let t = t.clamp(domain[0].min(domain[1]), domain[0].max(domain[1]));
let t = clamp(t, domain[0], domain[1]);
let x = if *n == 1.0 { t } else { t.max(0.0).powf(*n) };
(0..c0.len().max(c1.len()))
.map(|i| c0.get(i).copied().unwrap_or(0.0) + x * (c1.get(i).copied().unwrap_or(1.0) - c0.get(i).copied().unwrap_or(0.0)))
@@ -520,7 +692,7 @@ impl Func {
if funcs.is_empty() {
return vec![0.0];
}
let t = t.clamp(domain[0], domain[1]);
let t = clamp(t, domain[0], domain[1]);
let k = bounds.iter().take_while(|b| t >= **b).count().min(funcs.len() - 1);
let lo = if k == 0 { domain[0] } else { bounds[k - 1] };
let hi = bounds.get(k).copied().unwrap_or(domain[1]);
@@ -529,9 +701,9 @@ impl Func {
funcs[k].eval1(u)
}
Func::Sampled { domain, size, outputs, encode, decode, samples } => {
let t = t.clamp(domain[0], domain[1]);
let t = clamp(t, domain[0], domain[1]);
let e = if domain[1] > domain[0] { encode[0] + (t - domain[0]) / (domain[1] - domain[0]) * (encode[1] - encode[0]) } else { encode[0] };
let e = e.clamp(0.0, *size as f64 - 1.0);
let e = clamp(e, 0.0, *size as f64 - 1.0);
let (i0, f) = (e.floor() as usize, e.fract());
let i1 = (i0 + 1).min(size - 1);
(0..*outputs)
@@ -573,6 +745,19 @@ pub fn text_string(b: &[u8]) -> String {
mod tests {
use super::*;
#[test]
fn sample_count_at_and_over_the_cap() {
// 4 Mi entries × 4 outputs fill the cap exactly; a fifth output passes it.
assert_eq!(sample_count(1 << 22, 4), Some(MAX_SAMPLES));
assert_eq!(sample_count(1 << 22, 5), None);
// A 33 × 33 × 33 × 33 grid with four outputs fits (4_743_684 samples, under the cap, so
// `Some`).
assert_eq!(sample_count(33 * 33 * 33 * 33, 4), Some(4_743_684));
// A product that overflows is refused (on 64-bit targets that takes a `/Range` of at least
// 2^41 numbers, too big to build in a test).
assert_eq!(sample_count(1 << 22, usize::MAX / (1 << 22) + 1), None);
}
#[test]
fn calculator_functions() {
// Two inputs → three outputs: { 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } if 0 }
@@ -583,4 +768,62 @@ mod tests {
assert_eq!(f.eval(&[0.5, 0.4]), vec![0.2, 0.1125, 0.0]);
assert_eq!(f.eval(&[1.0, 0.8]), vec![0.8, 1.0, 0.0]);
}
/// Run the calculator program `{ src }` on the stack `st`.
fn run(src: &str, mut st: Vec<f64>) -> Vec<f64> {
let src = format!("{{ {src} }}");
let mut lx = crate::object::Lexer::new(src.as_bytes(), 0);
let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0).into_iter().next() else { panic!() };
ps_run(&code, &mut st, 0);
st
}
#[test]
fn calculator_stack_holds_at_most_100_values() {
// `1 copy 2 copy 4 copy …`: 40 doublings asked for 2^40 values (8 TiB). The copies stop at
// 64 values, since the next would pass 100.
let doublings: String = (0..40).map(|i| format!("{} copy ", 1u64 << i)).collect();
assert_eq!(run(&doublings, vec![0.0]).len(), 64);
// A copy up to exactly 100 values is made; after it nothing grows the stack.
let fifty: Vec<f64> = (0..50).map(f64::from).collect();
let full = run("50 copy", fifty.clone());
assert_eq!(full.len(), 100);
assert_eq!(full[50..], fifty[..]);
for op in ["7", "true", "false", "dup", "0 index", "1 copy", "neg", "add", "nonsense"] {
assert!(run(op, full.clone()).len() <= 100, "{op}");
}
// Nor does a long program without loops: one push per operator, up to the cap.
for op in ["1 ", "true ", "dup ", "0 index "] {
assert_eq!(run(&op.repeat(1000), vec![1.0]).len(), 100, "{op}");
}
}
#[test]
fn calculator_index_past_the_stack_pushes_zero() {
// 10^20 is past usize::MAX: `k + 1` overflowed (a panic), or wrapped to read past the stack.
assert_eq!(run("99999999999999999999 index", vec![1.0, 2.0]), vec![1.0, 2.0, 0.0]);
assert_eq!(run("2 index", vec![1.0, 2.0]), vec![1.0, 2.0, 0.0]);
assert_eq!(run("1 index", vec![1.0, 2.0]), vec![1.0, 2.0, 1.0]);
}
#[test]
fn calculator_copy_index_and_roll_below_the_cap() {
assert_eq!(run("3 copy 1 index 4 1 roll", vec![1.0, 2.0, 3.0]), vec![1.0, 2.0, 3.0, 2.0, 1.0, 2.0, 3.0]);
assert_eq!(run("3 -1 roll dup", vec![1.0, 2.0, 3.0]), vec![2.0, 3.0, 1.0, 1.0]);
}
#[test]
fn calculator_idiv_and_mod_of_i64_min_by_minus_one() {
// `i64::MIN / -1` and `i64::MIN % -1` overflow and panic (in debug and release); `idiv` and
// `mod` must fall back like a zero divisor does. -2^63 is below any f64 the program sees,
// but the cast saturates to i64::MIN, which is enough to trigger it.
let min = -(2f64.powi(63));
assert_eq!(run("idiv", vec![min, -1.0]), vec![0.0]);
assert_eq!(run("mod", vec![min, -1.0]), vec![0.0]);
// Divide by zero still gives 0, ordinary division still works.
assert_eq!(run("idiv", vec![7.0, 0.0]), vec![0.0]);
assert_eq!(run("mod", vec![7.0, 0.0]), vec![0.0]);
assert_eq!(run("idiv", vec![7.0, 2.0]), vec![3.0]);
assert_eq!(run("mod", vec![7.0, 2.0]), vec![1.0]);
}
}
+337
View File
@@ -485,6 +485,343 @@ fn tiling_patterns_coloured_and_uncoloured() {
assert_eq!(at(115, 15)[3], 0.0);
}
/// Object 1 of a file holding just `objs`, read as a function.
fn read_function(objs: Objs) -> color::Func {
color::Func::read(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0))
}
/// A sampled (type 0) function, read from a file holding just its stream.
fn read_sampled_function(dict: &str, data: Vec<u8>) -> color::Func {
read_function(vec![(1, dict.into(), Some(data))])
}
/// Object 1 of a file holding just `objs`, read as a colour space.
fn read_color_space(objs: Objs) -> color::Cs {
color::color_space(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0), None)
}
#[test]
fn sampled_function_over_the_sample_cap_is_unsupported() {
// 4 Mi entries, as many as `/Size` allows; every `/Range` pair adds an output, 4 Mi more samples.
let dict = |outputs: usize| format!("<< /FunctionType 0 /Domain [0 1] /Size [4194304] /Range [{}] /BitsPerSample 1 >>", "0 1 ".repeat(outputs));
// Five outputs ask for 20 Mi samples, past the per-table cap (16 Mi); refused by the sample
// count before any decode, so this is cheap and no lost cap reaches the large table below.
assert!(matches!(read_sampled_function(&dict(5), vec![]), color::Func::Unsupported));
// 32 outputs of an empty stream ask for 128 Mi zeros; also past the cap. Through parse() the
// refused tint transform takes the Separation fallback: tint 0.25 paints grey 0.75, not black.
let bytes =
page_pdf("/Tint cs 0.25 scn 0 0 200 100 re f", "/ColorSpace << /Tint [/Separation /Ink /DeviceRGB 10 0 R] >>", vec![(10, dict(32), Some(vec![]))]);
let doc = parse(&bytes).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
assert_eq!(px(&doc, 50, 50), [0.75, 0.75, 0.75, 1.0]);
}
#[test]
fn sampled_function_one_input_two_outputs() {
let func =
read_sampled_function("<< /FunctionType 0 /Domain [0 1] /Size [4] /Range [0 1 -1 1] /BitsPerSample 8 >>", vec![0, 255, 85, 170, 170, 85, 255, 0]);
assert!(matches!(func, color::Func::Sampled { outputs: 2, .. }));
// 0.5 → coordinate 1.5, halfway between samples 1 and 2; the second output decodes to [-1, 1].
assert_eq!(func.eval(&[0.5]), vec![0.5, 0.0]);
// Inputs outside the domain take the end samples.
assert_eq!(func.eval(&[-1.0]), vec![0.0, 1.0]);
assert_eq!(func.eval(&[2.0]), vec![1.0, -1.0]);
}
#[test]
fn sampled_function_two_inputs() {
let func = read_sampled_function(
"<< /FunctionType 0 /Domain [0 1 0 1] /Size [3 3] /Range [0 1 0 1] /BitsPerSample 8 >>",
vec![0, 0, 85, 0, 170, 0, 0, 85, 85, 85, 170, 85, 0, 170, 85, 170, 170, 170],
);
assert!(matches!(func, color::Func::SampledN { outputs: 2, .. }));
// Entry (i, j), the first input varying fastest, holds (i / 3, j / 3); (0.25, 0.75) → (0.5, 1.5).
assert_eq!(func.eval(&[0.25, 0.75]), vec![1.0 / 6.0, 0.5]);
assert_eq!(func.eval(&[0.0, 0.0]), vec![0.0, 0.0]);
assert_eq!(func.eval(&[1.0, 1.0]), vec![2.0 / 3.0, 2.0 / 3.0]);
}
#[test]
fn sampled_functions_have_at_most_32_inputs_and_outputs() {
// Each evaluation allocates and loops over every output, and shadings evaluate millions of
// times; no colour space has more than 32 components.
let outputs = |n: usize| format!("<< /FunctionType 0 /Domain [0 1] /Size [2] /Range [{}] /BitsPerSample 8 >>", "0 1 ".repeat(n));
assert!(matches!(read_sampled_function(&outputs(32), vec![255; 64]), color::Func::Sampled { outputs: 32, .. }));
assert_eq!(read_sampled_function(&outputs(33), vec![255; 66]), color::Func::Unsupported);
let inputs = |m: usize| format!("<< /FunctionType 0 /Domain [{}] /Size [{}] /Range [0 1] /BitsPerSample 8 >>", "0 1 ".repeat(m), "1 ".repeat(m));
let f = read_sampled_function(&inputs(32), vec![255]);
assert!(matches!(f, color::Func::SampledN { .. }), "{f:?}");
assert_eq!(f.eval(&[0.5; 32]), vec![1.0]);
assert_eq!(read_sampled_function(&inputs(33), vec![255]), color::Func::Unsupported);
}
/// Object 10: a sampled function with one input, one output and samples 0 and 1.
fn ramp() -> (u32, String, Option<Vec<u8>>) {
(10, "<< /FunctionType 0 /Domain [0 1] /Size [2] /Range [0 1] /BitsPerSample 8 >>".into(), Some(vec![0, 255]))
}
/// A stitching function of `n` equal pieces, each function object 10.
fn stitching_of_10(n: usize) -> String {
let bounds: Vec<String> = (1..n).map(|i| (i as f64 / n as f64).to_string()).collect();
format!("<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>", "10 0 R ".repeat(n), bounds.join(" "), "0 1 ".repeat(n))
}
#[test]
fn function_arrays_hold_at_most_32_functions() {
// An array of one-output functions gives an output per function: 32 at most, as for a single
// function. (A stitching function's pieces are NOT capped this way; see the 100-piece test.)
let array = |n: usize| format!("[{}]", "10 0 R ".repeat(n));
assert!(matches!(read_function(vec![(1, array(32), None), ramp()]), color::Func::Array(fs) if fs.len() == 32));
assert_eq!(read_function(vec![(1, array(33), None), ramp()]), color::Func::Unsupported);
}
#[test]
fn stitching_function_with_100_pieces_builds_and_evaluates() {
// A repeating gradient (Cairo, Inkscape) lists one sub-function once per repeat, often far
// more than 32 times; such a stitching function is valid and must build. Here object 10 (a
// 0 → 1 ramp) is listed 100 times, read once and shared.
let f = read_function(vec![(1, stitching_of_10(100), None), ramp()]);
let color::Func::Stitch { funcs, .. } = &f else { panic!("{f:?}") };
assert_eq!(funcs.len(), 100);
let table = |g: &color::Func| match g {
color::Func::Sampled { samples, .. } => Some(samples.as_ptr()),
_ => None,
};
assert!(table(&funcs[0]).is_some());
assert!(funcs.iter().all(|g| g == &funcs[0] && table(g) == table(&funcs[0])));
// The sawtooth's ends: piece 0 at t = 0 and piece 99 at t = 1 both map through the ramp.
assert_eq!(f.eval1(0.0), vec![0.0]);
assert_eq!(f.eval1(1.0), vec![1.0]);
}
#[test]
fn a_function_repeated_in_an_array_is_read_once() {
// Read once per reference, 32 references to one 32 MiB table made 1 GiB. Now the object is
// read once: the copies are equal and share its table.
let table = |f: &color::Func| match f {
color::Func::Sampled { samples, .. } => samples.as_ptr(),
_ => std::ptr::null(),
};
let array = read_function(vec![(1, format!("[{}]", "10 0 R ".repeat(32)), None), ramp()]);
let stitch = read_function(vec![(1, stitching_of_10(32), None), ramp()]);
for f in [&array, &stitch] {
let (color::Func::Array(fs) | color::Func::Stitch { funcs: fs, .. }) = f else { panic!("{f:?}") };
assert_eq!(fs.len(), 32);
assert!(!table(&fs[0]).is_null());
assert!(fs.iter().all(|g| g == &fs[0] && table(g) == table(&fs[0])));
}
assert_eq!(array.eval1(0.5), vec![0.5; 32]);
}
#[test]
fn saved_graphics_states_share_a_sampled_tint() {
// `q` saves a copy of the graphics state, colour spaces included: the copies share the tint
// transform's table.
let tint = "<< /FunctionType 0 /Domain [0 1] /Size [4096] /Range [0 1 0 1 0 1] /BitsPerSample 8 >>";
let data: Vec<u8> = (0..4096u32).flat_map(|i| [(i >> 4) as u8, 0, 255 - (i >> 4) as u8]).collect();
let cs = read_color_space(vec![(1, "[/Separation /Ink /DeviceRGB 10 0 R]".into(), None), (10, tint.into(), Some(data.clone()))]);
let table = |cs: &color::Cs| match cs {
color::Cs::Tint { func: color::Func::Sampled { samples, .. }, .. } => samples.as_ptr(),
_ => std::ptr::null(),
};
assert!(!table(&cs).is_null());
assert_eq!(table(&cs.clone()), table(&cs));
// A thousand nested `q` around a fill in that space: tint 1 is the last entry, red.
let content = format!("/Tint cs 1 scn {}0 0 200 100 re f {}", "q ".repeat(1000), "Q ".repeat(1000));
let doc = parse(&page_pdf(&content, "/ColorSpace << /Tint [/Separation /Ink /DeviceRGB 10 0 R] >>", vec![(10, tint.into(), Some(data))])).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
assert_eq!(px(&doc, 100, 50), [1.0, 0.0, 0.0, 1.0]);
}
/// A one-page PDF drawing image object 10 over the whole 200 × 100 page; `extra` holds more objects.
fn image_page(dict: &str, data: Vec<u8>, extra: Objs) -> Vec<u8> {
let mut objs: Objs = vec![(10, dict.into(), Some(data))];
objs.extend(extra);
page_pdf("q 200 0 0 100 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", objs)
}
/// Whether two colours match within rendering error.
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02)
}
#[test]
fn indexed_space_reads_at_most_256_entries() {
// hival 10^12 asked the image palette for 10^12 colours (24 TB); an indexed palette holds at
// most 256 entries, so hival is 0 to 255.
let im = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1000000000000 <00>] /BitsPerComponent 8 >>";
let doc = parse(&image_page(im, vec![200], vec![])).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
// Entry 200 is past the one-byte lookup string: black.
assert!(near(px(&doc, 100, 50), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&doc, 100, 50));
let cs = read_color_space(vec![(1, "[/Indexed /DeviceRGB 1000000000000 <00>]".into(), None)]);
assert!(matches!(cs, color::Cs::Indexed { hival: 255, .. }), "{cs:?}");
}
#[test]
fn indexed_images_with_4_and_256_entries() {
let im = |cs: &str, w: usize| format!("<< /Type /XObject /Subtype /Image /Width {w} /Height 1 /ColorSpace {cs} /BitsPerComponent 8 >>");
let doc = parse(&image_page(&im("[/Indexed /DeviceRGB 3 <FF000000FF000000FFFFFFFF>]", 4), vec![0, 1, 2, 3], vec![])).unwrap();
for (x, c) in [(25, [1.0, 0.0, 0.0, 1.0]), (75, [0.0, 1.0, 0.0, 1.0]), (125, [0.0, 0.0, 1.0, 1.0]), (175, [1.0; 4])] {
assert!(near(px(&doc, x, 50), c), "x = {x}: {:?}", px(&doc, x, 50));
}
// 256 entries, the lookup table in a stream: entry i is (i, 0, 255 − i).
let lookup: Vec<u8> = (0..=255u8).flat_map(|i| [i, 0, 255 - i]).collect();
let doc = parse(&image_page(&im("[/Indexed /DeviceRGB 255 11 0 R]", 2), vec![255, 0], vec![(11, "<< >>".into(), Some(lookup))])).unwrap();
assert!(near(px(&doc, 50, 50), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&doc, 50, 50));
assert!(near(px(&doc, 150, 50), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&doc, 150, 50));
}
/// Functions nested down the first element of arrays and stitching functions, outermost first.
fn nested_functions(f: &color::Func) -> Vec<&color::Func> {
let mut out = vec![f];
let mut f = f;
while let color::Func::Array(fs) | color::Func::Stitch { funcs: fs, .. } = f
&& let Some(first) = fs.first()
{
out.push(first);
f = first;
}
out
}
/// Colour spaces nested down Indexed bases and Separation or DeviceN alternates, outermost first.
fn nested_spaces(cs: &color::Cs) -> Vec<&color::Cs> {
let mut out = vec![cs];
let mut cs = cs;
while let color::Cs::Indexed { base: next, .. } | color::Cs::Tint { alt: next, .. } = cs {
cs = next;
out.push(cs);
}
out
}
#[test]
fn function_array_containing_itself_stops_at_depth_8() {
// `[1 0 R]` as object 1: the reader recursed until the stack overflowed (an abort).
let f = read_function(vec![(1, "[1 0 R]".into(), None)]);
let chain = nested_functions(&f);
assert_eq!(chain.len(), 9);
assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Array(_))));
assert_eq!(chain[8], &color::Func::Unsupported);
// As a tint transform: the innermost function's 0.5, in DeviceGray.
let tint = "/ColorSpace << /Tint [/Separation /Ink /DeviceGray 10 0 R] >>";
let doc = parse(&page_pdf("/Tint cs 1 scn 0 0 200 100 re f", tint, vec![(10, "[10 0 R]".into(), None)])).unwrap();
assert_eq!(px(&doc, 100, 50), [0.5, 0.5, 0.5, 1.0]);
}
#[test]
fn stitching_function_containing_itself_stops_at_depth_8() {
let stitch = |me: u32| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{me} 0 R] /Bounds [] /Encode [0 1] >>");
let f = read_function(vec![(1, stitch(1), None)]);
let chain = nested_functions(&f);
assert_eq!(chain.len(), 9);
assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Stitch { .. })));
assert_eq!(chain[8], &color::Func::Unsupported);
let tint = "/ColorSpace << /Tint [/Separation /Ink /DeviceGray 10 0 R] >>";
let doc = parse(&page_pdf("/Tint cs 1 scn 0 0 200 100 re f", tint, vec![(10, stitch(10), None)])).unwrap();
assert_eq!(px(&doc, 100, 50), [0.5, 0.5, 0.5, 1.0]);
}
#[test]
fn colour_spaces_based_on_themselves_stop_at_depth_8() {
// Object 1 as its own Indexed base, Separation alternate or ICCBased alternate: the reader
// recursed until the stack overflowed (an abort).
let indexed = read_color_space(vec![(1, "[/Indexed 1 0 R 1 <00>]".into(), None)]);
let separation = read_color_space(vec![(1, "[/Separation /Ink 1 0 R << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>]".into(), None)]);
for cs in [&indexed, &separation] {
let chain = nested_spaces(cs);
assert_eq!(chain.len(), 9);
assert!(chain[..8].iter().all(|c| matches!(c, color::Cs::Indexed { .. } | color::Cs::Tint { .. })));
assert_eq!(chain[8], &color::Cs::Gray);
}
// An ICCBased space reads as its alternate: DeviceGray once the depth runs out.
let icc = read_color_space(vec![(1, "[/ICCBased 2 0 R]".into(), None), (2, "<< /N 3 /Alternate 1 0 R >>".into(), Some(vec![0; 16]))]);
assert_eq!(icc, color::Cs::Gray);
// A fill in the Indexed one paints black.
let doc = parse(&page_pdf("/CS0 cs 0 scn 0 0 200 100 re f", "/ColorSpace << /CS0 10 0 R >>", vec![(10, "[/Indexed 10 0 R 1 <00>]".into(), None)])).unwrap();
assert_eq!(px(&doc, 100, 50), [0.0, 0.0, 0.0, 1.0]);
}
#[test]
fn nesting_deeper_than_8_is_refused() {
// Chains of 100 distinct objects, each nesting the next (real files nest two or three
// levels): read down to depth 7, refused at depth 8.
let functions = |n: u32| -> Objs {
let stitch = |next: u32| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{next} 0 R] /Bounds [] /Encode [0 1] >>");
(1..n).map(|i| (i, stitch(i + 1), None)).chain([(n, "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".into(), None)]).collect()
};
let f = read_function(functions(100));
let chain = nested_functions(&f);
assert_eq!(chain.len(), 9);
assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Stitch { .. })));
assert_eq!(chain[8], &color::Func::Unsupported);
// Three levels are read to the end.
let f = read_function(functions(3));
assert_eq!(nested_functions(&f).len(), 3);
assert_eq!(f.eval1(0.25), vec![0.25]);
// Indexed bases.
let spaces: Objs =
(1..100).map(|i| (i, format!("[/Indexed {} 0 R 0 <00>]", i + 1), None)).chain([(100, "[/Indexed /DeviceRGB 0 <FF0000>]".into(), None)]).collect();
let cs = read_color_space(spaces);
let chain = nested_spaces(&cs);
assert_eq!(chain.len(), 9);
assert!(chain[..8].iter().all(|c| matches!(c, color::Cs::Indexed { .. })));
assert_eq!(chain[8], &color::Cs::Gray);
}
#[test]
fn a_fanning_out_reference_graph_is_refused_within_the_budget() {
// Eight objects, each an array of 32 references to the next, with a function at the bottom:
// depth 8 alone would still expand 32^7 copies. Each object is read once per array but its
// nodes are charged per copy, so the per-read node budget is spent after a few levels and the
// whole function is refused — fast and with bounded memory, not 32^7 nodes.
let objs: Objs = (1..=7u32)
.map(|i| (i, format!("[{}]", format!("{} 0 R ", i + 1).repeat(32)), None))
.chain([(8, "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".into(), None)])
.collect();
assert_eq!(read_function(objs), color::Func::Unsupported);
}
#[test]
fn colour_space_chain_of_depth_3() {
// Indexed → Separation → ICCBased → DeviceRGB: entry 0 is tint 1 (blue), entry 1 tint 0 (white).
let doc = parse(&image_page(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/Indexed 11 0 R 1 <FF00>] /BitsPerComponent 8 >>",
vec![0, 1],
vec![
(11, "[/Separation /Ink 12 0 R 13 0 R]".into(), None),
(12, "[/ICCBased 14 0 R]".into(), None),
(13, "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>".into(), None),
(14, "<< /N 3 /Alternate /DeviceRGB >>".into(), Some(vec![0; 16])),
],
))
.unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
assert!(near(px(&doc, 50, 50), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&doc, 50, 50));
assert!(near(px(&doc, 150, 50), [1.0; 4]), "{:?}", px(&doc, 150, 50));
}
#[test]
fn stitching_function_with_a_reversed_domain() {
// `f64::clamp` panics on bounds in the wrong order, as `/Domain [1 0]` gives them.
let exp = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>";
let f = read_function(vec![(1, format!("<< /FunctionType 3 /Domain [1 0] /Functions [{exp}] /Bounds [] /Encode [0 1] >>"), None)]);
assert_eq!(f.eval(&[0.25]), vec![0.0]);
}
#[test]
fn sampled_function_with_a_reversed_domain() {
let f = read_sampled_function("<< /FunctionType 0 /Domain [1 0] /Size [2] /Range [0 1] /BitsPerSample 8 >>", vec![0, 255]);
assert_eq!(f.eval(&[0.25]), vec![0.0]);
}
#[test]
fn calculator_function_with_a_reversed_range() {
let f = read_function(vec![(1, "<< /FunctionType 4 /Domain [0 1] /Range [1 0] >>".into(), Some(b"{ 0.5 add }".to_vec()))]);
assert_eq!(f.eval(&[0.25]), vec![0.75]);
}
#[test]
fn multiple_pages_and_calculator_shadings() {
let objs: Objs = vec![