diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs index 256f5d2f..6b4e4ae7 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -167,6 +167,16 @@ pub enum Func { decode: Vec, samples: Vec, }, + /// Type 0 with several inputs (function-based shadings): multilinear interpolation, the + /// first input varying fastest in the sample table. + SampledN { + domain: Vec, + size: Vec, + outputs: usize, + encode: Vec, + decode: Vec, + samples: Vec, + }, /// An array of one-output functions. Array(Vec), /// Type 4: a PostScript calculator program (§7.10.5). @@ -385,20 +395,23 @@ impl Func { 0 => { let Obj::Stream(sd, raw) = o else { return Func::Unsupported }; let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported }; - let size = file.get(d, "Size").map(|x| file.nums(x)).and_then(|v| v.first().copied()).unwrap_or(0.0) as usize; + let sizes: Vec = file.get(d, "Size").map(|x| file.nums(x)).unwrap_or_default().iter().map(|v| v.max(0.0) as usize).collect(); + let size = sizes.first().copied().unwrap_or(0); 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) { 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 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..size * outputs { + for _ in 0..total * outputs { let mut v = 0u64; for _ in 0..bps { let byte = data.get(bit / 8).copied().unwrap_or(0); @@ -407,6 +420,13 @@ impl Func { } samples.push(v as f64 / max); } + if sizes.len() > 1 { + let m = sizes.len(); + let encode = + (0..m).flat_map(|i| [enc.get(2 * i).copied().unwrap_or(0.0), enc.get(2 * i + 1).copied().unwrap_or(sizes[i] as f64 - 1.0)]).collect(); + let domain = (0..m).flat_map(|i| [dom.get(2 * i).copied().unwrap_or(0.0), dom.get(2 * i + 1).copied().unwrap_or(1.0)]).collect(); + return Func::SampledN { domain, size: sizes, outputs, encode, decode, samples }; + } Func::Sampled { domain, size, outputs, encode, decode, samples } } 4 => { @@ -441,8 +461,50 @@ impl Func { 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(); } - let t = input.first().copied().unwrap_or(0.0); - self.eval1(t) + match self { + Func::SampledN { domain, size, outputs, encode, decode, samples } => { + let m = size.len(); + // Each input → a sample-table coordinate, then the 2^m surrounding samples. + let mut base = vec![0usize; m]; + let mut frac = vec![0.0f64; m]; + 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 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 }; + base[i] = e.floor() as usize; + frac[i] = e - e.floor(); + } + let corners = if m <= 6 { 1usize << m } else { 1 }; + let mut out = vec![0.0; *outputs]; + for c in 0..corners { + let mut w = 1.0; + let mut idx = 0usize; + let mut stride = 1usize; + for i in 0..m { + let up = m <= 6 && (c >> i) & 1 == 1; + w *= if up { frac[i] } else { 1.0 - frac[i] }; + let k = if up { (base[i] + 1).min(size[i] - 1) } else { base[i] }; + idx += k * stride; + stride *= size[i]; + } + if w == 0.0 { + continue; + } + for (o, v) in out.iter_mut().enumerate() { + *v += w * samples.get(idx * outputs + o).copied().unwrap_or(0.0); + } + } + for (o, v) in out.iter_mut().enumerate() { + let (d0, d1) = (decode.get(2 * o).copied().unwrap_or(0.0), decode.get(2 * o + 1).copied().unwrap_or(1.0)); + *v = d0 + *v * (d1 - d0); + } + out + } + Func::Array(fs) if input.len() > 1 => fs.iter().map(|f| f.eval(input).first().copied().unwrap_or(0.0)).collect(), + _ => self.eval1(input.first().copied().unwrap_or(0.0)), + } } pub fn eval1(&self, t: f64) -> Vec { @@ -481,7 +543,7 @@ impl Func { .collect() } Func::Array(fs) => fs.iter().map(|f| f.eval1(t).first().copied().unwrap_or(0.0)).collect(), - Func::Calc { .. } => self.eval(&[t]), + Func::Calc { .. } | Func::SampledN { .. } => self.eval(&[t]), Func::Unsupported => vec![0.5], } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index db443a41..a2f98c9f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -16,6 +16,7 @@ mod image; mod object; mod page; mod ps; +mod shading; mod type1; pub mod write; @@ -192,3 +193,5 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_shading; diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index cbfaad01..67d9ff8b 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -90,11 +90,20 @@ pub(crate) struct Tiling { uncolored: Option<[f64; 3]>, } +/// A shading pattern without a gradient equivalent (function-based or mesh shadings), +/// rasterised when it fills a path. +pub(crate) struct ShadingFill { + sh: Obj, + /// Shading space → page. + space: Affine, +} + #[derive(Clone)] enum Fill { Color([f64; 3]), Paint(Paint), Tiling(Rc), + Shading(Rc), None, } @@ -650,6 +659,10 @@ impl<'a> Interp<'a> { self.b.skip("tiling pattern stroke"); Some(Paint::Color(t.uncolored.unwrap_or([0.5; 3]))) } + Fill::Shading(_) => { + self.b.skip("shading pattern stroke"); + Some(Paint::Color([0.5; 3])) + } Fill::None => None, } } @@ -681,6 +694,21 @@ impl<'a> Interp<'a> { self.tile(&t, area, depth); self.close_to(d); } + Fill::Shading(sf) => { + let page = ctm * path.clone(); + let d = self.b.depth(); + let mut sk = vec![]; + let img = crate::shading::shading_image(self.file, &sf.sh, sf.space, self.page_box, &mut sk); + for s in sk { + self.b.skip(&s); + } + if let Some(mut img) = img { + img.opacity = self.gs.fill_alpha; + self.b.clip(page, rule); + self.b.push(Node::Image(img)); + self.close_to(d); + } + } f => fill_paint = self.paint_of(&f).map(|p| (p, self.gs.fill_alpha, rule)), } } @@ -811,7 +839,11 @@ impl<'a> Interp<'a> { let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); match f.get_num(p, "PatternType").unwrap_or(0.0) as i32 { 2 => { - let Some(sh) = f.get(p, "Shading").and_then(Obj::dict) else { return Fill::None }; + let Some(sh_obj) = f.get(p, "Shading") else { return Fill::None }; + let Some(sh) = sh_obj.dict() else { return Fill::None }; + if !matches!(f.get_num(sh, "ShadingType").unwrap_or(0.0) as i32, 2 | 3) { + return Fill::Shading(Rc::new(ShadingFill { sh: sh_obj.clone(), space: base * m })); + } let sh = sh.clone(); match self.shading_gradient(&sh) { // Gradient space → shape user space (shapes carry the CTM). @@ -848,7 +880,21 @@ impl<'a> Interp<'a> { fn shade(&mut self, res: &Dict, name: &str) { let f = self.file; - let Some(sh) = f.get_dict(res, "Shading").and_then(|s| f.get_dict(s, name)).cloned() else { return }; + let Some(sh_obj) = f.get_dict(res, "Shading").and_then(|s| f.get(s, name)) else { return }; + let Some(sh) = sh_obj.dict().cloned() else { return }; + if !matches!(f.get_num(&sh, "ShadingType").unwrap_or(0.0) as i32, 2 | 3) { + // Function-based and mesh shadings paint only their own extent. + let mut sk = vec![]; + let img = crate::shading::shading_image(f, sh_obj, self.gs.ctm, self.page_box, &mut sk); + for s in sk { + self.b.skip(&s); + } + if let Some(mut img) = img { + img.opacity = self.gs.fill_alpha; + self.b.push(Node::Image(img)); + } + return; + } let Some(g) = self.shading_gradient(&sh) else { return }; // Fill everything (the current clip limits it): the page box in user space. let inv = self.gs.ctm.inverse(); diff --git a/crates/pdf/src/shading.rs b/crates/pdf/src/shading.rs new file mode 100644 index 00000000..df5e5f9c --- /dev/null +++ b/crates/pdf/src/shading.rs @@ -0,0 +1,580 @@ +//! Shadings the render tree has no gradient for (ISO 32000-1 §8.7.4.5): function-based +//! shadings (type 1) and the mesh shadings — free-form and lattice-form Gouraud-shaded triangle +//! meshes (types 4 and 5), Coons patch meshes (type 6) and tensor-product patch meshes (type 7). +//! Patches are tessellated into triangles; triangles are rasterised with their colours (or the +//! parametric `t` of a shading `Function`) interpolated across them, into an image node in page +//! space (2×2 supersampled, so the mesh's outer edges are anti-aliased without seams between +//! triangles). + +use effectcraft_svg::{Affine, Image as SvgImage}; +use kurbo::Point; + +use crate::color::{Cs, Func, color_space}; +use crate::object::{Dict, File, Obj, decode_stream}; + +/// The longest side of a rasterised shading, in image pixels. +const MAX_SIDE: f64 = 1024.0; +/// The most image pixels per PDF point. +const MAX_SCALE: f64 = 4.0; +/// The most triangles one shading draws. +const MAX_TRIANGLES: usize = 2_000_000; + +/// A mesh vertex: page-space position and its value (RGB, or `t` in `[0]` with a function). +#[derive(Clone, Copy, Debug, PartialEq)] +struct V { + p: Point, + c: [f64; 3], +} + +/// How vertex values become colours. +struct Colors { + cs: Cs, + /// With a function: `t` (domain `[t0, t1]`) → RGB through a 1024-entry table. + lut: Option<(f64, f64, Vec<[u8; 3]>)>, +} + +impl Colors { + fn new(file: &File, sh: &Dict) -> (Colors, usize) { + let cs = file.get(sh, "ColorSpace").map(|c| color_space(file, c, None)).unwrap_or(Cs::Rgb); + let n = cs.components().max(1); + match file.get(sh, "Function") { + Some(fo) => { + let func = Func::read(file, fo); + let dom = file.get(sh, "Domain").map(|d| file.nums(d)).filter(|d| d.len() == 2).unwrap_or_else(|| vec![0.0, 1.0]); + let (t0, t1) = (dom[0], dom[1]); + let lut = (0..1024) + .map(|i| { + let t = t0 + (t1 - t0) * i as f64 / 1023.0; + to8(cs.to_rgb(&func.eval(&[t]))) + }) + .collect(); + (Colors { cs, lut: Some((t0, t1, lut)) }, 1) + } + None => (Colors { cs, lut: None }, n), + } + } + + /// A vertex value from its components (RGB, or `t`). + fn value(&self, comps: &[f64]) -> [f64; 3] { + match self.lut { + Some(_) => [comps.first().copied().unwrap_or(0.0), 0.0, 0.0], + None => self.cs.to_rgb(comps), + } + } + + /// An interpolated value → RGB8. + fn rgb(&self, v: [f64; 3]) -> [u8; 3] { + match &self.lut { + Some((t0, t1, lut)) => { + let k = if t1 != t0 { (v[0] - t0) / (t1 - t0) } else { 0.0 }; + let k = if k.is_finite() { k.clamp(0.0, 1.0) } else { 0.0 }; + lut[(k * 1023.0).round() as usize] + } + None => to8(v), + } + } +} + +fn to8(c: [f64; 3]) -> [u8; 3] { + c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8) +} + +/// Big-endian bit reader over mesh data. +struct Bits<'a> { + data: &'a [u8], + bit: usize, +} + +impl Bits<'_> { + fn left(&self) -> usize { + (self.data.len() * 8).saturating_sub(self.bit) + } + + fn read(&mut self, n: usize) -> Option { + if n == 0 || n > 32 || self.left() < n { + return None; + } + let mut v = 0u64; + for _ in 0..n { + let byte = self.data[self.bit / 8]; + v = (v << 1) | ((byte >> (7 - self.bit % 8)) & 1) as u64; + self.bit += 1; + } + Some(v) + } + + fn align(&mut self) { + self.bit = self.bit.div_ceil(8) * 8; + } +} + +/// The layout of a mesh's vertex data (§8.7.4.5.5 Table 83 and following). +struct Layout { + bpc: usize, + bpcomp: usize, + bpf: usize, + /// Value components per vertex (1 with a function). + n: usize, + decode: Vec, +} + +impl Layout { + fn read(file: &File, sh: &Dict, n: usize) -> Option { + let bpc = file.get_num(sh, "BitsPerCoordinate")? as usize; + let bpcomp = file.get_num(sh, "BitsPerComponent")? as usize; + let bpf = file.get_num(sh, "BitsPerFlag").unwrap_or(8.0) as usize; + let decode = file.get(sh, "Decode").map(|d| file.nums(d)).unwrap_or_default(); + let ok = |b: usize| matches!(b, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32); + if !ok(bpc) || !ok(bpcomp) || !matches!(bpf, 2 | 4 | 8) || decode.len() < 4 + 2 * n { + return None; + } + Some(Layout { bpc, bpcomp, bpf, n, decode }) + } + + fn scale(raw: u64, bits: usize, lo: f64, hi: f64) -> f64 { + let max = ((1u64 << bits) - 1) as f64; + lo + raw as f64 * (hi - lo) / max + } + + fn point(&self, r: &mut Bits) -> Option { + let x = r.read(self.bpc)?; + let y = r.read(self.bpc)?; + let d = &self.decode; + Some(Point::new(Self::scale(x, self.bpc, d[0], d[1]), Self::scale(y, self.bpc, d[2], d[3]))) + } + + fn comps(&self, r: &mut Bits) -> Option> { + (0..self.n) + .map(|i| { + let v = r.read(self.bpcomp)?; + Some(Self::scale(v, self.bpcomp, self.decode[4 + 2 * i], self.decode[5 + 2 * i])) + }) + .collect() + } +} + +/// A shading rasterised into an image node in page space, for `sh` or a shading-pattern fill. +/// `sh` is the shading dictionary or stream, `to_page` maps shading space to page space and +/// `page_box` limits the image (page space). `None` with a note in `skip` when nothing draws. +pub(crate) fn shading_image(file: &File, sh: &Obj, to_page: Affine, page_box: [f64; 4], skip: &mut Vec) -> Option { + let sh_obj = file.resolve(sh); + let d = sh_obj.dict()?; + let ty = file.get_num(d, "ShadingType").unwrap_or(0.0) as i32; + if !to_page.as_coeffs().iter().all(|v| v.is_finite()) || to_page.determinant().abs() < 1e-12 { + return None; + } + let tris = match ty { + 1 => return function_shading(file, d, to_page, page_box, skip), + 4..=7 => { + let Obj::Stream(_, raw) = sh_obj else { + skip.push(format!("shading type {ty} (no data)")); + return None; + }; + let Some(data) = decode_stream(file, d, raw) else { + skip.push(format!("shading type {ty} (filters)")); + return None; + }; + let (colors, n) = Colors::new(file, d); + let Some(layout) = Layout::read(file, d, n) else { + skip.push(format!("shading type {ty} (layout)")); + return None; + }; + let mut r = Bits { data: &data, bit: 0 }; + let tris = match ty { + 4 => free_form(&mut r, &layout, &colors, to_page), + 5 => { + let per_row = file.get_num(d, "VerticesPerRow").unwrap_or(0.0) as usize; + lattice(&mut r, &layout, &colors, to_page, per_row) + } + _ => patches(&mut r, &layout, &colors, to_page, ty == 7, page_box), + }; + (tris, colors) + } + _ => { + skip.push(format!("shading type {ty}")); + return None; + } + }; + let (tris, colors) = tris; + if tris.is_empty() { + return None; + } + rasterize(&tris, &colors, page_box) +} + +/// Type 4: free-form triangles with edge flags (0 starts a triangle; 1 and 2 share an edge with +/// the previous one). +fn free_form(r: &mut Bits, l: &Layout, colors: &Colors, m: Affine) -> Vec<[V; 3]> { + let mut out = vec![]; + let mut pending: Vec = vec![]; + let mut last: Option<[V; 3]> = None; + while out.len() < MAX_TRIANGLES { + let Some(flag) = r.read(l.bpf) else { break }; + let (Some(p), Some(c)) = (l.point(r), l.comps(r)) else { break }; + r.align(); + let v = V { p: m * p, c: colors.value(&c) }; + // A triangle being started takes the next two vertices whatever their flags. + if !pending.is_empty() || flag == 0 { + pending.push(v); + if pending.len() == 3 { + let t = [pending[0], pending[1], pending[2]]; + out.push(t); + last = Some(t); + pending.clear(); + } + continue; + } + let Some([a, b, c]) = last else { continue }; + let t = if flag == 1 { [b, c, v] } else { [a, c, v] }; + out.push(t); + last = Some(t); + } + out +} + +/// Type 5: a lattice of `per_row` vertices per row, two triangles per cell. +fn lattice(r: &mut Bits, l: &Layout, colors: &Colors, m: Affine, per_row: usize) -> Vec<[V; 3]> { + if per_row < 2 { + return vec![]; + } + let mut rows: Vec> = vec![]; + let mut row = vec![]; + while let (Some(p), Some(c)) = (l.point(r), l.comps(r)) { + r.align(); + row.push(V { p: m * p, c: colors.value(&c) }); + if row.len() == per_row { + rows.push(std::mem::take(&mut row)); + if rows.len() * per_row > MAX_TRIANGLES { + break; + } + } + } + let mut out = vec![]; + for w in rows.windows(2) { + for i in 0..per_row - 1 { + let (a, b, c, d) = (w[0][i], w[0][i + 1], w[1][i], w[1][i + 1]); + out.push([a, b, c]); + out.push([b, d, c]); + } + } + out +} + +fn bez(p: [Point; 4], t: f64) -> Point { + let s = 1.0 - t; + let (a, b, c, d) = (s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t); + Point::new(a * p[0].x + b * p[1].x + c * p[2].x + d * p[3].x, a * p[0].y + b * p[1].y + c * p[2].y + d * p[3].y) +} + +/// Types 6 and 7: Coons and tensor-product patches. The 12 boundary points run around the +/// patch from its first corner (`p00 p01 p02 p03 p13 p23 p33 p32 p31 p30 p20 p10`); tensor +/// patches add `p11 p12 p22 p21`. Corner colours belong to `p00`, `p03`, `p33` and `p30`. +/// Flags 1–3 reuse the previous patch's second, third or fourth side as the first. +fn patches(r: &mut Bits, l: &Layout, colors: &Colors, m: Affine, tensor: bool, page_box: [f64; 4]) -> Vec<[V; 3]> { + let mut out = vec![]; + let mut prev: Option<([Point; 12], [[f64; 3]; 4])> = None; + while out.len() < MAX_TRIANGLES { + let Some(flag) = r.read(l.bpf) else { break }; + let mut lp = [Point::ZERO; 12]; + let mut cs = [[0.0; 3]; 4]; + let first = match (flag, &prev) { + (0, _) => 0, + (1..=3, Some((pp, pc))) => { + let (k, ci) = match flag { + 1 => (3, 1), + 2 => (6, 2), + _ => (9, 3), + }; + for i in 0..4 { + lp[i] = pp[(k + i) % 12]; + } + cs[0] = pc[ci]; + cs[1] = pc[(ci + 1) % 4]; + 4 + } + _ => break, + }; + let mut ok = true; + for p in lp.iter_mut().skip(first) { + match l.point(r) { + Some(q) => *p = q, + None => ok = false, + } + } + let mut inner = [Point::ZERO; 4]; + if tensor { + for p in &mut inner { + match l.point(r) { + Some(q) => *p = q, + None => ok = false, + } + } + } + for c in cs.iter_mut().skip(if first == 0 { 0 } else { 2 }) { + match l.comps(r) { + Some(v) => *c = colors.value(&v), + None => ok = false, + } + } + r.align(); + if !ok { + break; + } + // Shading space → page space before tessellating (the rasteriser works in page space). + let lp_page = lp.map(|p| m * p); + let inner_page = inner.map(|p| m * p); + // The 4×4 grid g[i][j]: row i = 0 is p00 … p03. + let mut g = [[Point::ZERO; 4]; 4]; + g[0] = [lp_page[0], lp_page[1], lp_page[2], lp_page[3]]; + g[1][3] = lp_page[4]; + g[2][3] = lp_page[5]; + g[3] = [lp_page[9], lp_page[8], lp_page[7], lp_page[6]]; + g[2][0] = lp_page[10]; + g[1][0] = lp_page[11]; + g[1][1] = inner_page[0]; + g[1][2] = inner_page[1]; + g[2][2] = inner_page[2]; + g[2][1] = inner_page[3]; + tessellate(&g, tensor, cs, page_box, &mut out); + prev = Some((lp, cs)); + } + out +} + +/// A patch → `n`×`n` cells of two triangles; `u` runs along `p00 → p03`, `v` along `p00 → p30`. +fn tessellate(g: &[[Point; 4]; 4], tensor: bool, cs: [[f64; 3]; 4], page_box: [f64; 4], out: &mut Vec<[V; 3]>) { + let (mut lo, mut hi) = (Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)); + for p in g.iter().flatten() { + lo = Point::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Point::new(hi.x.max(p.x), hi.y.max(p.y)); + } + if !(lo.x.is_finite() && hi.x.is_finite() && lo.y.is_finite() && hi.y.is_finite()) { + return; + } + // Patches entirely off the page draw nothing. + if hi.x < page_box[0] || lo.x > page_box[2] || hi.y < page_box[1] || lo.y > page_box[3] { + return; + } + let size = (hi.x - lo.x).max(hi.y - lo.y).min(page_box[2] - page_box[0] + page_box[3] - page_box[1]); + let n = ((size * MAX_SCALE / 6.0).ceil() as usize).clamp(2, 48); + let eval = |u: f64, v: f64| -> Point { + if tensor { + let rows = g.map(|row| bez(row, u)); + bez(rows, v) + } else { + let cb = bez(g[0], u); + let ct = bez(g[3], u); + let cl = bez([g[0][0], g[1][0], g[2][0], g[3][0]], v); + let cr = bez([g[0][3], g[1][3], g[2][3], g[3][3]], v); + let corner = |a: Point, w: f64| a.to_vec2() * w; + let s = cb.to_vec2() * (1.0 - v) + ct.to_vec2() * v + cl.to_vec2() * (1.0 - u) + cr.to_vec2() * u + - corner(g[0][0], (1.0 - u) * (1.0 - v)) + - corner(g[0][3], u * (1.0 - v)) + - corner(g[3][0], (1.0 - u) * v) + - corner(g[3][3], u * v); + s.to_point() + } + }; + let color = |u: f64, v: f64| -> [f64; 3] { + // c0 (0,0), c1 (1,0), c2 (1,1), c3 (0,1). + let mut c = [0.0; 3]; + for (k, ck) in c.iter_mut().enumerate() { + *ck = cs[0][k] * (1.0 - u) * (1.0 - v) + cs[1][k] * u * (1.0 - v) + cs[2][k] * u * v + cs[3][k] * (1.0 - u) * v; + } + c + }; + let mut grid = Vec::with_capacity((n + 1) * (n + 1)); + for j in 0..=n { + for i in 0..=n { + let (u, v) = (i as f64 / n as f64, j as f64 / n as f64); + grid.push(V { p: eval(u, v), c: color(u, v) }); + } + } + for j in 0..n { + for i in 0..n { + let a = grid[j * (n + 1) + i]; + let b = grid[j * (n + 1) + i + 1]; + let c = grid[(j + 1) * (n + 1) + i]; + let d = grid[(j + 1) * (n + 1) + i + 1]; + out.push([a, b, c]); + out.push([b, d, c]); + } + } +} + +/// The page-space raster frame for content covering `lo..hi`: origin (top-left, page space), +/// scale (pixels per point) and size, or `None` when it misses the page. +fn frame(lo: Point, hi: Point, page_box: [f64; 4]) -> Option<(Point, f64, usize, usize)> { + let x0 = lo.x.max(page_box[0]); + let y0 = lo.y.max(page_box[1]); + let x1 = hi.x.min(page_box[2]); + let y1 = hi.y.min(page_box[3]); + let finite = x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite(); + if !finite || x1 <= x0 || y1 <= y0 { + return None; + } + let s = (MAX_SIDE / (x1 - x0).max(y1 - y0)).min(MAX_SCALE); + let w = ((x1 - x0) * s).ceil().max(1.0) as usize; + let h = ((y1 - y0) * s).ceil().max(1.0) as usize; + Some((Point::new(x0, y1), s, w, h)) +} + +/// The image node for an RGBA8 raster with origin `o` (page space, top-left) and `s` px/pt. +fn image_node(rgba: Vec, w: usize, h: usize, o: Point, s: f64) -> SvgImage { + SvgImage { + name: "Shading".into(), + transform: Affine::new([1.0 / s, 0.0, 0.0, -1.0 / s, o.x, o.y]), + width: w as u32, + height: h as u32, + rgba: std::sync::Arc::new(rgba), + opacity: 1.0, + } +} + +/// Gouraud-shade the triangles (page space) into an image: 2×2 samples per pixel, later +/// triangles over earlier ones. +fn rasterize(tris: &[[V; 3]], colors: &Colors, page_box: [f64; 4]) -> Option { + let (mut lo, mut hi) = (Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)); + for v in tris.iter().flatten() { + if v.p.x.is_finite() && v.p.y.is_finite() { + lo = Point::new(lo.x.min(v.p.x), lo.y.min(v.p.y)); + hi = Point::new(hi.x.max(v.p.x), hi.y.max(v.p.y)); + } + } + let (o, s, w, h) = frame(lo, hi, page_box)?; + // Supersampled buffer: value + covered. + let (sw, sh) = (w * 2, h * 2); + let ss = s * 2.0; + let mut val = vec![[0.0f32; 3]; sw * sh]; + let mut cov = vec![false; sw * sh]; + for t in tris { + let q = t.map(|v| Point::new((v.p.x - o.x) * ss, (o.y - v.p.y) * ss)); + let area = (q[1].x - q[0].x) * (q[2].y - q[0].y) - (q[2].x - q[0].x) * (q[1].y - q[0].y); + if area.abs() < 1e-12 || !area.is_finite() { + continue; + } + let bx0 = q.iter().map(|p| p.x).fold(f64::MAX, f64::min).floor().max(0.0) as usize; + let by0 = q.iter().map(|p| p.y).fold(f64::MAX, f64::min).floor().max(0.0) as usize; + let bx1 = (q.iter().map(|p| p.x).fold(f64::MIN, f64::max).ceil().max(0.0) as usize).min(sw); + let by1 = (q.iter().map(|p| p.y).fold(f64::MIN, f64::max).ceil().max(0.0) as usize).min(sh); + let eps = 1e-9 * area.abs(); + for y in by0..by1 { + for x in bx0..bx1 { + let p = Point::new(x as f64 + 0.5, y as f64 + 0.5); + let w0 = ((q[1].x - p.x) * (q[2].y - p.y) - (q[2].x - p.x) * (q[1].y - p.y)) / area; + let w1 = ((q[2].x - p.x) * (q[0].y - p.y) - (q[0].x - p.x) * (q[2].y - p.y)) / area; + let w2 = 1.0 - w0 - w1; + if w0 < -eps || w1 < -eps || w2 < -eps { + continue; + } + let i = y * sw + x; + for k in 0..3 { + val[i][k] = (t[0].c[k] * w0 + t[1].c[k] * w1 + t[2].c[k] * w2) as f32; + } + cov[i] = true; + } + } + } + // Resolve: average the covered samples' colours, alpha = covered fraction. + let mut rgba = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + let mut acc = [0.0f64; 3]; + let mut n = 0; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let i = (2 * y + dy) * sw + 2 * x + dx; + if cov[i] { + let c = colors.rgb(val[i].map(f64::from)); + for k in 0..3 { + acc[k] += c[k] as f64; + } + n += 1; + } + } + if n > 0 { + let o = (y * w + x) * 4; + for k in 0..3 { + rgba[o + k] = (acc[k] / n as f64).round() as u8; + } + rgba[o + 3] = (n * 255 / 4) as u8; + } + } + } + Some(image_node(rgba, w, h, o, s)) +} + +/// Type 1: `Function(x, y)` over `Domain` `[x0 x1 y0 y1]`, mapped by `Matrix`. +fn function_shading(file: &File, d: &Dict, to_page: Affine, page_box: [f64; 4], skip: &mut Vec) -> Option { + let Some(fo) = file.get(d, "Function") else { + skip.push("shading type 1 (no function)".into()); + return None; + }; + let func = Func::read(file, fo); + if func == Func::Unsupported { + skip.push("shading function".into()); + return None; + } + let cs = file.get(d, "ColorSpace").map(|c| color_space(file, c, None)).unwrap_or(Cs::Rgb); + let dom = file.get(d, "Domain").map(|x| file.nums(x)).filter(|v| v.len() == 4).unwrap_or_else(|| vec![0.0, 1.0, 0.0, 1.0]); + let mtx = file.get(d, "Matrix").map(|x| file.nums(x)).filter(|v| v.len() == 6).map(|v| Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]])); + let full = to_page * mtx.unwrap_or(Affine::IDENTITY); + if full.determinant().abs() < 1e-12 || !full.as_coeffs().iter().all(|v| v.is_finite()) { + return None; + } + let corners = [(dom[0], dom[2]), (dom[1], dom[2]), (dom[1], dom[3]), (dom[0], dom[3])].map(|(x, y)| full * Point::new(x, y)); + let lo = Point::new(corners.iter().map(|p| p.x).fold(f64::MAX, f64::min), corners.iter().map(|p| p.y).fold(f64::MAX, f64::min)); + let hi = Point::new(corners.iter().map(|p| p.x).fold(f64::MIN, f64::max), corners.iter().map(|p| p.y).fold(f64::MIN, f64::max)); + let (o, s, w, h) = frame(lo, hi, page_box)?; + let inv = full.inverse(); + let (xa, xb) = (dom[0].min(dom[1]), dom[0].max(dom[1])); + let (ya, yb) = (dom[2].min(dom[3]), dom[2].max(dom[3])); + let mut rgba = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + // 2×2 samples: anti-aliased domain edges. + let mut acc = [0.0f64; 3]; + let mut n = 0; + for (dx, dy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] { + let page = Point::new(o.x + (x as f64 + dx) / s, o.y - (y as f64 + dy) / s); + let q = inv * page; + if q.x < xa - 1e-9 || q.x > xb + 1e-9 || q.y < ya - 1e-9 || q.y > yb + 1e-9 { + continue; + } + let c = cs.to_rgb(&func.eval(&[q.x, q.y])); + for k in 0..3 { + acc[k] += c[k]; + } + n += 1; + } + if n > 0 { + let i = (y * w + x) * 4; + let c = to8(acc.map(|v| v / n as f64)); + rgba[i..i + 3].copy_from_slice(&c); + rgba[i + 3] = (n * 255 / 4) as u8; + } + } + } + Some(image_node(rgba, w, h, o, s)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bits_reader_and_alignment() { + let mut r = Bits { data: &[0b1010_0000, 0xFF], bit: 0 }; + assert_eq!(r.read(3), Some(0b101)); + r.align(); + assert_eq!(r.read(8), Some(0xFF)); + assert_eq!(r.read(1), None); + assert_eq!(r.read(33), None); + } + + #[test] + fn bezier_ends() { + let p = [Point::new(0.0, 0.0), Point::new(1.0, 2.0), Point::new(2.0, 2.0), Point::new(3.0, 0.0)]; + assert_eq!(bez(p, 0.0), p[0]); + assert_eq!(bez(p, 1.0), p[3]); + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 39c82b6e..b3e054a3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -152,11 +152,11 @@ fn filters_decode() { // ------------------------------------------------------------------ M13.6: text, images, // patterns, soft masks, blend modes, pages -type Objs = Vec<(u32, String, Option>)>; +pub(crate) type Objs = Vec<(u32, String, Option>)>; /// A one-page 200×100 PDF: `resources` is the page's resource dictionary body, `extra` more /// objects (numbered from 10). -fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec { +pub(crate) fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec { let mut objs: Objs = vec![ (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), @@ -167,12 +167,12 @@ fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec { pdf(&objs, 1) } -fn render(doc: &Doc) -> effectcraft_raster::Image { +pub(crate) fn render(doc: &Doc) -> effectcraft_raster::Image { let (w, h) = doc.pixel_size(); effectcraft_svg::rasterize(doc, w, h, 1.0) } -fn coverage(img: &effectcraft_raster::Image, x0: i64, y0: i64, x1: i64, y1: i64) -> f32 { +pub(crate) fn coverage(img: &effectcraft_raster::Image, x0: i64, y0: i64, x1: i64, y1: i64) -> f32 { let mut s = 0.0; for y in y0..y1 { for x in x0..x1 { @@ -182,7 +182,7 @@ fn coverage(img: &effectcraft_raster::Image, x0: i64, y0: i64, x1: i64, y1: i64) s } -fn shape_names(doc: &Doc) -> Vec { +pub(crate) fn shape_names(doc: &Doc) -> Vec { fn walk(g: &effectcraft_svg::Group, out: &mut Vec) { for c in &g.children { match c { @@ -223,7 +223,7 @@ fn square_cff() -> Vec { const SQUARE_FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Square /FirstChar 65 /LastChar 65 /Widths [600] /Encoding << /Differences [65 /square] >> /FontDescriptor 11 0 R >>"; -fn square_font_objs() -> Objs { +pub(crate) fn square_font_objs() -> Objs { vec![ (10, SQUARE_FONT.into(), None), (11, "<< /Type /FontDescriptor /FontName /Square /Flags 32 /FontFile3 12 0 R >>".into(), None), diff --git a/crates/pdf/src/tests_shading.rs b/crates/pdf/src/tests_shading.rs new file mode 100644 index 00000000..12ca2ff8 --- /dev/null +++ b/crates/pdf/src/tests_shading.rs @@ -0,0 +1,264 @@ +//! M13.12: function-based and mesh shadings (generated fixtures). + +use super::*; +use crate::tests::{Objs, page_pdf, render}; + +/// Mesh vertex data: 8-bit flags, 16-bit coordinates (Decode `[0 65535 0 65535 …]`, so raw +/// values are page units) and 8-bit components. +#[derive(Default)] +struct Mesh(Vec); + +impl Mesh { + fn flag(&mut self, f: u8) -> &mut Self { + self.0.push(f); + self + } + fn pt(&mut self, x: u16, y: u16) -> &mut Self { + self.0.extend_from_slice(&x.to_be_bytes()); + self.0.extend_from_slice(&y.to_be_bytes()); + self + } + fn c(&mut self, c: &[u8]) -> &mut Self { + self.0.extend_from_slice(c); + self + } +} + +const RGB_DECODE: &str = "/Decode [0 65535 0 65535 0 1 0 1 0 1]"; + +fn close(a: [f32; 4], b: [f32; 3], tol: f32) -> bool { + a[3] > 0.99 && (0..3).all(|i| (a[i] - b[i]).abs() < tol) +} + +fn sh_page(shading: &str, data: Vec) -> Doc { + let bytes = page_pdf("/Sh sh", "/Shading << /Sh 10 0 R >>", vec![(10, shading.into(), Some(data))]); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + doc +} + +#[test] +fn free_form_triangle_mesh_with_edge_flags() { + let mut m = Mesh::default(); + // Triangle (20,20) red, (80,20) green, (20,80) blue; then flag 1: (80,20) (20,80) (80,80) white. + m.flag(0).pt(20, 20).c(&[255, 0, 0]); + m.flag(0).pt(80, 20).c(&[0, 255, 0]); + m.flag(0).pt(20, 80).c(&[0, 0, 255]); + m.flag(1).pt(80, 80).c(&[255, 255, 255]); + // Flag 2 off the shared edge (a, c): (80,20) (80,80) → (140,20) black. + m.flag(2).pt(140, 20).c(&[0, 0, 0]); + let doc = sh_page(&format!("<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 {RGB_DECODE} >>"), m.0); + let img = render(&doc); + let at = |x: i64, y: i64| img.get(x, 99 - y); + assert!(close(at(21, 21), [1.0, 0.0, 0.0], 0.06), "{:?}", at(21, 21)); + // Centroid of the first triangle: the average of its colours. + assert!(close(at(40, 40), [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], 0.04), "{:?}", at(40, 40)); + assert!(close(at(79, 79), [1.0, 1.0, 1.0], 0.06), "{:?}", at(79, 79)); + // Third triangle (a = (80,20) green, c = (80,80) white, (140,20) black). + assert!(close(at(135, 22), [0.0, 0.0, 0.0], 0.1), "{:?}", at(135, 22)); + // Outside the mesh: nothing. + assert_eq!(at(10, 50)[3], 0.0); + assert_eq!(at(150, 90)[3], 0.0); + // The shading is an image node (no Gouraud primitive in the render tree). + assert!(crate::tests::shape_names(&doc).contains(&"Shading".to_string())); +} + +#[test] +fn lattice_mesh_with_a_function() { + // Two rows of three vertices; t = 0 at the left, 1 at the right; grey ramp function. + let mut m = Mesh::default(); + for y in [10u16, 90] { + for (x, t) in [(10u16, 0u8), (100, 128), (190, 255)] { + m.pt(x, y).c(&[t]); + } + } + let doc = sh_page( + "<< /ShadingType 5 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /VerticesPerRow 3 /Decode [0 65535 0 65535 0 1] \ + /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 1 1] /N 1 >> >>", + m.0, + ); + let img = render(&doc); + let at = |x: i64, y: i64| img.get(x, 99 - y); + for x in [20i64, 60, 100, 140, 180] { + let want = (x as f32 + 0.5 - 10.0) / 180.0; + let p = at(x, 50); + assert!(p[3] > 0.99 && (p[0] - want).abs() < 0.03 && (p[1] - p[0]).abs() < 0.01, "x {x}: {p:?} want {want}"); + } + assert_eq!(at(5, 50)[3], 0.0); +} + +/// A straight-sided rectangle as the 12 boundary points from `(x0, y0)` up the left side, +/// along the top, down the right side and back along the bottom. +fn rect_loop(x0: u16, y0: u16, x1: u16, y1: u16) -> Vec<(u16, u16)> { + let lerp = |a: u16, b: u16, k: u16| ((a as u32 * (3 - k as u32) + b as u32 * k as u32) / 3) as u16; + let mut v = vec![]; + for k in 0..3 { + v.push((x0, lerp(y0, y1, k))); + } + for k in 0..3 { + v.push((lerp(x0, x1, k), y1)); + } + for k in 0..3 { + v.push((x1, lerp(y1, y0, k))); + } + for k in 0..3 { + v.push((lerp(x1, x0, k), y0)); + } + v +} + +fn patch_doc(tensor: bool) -> Doc { + let mut m = Mesh::default(); + // Patch 1 over x 100..140, y 20..80: corners (100,20) red, (100,80) green, (140,80) blue, + // (140,20) black. + m.flag(0); + let l1 = rect_loop(100, 20, 140, 80); + for (x, y) in &l1 { + m.pt(*x, *y); + } + if tensor { + // Interior points p11 p12 p22 p21 at the thirds. + for (x, y) in [(113, 40), (113, 60), (127, 60), (127, 40)] { + m.pt(x, y); + } + } + m.c(&[255, 0, 0]).c(&[0, 255, 0]).c(&[0, 0, 255]).c(&[0, 0, 0]); + // Patch 2 (flag 2): shares patch 1's third side (140,80) → (140,20); then (180,20) white, + // (180,80) red. + m.flag(2); + // The 8 new points: from (140,20) along the bottom to (180,20), up to (180,80), back + // along the top towards (140,80). + let new_pts = [(153, 20), (167, 20), (180, 20), (180, 40), (180, 60), (180, 80), (167, 80), (153, 80)]; + for (x, y) in new_pts { + m.pt(x, y); + } + if tensor { + for (x, y) in [(153, 60), (153, 40), (167, 40), (167, 60)] { + m.pt(x, y); + } + } + m.c(&[255, 255, 255]).c(&[255, 0, 0]); + let ty = if tensor { 7 } else { 6 }; + sh_page(&format!("<< /ShadingType {ty} /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 {RGB_DECODE} >>"), m.0) +} + +#[test] +fn coons_and_tensor_patch_meshes() { + for tensor in [false, true] { + let doc = patch_doc(tensor); + let img = render(&doc); + let at = |x: i64, y: i64| img.get(x, 99 - y); + let k = if tensor { "tensor" } else { "coons" }; + assert!(close(at(100, 20), [1.0, 0.0, 0.0], 0.08), "{k} {:?}", at(100, 20)); + assert!(close(at(100, 79), [0.0, 1.0, 0.0], 0.08), "{k} {:?}", at(100, 79)); + assert!(close(at(139, 79), [0.0, 0.0, 1.0], 0.08), "{k} {:?}", at(139, 79)); + // Patch centre: the average of the corners. + assert!(close(at(120, 50), [0.25, 0.25, 0.25], 0.04), "{k} {:?}", at(120, 50)); + // Patch 2 inherits blue / black on its shared side. + assert!(close(at(179, 20), [1.0, 1.0, 1.0], 0.08), "{k} {:?}", at(179, 20)); + assert!(close(at(179, 79), [1.0, 0.0, 0.0], 0.08), "{k} {:?}", at(179, 79)); + assert!(close(at(141, 22), [0.0, 0.0, 0.0], 0.1), "{k} {:?}", at(141, 22)); + assert_eq!(at(90, 50)[3], 0.0); + assert_eq!(at(190, 50)[3], 0.0); + } +} + +#[test] +fn curved_coons_patch_follows_its_boundary() { + // A patch whose top side bulges up to y = 95 between x 40 and 60. + let mut m = Mesh::default(); + m.flag(0); + let pts = [(20, 20), (20, 40), (20, 60), (20, 80), (40, 107), (60, 107), (80, 80), (80, 60), (80, 40), (80, 20), (60, 20), (40, 20)]; + for (x, y) in pts { + m.pt(x, y); + } + m.c(&[255, 0, 0]).c(&[255, 0, 0]).c(&[255, 0, 0]).c(&[255, 0, 0]); + let doc = sh_page(&format!("<< /ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 {RGB_DECODE} >>"), m.0); + let img = render(&doc); + let at = |x: i64, y: i64| img.get(x, 99 - y); + // The top curve peaks at 80 + 0.75·27 ≈ 100 at x = 50. + assert!(at(50, 95)[3] > 0.99, "{:?}", at(50, 95)); + assert_eq!(at(22, 95)[3], 0.0); +} + +#[test] +fn function_based_shadings_calculator_and_sampled() { + let bytes = page_pdf( + "/A sh /B sh", + "/Shading << /A 10 0 R /B 11 0 R >>", + vec![ + (10, "<< /ShadingType 1 /ColorSpace /DeviceRGB /Matrix [100 0 0 50 0 0] /Function 12 0 R >>".into(), None), + (11, "<< /ShadingType 1 /ColorSpace /DeviceRGB /Domain [0 1 0 1] /Matrix [100 0 0 50 100 50] /Function 13 0 R >>".into(), None), + (12, "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] >>".into(), Some(b"{ 0 }".to_vec())), + ( + 13, + "<< /FunctionType 0 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Size [2 2] /BitsPerSample 8 >>".into(), + Some(vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]), + ), + ], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + let at = |x: i64, y: i64| img.get(x, 99 - y); + // Calculator: (x, y) → (x, y, 0). + let p = at(90, 10); + assert!(close(p, [0.905, 0.21, 0.0], 0.03), "{p:?}"); + // Only the domain is painted. + assert_eq!(at(50, 75)[3], 0.0); + // Sampled 2-in function, bilinear: red (0,0), green (1,0), blue (0,1), white (1,1). + assert!(close(at(150, 75), [0.5, 0.5, 0.5], 0.03), "{:?}", at(150, 75)); + assert!(close(at(100, 50), [1.0, 0.0, 0.0], 0.04), "{:?}", at(100, 50)); + assert!(close(at(199, 99), [1.0, 1.0, 1.0], 0.04), "{:?}", at(199, 99)); +} + +#[test] +fn mesh_shading_pattern_fills_a_path() { + let mut m = Mesh::default(); + m.flag(0).pt(0, 0).c(&[255, 0, 0]); + m.flag(0).pt(200, 0).c(&[255, 0, 0]); + m.flag(0).pt(0, 200).c(&[255, 0, 0]); + let objs: Objs = vec![ + (10, "<< /PatternType 2 /Shading 11 0 R >>".into(), None), + (11, format!("<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 {RGB_DECODE} >>"), Some(m.0)), + ]; + let bytes = page_pdf("/Pattern cs /P scn 10 10 30 30 re f", "/Pattern << /P 10 0 R >>", objs); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + let at = |x: i64, y: i64| img.get(x, 99 - y); + assert!(close(at(20, 20), [1.0, 0.0, 0.0], 0.02), "{:?}", at(20, 20)); + // Clipped to the path. + assert_eq!(at(50, 20)[3], 0.0); + assert_eq!(at(5, 5)[3], 0.0); +} + +#[test] +fn corrupt_mesh_data_draws_what_parses() { + // Truncated and garbage vertex data never panic. + for ty in [4, 5, 6, 7] { + for data in [vec![], vec![0u8; 3], vec![0xFF; 7], (0..=255u8).collect::>(), vec![3u8; 200]] { + let bytes = page_pdf( + "/Sh sh /Pattern cs /P scn 0 0 50 50 re f", + "/Shading << /Sh 10 0 R >> /Pattern << /P << /PatternType 2 /Shading 10 0 R >> >>", + vec![( + 10, + format!( + "<< /ShadingType {ty} /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 /VerticesPerRow 2 {RGB_DECODE} >>" + ), + Some(data), + )], + ); + let doc = parse(&bytes).unwrap(); + let _ = render(&doc); + } + } + // Bad layouts are reported. + let bytes = page_pdf( + "/Sh sh", + "/Shading << /Sh 10 0 R >>", + vec![(10, "<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 7 >>".into(), Some(vec![0; 16]))], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.iter().any(|s| s.contains("layout")), "{:?}", doc.skipped); +}