diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs index d450ea0d..019121e0 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -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, encode: Vec, }, - /// 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, - samples: Vec, + 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, decode: Vec, - samples: Vec, + samples: Arc<[f64]>, }, /// An array of one-output functions. Array(Vec), @@ -215,6 +234,17 @@ fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec { 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, v: f64) { + if st.len() < MAX_STACK { + st.push(v); + } +} + fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { if depth > 32 { return; @@ -222,7 +252,7 @@ fn ps_run(code: &[PsOp], st: &mut Vec, 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| st.pop().unwrap_or(0.0); @@ -243,14 +273,14 @@ fn ps_run(code: &[PsOp], st: &mut Vec, 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, 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, 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, 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, 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, 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 { + 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> { + 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 { 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 = 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 { 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) -> Vec { + 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 = (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]); + } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index dac34bee..858c8235 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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) -> 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>) { + (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 = (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 = (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, extra: Objs) -> Vec { + 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 ]", 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 = (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 ]".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 ] /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![