From b0b8d4c01a296833fbabe1e51d365a3c50278082 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:42:49 -0400 Subject: [PATCH 01/10] M13.12: pdf mesh shadings (types 4-7) and function-based shadings Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/color.rs | 72 +++- crates/pdf/src/lib.rs | 3 + crates/pdf/src/page.rs | 50 ++- crates/pdf/src/shading.rs | 580 ++++++++++++++++++++++++++++++++ crates/pdf/src/tests.rs | 12 +- crates/pdf/src/tests_shading.rs | 264 +++++++++++++++ 6 files changed, 968 insertions(+), 13 deletions(-) create mode 100644 crates/pdf/src/shading.rs create mode 100644 crates/pdf/src/tests_shading.rs 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); +} From d15b69c734c766aa206c73d79754fc7b579f4849 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:46:41 -0400 Subject: [PATCH 02/10] M13.12: pdf knockout/isolated transparency groups, group alpha and masks on group results Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/build.rs | 2 + crates/pdf/src/lib.rs | 2 + crates/pdf/src/page.rs | 29 ++++++- crates/pdf/src/tests_groups.rs | 99 ++++++++++++++++++++++++ crates/svg/src/lib.rs | 52 ++++++++++++- crates/svg/src/raster.rs | 134 +++++++++++++++++++++++++++++---- 6 files changed, 296 insertions(+), 22 deletions(-) create mode 100644 crates/pdf/src/tests_groups.rs diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 4eeeb53b..7023e931 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -62,6 +62,8 @@ impl Builder { pub fn clip(&mut self, path: BezPath, rule: FillRule) { let mut g = group("Clip Group"); g.clip.push((path, rule)); + // Clipping is not a transparency group: blend modes inside reach the backdrop. + g.isolated = false; self.stack.push((g, Kind::Clip)); } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index a2f98c9f..985a7083 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -194,4 +194,6 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_groups; +#[cfg(test)] mod tests_shading; diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 67d9ff8b..b4a7799d 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -922,12 +922,33 @@ impl<'a> Interp<'a> { let m = f.get(d, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); let form_res = f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone()); self.saved.push((self.gs.clone(), self.b.depth())); - self.gs.ctm *= m; + // A transparency group XObject (§11.6.6) composites as one object: the current alpha + // (and the blend-mode and soft-mask groups already open around it) apply to its + // result, and its contents start from the initial alpha. let bb = f.get(d, "BBox").map(|b| f.nums(b)).unwrap_or_default(); - if bb.len() == 4 { - let r = kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]); - self.b.clip(self.gs.ctm * r.to_path(0.1), FillRule::NonZero); + let bbox_path = |ctm: Affine| (bb.len() == 4).then(|| ctm * m * kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]).to_path(0.1)); + let mut clipped = false; + if let Some(tg) = f.get_dict(d, "Group").filter(|g| f.get(g, "S").and_then(Obj::name) == Some("Transparency")) { + let mut grp = Group::new("Transparency Group"); + // The bounding box clips the group itself (its children stay its direct children, + // as knockout needs). + if let Some(p) = bbox_path(self.gs.ctm) { + grp.clip.push((p, FillRule::NonZero)); + clipped = true; + } + grp.opacity = self.gs.fill_alpha; + grp.isolated = matches!(f.get(tg, "I"), Some(Obj::Bool(true))); + grp.knockout = matches!(f.get(tg, "K"), Some(Obj::Bool(true))); + self.b.open_group(grp); + self.gs.fill_alpha = 1.0; + self.gs.stroke_alpha = 1.0; + self.gs.blend_depth = None; + self.gs.mask_depth = None; } + if !clipped && let Some(p) = bbox_path(self.gs.ctm) { + self.b.clip(p, FillRule::NonZero); + } + self.gs.ctm *= m; let base = self.gs.ctm; let path = std::mem::take(&mut self.path); let (tm, tlm) = (self.tm, self.tlm); diff --git a/crates/pdf/src/tests_groups.rs b/crates/pdf/src/tests_groups.rs new file mode 100644 index 00000000..3e27dd7c --- /dev/null +++ b/crates/pdf/src/tests_groups.rs @@ -0,0 +1,99 @@ +//! M13.12: transparency groups (isolated, knockout, group alpha) and soft masks on a group's +//! combined result. + +use super::*; +use crate::tests::{page_pdf, render}; + +fn near(a: [f32; 4], b: [f32; 4]) -> bool { + (0..4).all(|i| (a[i] - b[i]).abs() < 0.02) +} + +/// A page painting form `Fm` with graphics state `GS` set; the form has `group` as its +/// `/Group` entry body and `content`. +fn group_page(page: &str, gs: &str, group: &str, content: &str) -> Doc { + let bytes = page_pdf( + page, + &format!("/ExtGState << /GS << {gs} >> /M << /BM /Multiply >> /H << /ca 0.5 >> >> /XObject << /Fm 10 0 R >>"), + vec![( + 10, + format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] {group} /Resources << /ExtGState << /M << /BM /Multiply >> /H << /ca 0.5 >> >> >> >>" + ), + Some(content.as_bytes().to_vec()), + )], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + doc +} + +#[test] +fn group_alpha_applies_to_the_combined_result() { + let content = "1 0 0 rg 0 0 100 100 re f 0 0 1 rg 50 0 100 100 re f"; + // A transparency group: the overlap is blue at half opacity (no red shows through). + let doc = group_page("/GS gs /Fm Do", "/ca 0.5", "/Group << /S /Transparency >>", content); + let img = render(&doc); + assert!(near(img.get(75, 50), [0.0, 0.0, 0.5, 0.5]), "{:?}", img.get(75, 50)); + assert!(near(img.get(25, 50), [0.5, 0.0, 0.0, 0.5]), "{:?}", img.get(25, 50)); + // A plain form: each rectangle at half opacity. + let doc = group_page("/GS gs /Fm Do", "/ca 0.5", "", content); + let img = render(&doc); + assert!(near(img.get(75, 50), [0.25, 0.0, 0.5, 0.75]), "{:?}", img.get(75, 50)); +} + +#[test] +fn knockout_groups_composite_with_the_initial_backdrop() { + // Red, then half-opaque blue over it: knocked out, the overlap shows only the blue. + let content = "1 0 0 rg 0 0 100 100 re f /H gs 0 0 1 rg 50 0 100 100 re f"; + let ko = group_page("/Fm Do", "", "/Group << /S /Transparency /I true /K true >>", content); + let img = render(&ko); + assert!(near(img.get(75, 50), [0.0, 0.0, 0.5, 0.5]), "knockout {:?}", img.get(75, 50)); + assert!(near(img.get(25, 50), [1.0, 0.0, 0.0, 1.0]), "{:?}", img.get(25, 50)); + let plain = group_page("/Fm Do", "", "/Group << /S /Transparency /I true >>", content); + let img = render(&plain); + assert!(near(img.get(75, 50), [0.5, 0.0, 0.5, 1.0]), "plain {:?}", img.get(75, 50)); + // Non-isolated knockout over a white page: the overlap is half blue over the page. + let ko_bd = group_page("1 g 0 0 200 100 re f /Fm Do", "", "/Group << /S /Transparency /K true >>", content); + let img = render(&ko_bd); + assert!(near(img.get(75, 50), [0.5, 0.5, 1.0, 1.0]), "non-isolated knockout {:?}", img.get(75, 50)); +} + +#[test] +fn isolated_and_non_isolated_groups_with_blend_modes() { + // Yellow page; the group multiplies cyan over it at half group opacity. + let content = "/M gs 0 1 1 rg 0 0 200 100 re f"; + let non = group_page("1 1 0 rg 0 0 200 100 re f /GS gs /Fm Do", "/ca 0.5", "/Group << /S /Transparency >>", content); + let img = render(&non); + // yellow × cyan = green, half over yellow. + assert!(near(img.get(100, 50), [0.5, 1.0, 0.0, 1.0]), "non-isolated {:?}", img.get(100, 50)); + let iso = group_page("1 1 0 rg 0 0 200 100 re f /GS gs /Fm Do", "/ca 0.5", "/Group << /S /Transparency /I true >>", content); + let img = render(&iso); + // Isolated: multiply against nothing = cyan, half over yellow. + assert!(near(img.get(100, 50), [0.5, 1.0, 0.5, 1.0]), "isolated {:?}", img.get(100, 50)); +} + +#[test] +fn blend_modes_inside_clips_reach_the_backdrop() { + let bytes = page_pdf("1 1 0 rg 0 0 200 100 re f q 0 0 100 100 re W n /M gs 0 1 1 rg 0 0 200 100 re f Q", "/ExtGState << /M << /BM /Multiply >> >>", vec![]); + let img = render(&parse(&bytes).unwrap()); + assert!(near(img.get(50, 50), [0.0, 1.0, 0.0, 1.0]), "{:?}", img.get(50, 50)); + assert!(near(img.get(150, 50), [1.0, 1.0, 0.0, 1.0]), "{:?}", img.get(150, 50)); +} + +#[test] +fn soft_mask_applies_to_the_masked_group_result() { + // Two overlapping opaque rectangles under a 50 % grey luminosity mask: the overlap is + // the top rectangle at half opacity (the mask applies to their composite). + let bytes = page_pdf( + "q /GS1 gs 1 0 0 rg 0 0 100 100 re f 0 0 1 rg 50 0 100 100 re f Q", + "/ExtGState << /GS1 << /SMask << /S /Luminosity /G 10 0 R >> >> >>", + vec![( + 10, + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Group << /S /Transparency /CS /DeviceGray >> >>".into(), + Some(b"0.5 g 0 0 200 100 re f".to_vec()), + )], + ); + let img = render(&parse(&bytes).unwrap()); + let p = img.get(75, 50); + assert!((p[3] - 0.5).abs() < 0.02 && p[0] < 0.02 && (p[2] - 0.5).abs() < 0.02, "{p:?}"); +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 8bc170b1..2825cdfa 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -179,12 +179,30 @@ pub struct Group { /// A soft mask (PDF `SMask` in the graphics state): the group shows where the mask's /// luminosity (or alpha) is high. pub mask: Option>, + /// Composited on its own (a transparent backdrop, as SVG groups and PDF isolated + /// transparency groups are) rather than over what is below it (PDF non-isolated groups and + /// clipping groups: blend modes inside reach the backdrop). Matters only when the group is + /// drawn offscreen (clip, mask, blend mode, opacity or knockout). + pub isolated: bool, + /// A PDF knockout group: each child composites with the group's initial backdrop instead + /// of with the children below it. + pub knockout: bool, } impl Group { /// An empty, opaque, unclipped group. pub fn new(name: &str) -> Group { - Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![], blend: BlendMode::Normal, mask: None } + Group { + name: name.to_string(), + transform: Affine::IDENTITY, + opacity: 1.0, + children: vec![], + clip: vec![], + blend: BlendMode::Normal, + mask: None, + isolated: true, + knockout: false, + } } } @@ -753,7 +771,17 @@ impl<'a, 'i> Parser<'a, 'i> { for c in n.children() { self.node(c, &st, &mut children, depth + 1); } - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None })); + out.push(Node::Group(Group { + name: Self::element_name(n), + transform, + opacity, + children, + clip: vec![], + blend: BlendMode::Normal, + mask: None, + isolated: true, + knockout: false, + })); } "svg" => { // Nested viewport. @@ -780,6 +808,8 @@ impl<'a, 'i> Parser<'a, 'i> { clip: vec![], blend: BlendMode::Normal, mask: None, + isolated: true, + knockout: false, })); } "use" => { @@ -809,11 +839,23 @@ impl<'a, 'i> Parser<'a, 'i> { clip: vec![], blend: BlendMode::Normal, mask: None, + isolated: true, + knockout: false, })); } else { self.node(target, &st, &mut children, depth + 1); } - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None })); + out.push(Node::Group(Group { + name: Self::element_name(n), + transform, + opacity, + children, + clip: vec![], + blend: BlendMode::Normal, + mask: None, + isolated: true, + knockout: false, + })); } "path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => { if !st.visible { @@ -861,6 +903,8 @@ impl<'a, 'i> Parser<'a, 'i> { clip: vec![], blend: BlendMode::Normal, mask: None, + isolated: true, + knockout: false, })); } } @@ -1056,7 +1100,7 @@ pub fn parse(bytes: &[u8]) -> Result { Ok(Doc { width: dw, height: dh, - root: Group { name: "svg".into(), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None }, + root: Group { name: "svg".into(), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None, isolated: true, knockout: false }, skipped: std::mem::take(&mut p.skipped), }) } diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index 975adc5a..b1b9d93b 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -31,26 +31,62 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { if o <= 0.0 { return; } - let isolated = !g.clip.is_empty() || g.mask.is_some() || g.blend != BlendMode::Normal || (g.opacity < 1.0 && g.children.len() > 1); - if !isolated { + let offscreen = !g.clip.is_empty() + || g.mask.is_some() + || g.blend != BlendMode::Normal + || (g.opacity < 1.0 && !matches!(g.children.as_slice(), [Node::Shape(_) | Node::Image(_)])) + || g.knockout; + if !offscreen { for c in &g.children { draw_node(c, m, o, dst); } return; } - // Draw the children on their own, keep what every clip path covers and the soft mask - // lets through, then composite with the group's opacity and blend mode. - let mut tmp = Image::new(dst.width, dst.height); - for c in &g.children { - draw_node(c, m, 1.0, &mut tmp); + let only_clip = g.mask.is_none() && g.blend == BlendMode::Normal && o >= 1.0 && !g.knockout; + // Draw the children on their own (isolated) or over a copy of the backdrop, keep what + // every clip path covers and the soft mask lets through, then composite with the group's + // opacity and blend mode. + let backdrop = if g.isolated { None } else { Some(dst.clone()) }; + let mut tmp = match &backdrop { + Some(b) => b.clone(), + None => Image::new(dst.width, dst.height), + }; + draw_children(g, m, &mut tmp, backdrop.as_ref()); + if let Some(b) = &backdrop { + if only_clip { + // A non-isolated clipping group: the backdrop where the clip excludes the children. + let cov = clip_coverage(g, m, dst.width, dst.height); + dst.data.par_iter_mut().zip(tmp.data.par_iter()).zip(cov.par_iter()).for_each(|((d, t), c)| { + for i in 0..4 { + d[i] = d[i] * (1.0 - c) + t[i] * c; + } + }); + return; + } + // Remove the backdrop's contribution (ISO 32000-1 §11.4.8): with the group's own + // alpha αg (its children alone), C = Cn + (Cn − C0)·(α0/αg − α0). + let mut solo = Image::new(dst.width, dst.height); + draw_children(g, m, &mut solo, None); + tmp.data.par_iter_mut().zip(b.data.par_iter()).zip(solo.data.par_iter()).for_each(|((t, b0), s)| { + let ag = s[3]; + if ag <= 0.0 { + *t = [0.0; 4]; + return; + } + let un = |c: &[f32; 4]| if c[3] > 0.0 { [c[0] / c[3], c[1] / c[3], c[2] / c[3]] } else { [0.0; 3] }; + let (cn, c0, a0) = (un(t), un(b0), b0[3]); + let k = a0 / ag - a0; + let mut out = [0.0; 4]; + for i in 0..3 { + out[i] = (cn[i] + (cn[i] - c0[i]) * k).clamp(0.0, 1.0) * ag; + } + out[3] = ag; + *t = out; + }); } - for (path, rule) in &g.clip { - let rule = match rule { - FillRule::NonZero => effectcraft_path::FillRule::NonZero, - FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd, - }; - let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), dst.width, dst.height, rule); - tmp.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(p, c)| { + if !g.clip.is_empty() { + let cov = clip_coverage(g, m, dst.width, dst.height); + tmp.data.par_iter_mut().zip(cov.par_iter()).for_each(|(p, c)| { for v in p.iter_mut() { *v *= c; } @@ -89,6 +125,76 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { } } +/// The product of a group's clip-path coverages. +fn clip_coverage(g: &Group, m: Affine, w: u32, h: u32) -> Vec { + let mut out = vec![1.0f32; w as usize * h as usize]; + for (path, rule) in &g.clip { + let rule = match rule { + FillRule::NonZero => effectcraft_path::FillRule::NonZero, + FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd, + }; + let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), w, h, rule); + out.par_iter_mut().zip(cov.data.par_iter()).for_each(|(o, c)| *o *= c); + } + out +} + +/// Draw a group's children into `dst`. Knockout groups composite each child with the +/// group's initial backdrop (`backdrop`, or transparent) instead of with the children below: +/// where the child has shape `f`, `dst = dst·(1 − f) + child + backdrop·(f − αchild)`. +fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>) { + if !g.knockout { + for c in &g.children { + draw_node(c, m, 1.0, dst); + } + return; + } + for c in &g.children { + let mut ci = Image::new(dst.width, dst.height); + draw_node(c, m, 1.0, &mut ci); + // Shape: the child drawn fully opaque (constant opacities to 1). + let mut si = Image::new(dst.width, dst.height); + draw_node(&opaque(c), m, 1.0, &mut si); + let zero = [0.0f32; 4]; + dst.data.par_iter_mut().enumerate().for_each(|(i, d)| { + let (cp, f) = (ci.data[i], si.data[i][3].max(ci.data[i][3])); + if f <= 0.0 { + return; + } + let b = backdrop.map(|b| b.data[i]).unwrap_or(zero); + for k in 0..4 { + d[k] = d[k] * (1.0 - f) + cp[k] + b[k] * (f - cp[3]); + } + }); + } +} + +/// A node with its constant opacities set to 1 (its shape, for knockout groups). +fn opaque(n: &Node) -> Node { + match n { + Node::Group(g) => { + let mut g = g.clone(); + g.opacity = 1.0; + g.children = g.children.iter().map(opaque).collect(); + Node::Group(g) + } + Node::Shape(s) => { + let mut s = s.clone(); + s.opacity = 1.0; + s.fill_opacity = 1.0; + if let Some(st) = &mut s.stroke { + st.opacity = 1.0; + } + Node::Shape(s) + } + Node::Image(i) => { + let mut i = i.clone(); + i.opacity = 1.0; + Node::Image(i) + } + } +} + /// `src` composited onto `dst` with a blend mode (both premultiplied RGBA): /// `co = cs·(1 − αb) + cb·(1 − αs) + αs·αb·B(cb, cs)` (ISO 32000-1 §11.3.6). pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4]) -> [f32; 4] { From 3d536cfe5a22af869f6216b05fc38f1fef65e652 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:48:25 -0400 Subject: [PATCH 03/10] M13.12: pdf /W2 and /DW2 vertical metrics, CMap WMode Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/font.rs | 59 ++++++++++++++++++++++++++++++++++ crates/pdf/src/page.rs | 5 +-- crates/pdf/src/tests_groups.rs | 54 +++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs index 480873b0..c2716fc8 100644 --- a/crates/pdf/src/font.rs +++ b/crates/pdf/src/font.rs @@ -57,6 +57,10 @@ pub(crate) struct Font { codespace: Vec<(usize, u32, u32)>, cid_ranges: Vec<(u32, u32, u32)>, pub vertical: bool, + /// Vertical metrics (`/W2`, in text space): CID → (w1y, vx, vy). + cid_vmetrics: HashMap, + /// `/DW2`: default (vy, w1y). + dw2: (f64, f64), cache: RefCell>>>, /// Program glyph advances (font units → text space) for fonts without `/Widths`. has_widths: bool, @@ -194,6 +198,8 @@ struct CMap { codespace: Vec<(usize, u32, u32)>, unicode: HashMap, cids: Vec<(u32, u32, u32)>, + /// `/WMode 1`: vertical writing. + wmode: bool, } fn be(b: &[u8]) -> u32 { @@ -285,6 +291,14 @@ fn parse_cmap(data: &[u8]) -> CMap { } mode = ""; } + "def" if mode.is_empty() => { + if let [.., Obj::Name(k), Obj::Num(v)] = args.as_slice() + && k == "WMode" + { + m.wmode = *v == 1.0; + } + args.clear(); + } _ => { if mode.is_empty() { args.clear(); @@ -315,6 +329,8 @@ impl Font { codespace: vec![], cid_ranges: vec![], vertical: false, + cid_vmetrics: HashMap::new(), + dw2: (0.88, -1.0), cache: RefCell::new(HashMap::new()), has_widths: false, base_name: base_name.clone(), @@ -366,6 +382,37 @@ impl Font { } } } + // Vertical metrics (§9.7.4.3): /DW2 [vy w1y] and /W2 entries + // `c [w1y vx vy …]` or `cfirst clast w1y vx vy`. + let dw2 = file.get(&desc_dict, "DW2").map(|x| file.nums(x)).unwrap_or_default(); + if dw2.len() == 2 { + f.dw2 = (dw2[0] / 1000.0, dw2[1] / 1000.0); + } + if let Some(w) = file.get(&desc_dict, "W2").and_then(Obj::array) { + let num = |i: usize| w.get(i).and_then(|x| file.resolve(x).num()); + let mut i = 0; + while i < w.len() { + let Some(first) = num(i) else { break }; + let first = first.max(0.0) as u32; + match w.get(i + 1).map(|x| file.resolve(x)) { + Some(Obj::Array(list)) => { + let v: Vec = list.iter().filter_map(|x| file.resolve(x).num()).collect(); + for (k, t) in v.chunks_exact(3).enumerate() { + f.cid_vmetrics.insert(first + k as u32, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0)); + } + i += 2; + } + Some(Obj::Num(last)) => { + let (Some(w1), Some(vx), Some(vy)) = (num(i + 2), num(i + 3), num(i + 4)) else { break }; + for c in first..=(last.max(0.0) as u32).min(first + 65535) { + f.cid_vmetrics.insert(c, (w1 / 1000.0, vx / 1000.0, vy / 1000.0)); + } + i += 5; + } + _ => break, + } + } + } f.has_widths = true; // Encoding CMap. match file.get(d, "Encoding") { @@ -378,6 +425,7 @@ impl Font { Some(Obj::Stream(sd, raw)) => { if let Some(data) = decode_stream(file, sd, raw) { let cm = parse_cmap(&data); + f.vertical = cm.wmode || file.get_num(sd, "WMode") == Some(1.0); f.codespace = cm.codespace; f.cid_ranges = cm.cids; } @@ -530,6 +578,17 @@ impl Font { self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map(|(lo, _, c)| c + (code - lo)).unwrap_or(0) } + /// Vertical metrics of a code in text space (before the font size): the vertical + /// displacement `w1y` (negative: downwards) and the position vector `(vx, vy)` from the + /// horizontal origin to the vertical origin (§9.7.4.3; defaults from `/DW2`, `vx` half the + /// horizontal advance). + pub fn vmetrics(&self, code: u32) -> (f64, f64, f64) { + match self.cid_vmetrics.get(&self.cid(code)) { + Some(m) => *m, + None => (self.dw2.1, self.width(code) / 2.0, self.dw2.0), + } + } + /// The advance of a code in text space (before the font size). pub fn width(&self, code: u32) -> f64 { if self.composite { diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index b4a7799d..c754c1dd 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -1047,7 +1047,8 @@ impl<'a> Interp<'a> { for (code, nbytes) in font.codes(s) { let w0 = font.width(code); let trm = self.tm * Affine::new([fs * th, 0.0, 0.0, fs, 0.0, rise]); - let trm = if font.vertical { trm * Affine::translate((-w0 / 2.0, -0.88)) } else { trm }; + let (w1y, vx, vy) = if font.vertical { font.vmetrics(code) } else { (0.0, 0.0, 0.0) }; + let trm = if font.vertical { trm * Affine::translate((-vx, -vy)) } else { trm }; if let Some((proc_, t3res)) = font.type3_proc(code) { let proc_ = proc_.to_vec(); let r = t3res.cloned().unwrap_or_else(|| res.clone()); @@ -1060,7 +1061,7 @@ impl<'a> Interp<'a> { text.push_str(&font.unicode(code)); let tw = if nbytes == 1 && code == 32 { self.gs.word_spacing } else { 0.0 }; if font.vertical { - self.tm *= Affine::translate((0.0, -(fs + self.gs.char_spacing + tw))); + self.tm *= Affine::translate((0.0, w1y * fs - self.gs.char_spacing - tw)); } else { self.tm *= Affine::translate(((w0 * fs + self.gs.char_spacing + tw) * th, 0.0)); } diff --git a/crates/pdf/src/tests_groups.rs b/crates/pdf/src/tests_groups.rs index 3e27dd7c..ce928139 100644 --- a/crates/pdf/src/tests_groups.rs +++ b/crates/pdf/src/tests_groups.rs @@ -97,3 +97,57 @@ fn soft_mask_applies_to_the_masked_group_result() { let p = img.get(75, 50); assert!((p[3] - 0.5).abs() < 0.02 && p[0] < 0.02 && (p[2] - 0.5).abs() < 0.02, "{p:?}"); } + +/// The ink bounding box `(x0, y0, x1, y1)` of a render (pixels with alpha > 0.5). +fn ink_box(img: &effectcraft_raster::Image) -> (i64, i64, i64, i64) { + let (mut x0, mut y0, mut x1, mut y1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN); + for y in 0..img.height as i64 { + for x in 0..img.width as i64 { + if img.get(x, y)[3] > 0.5 { + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + } + (x0, y0, x1, y1) +} + +#[test] +fn vertical_metrics_w2_and_dw2() { + use skrifa::MetadataProvider; + let inter = effectcraft_text::fonts::INTER_REGULAR.to_vec(); + let font = skrifa::FontRef::new(&inter).unwrap(); + let h = font.charmap().map('H').unwrap().to_u32(); + let doc = |desc_extra: &str| { + let bytes = page_pdf( + &format!("BT /F1 20 Tf 100 95 Td <{h:04X}{h:04X}> Tj ET"), + "/Font << /F1 10 0 R >>", + vec![ + (10, "<< /Type /Font /Subtype /Type0 /BaseFont /Inter /Encoding /Identity-V /DescendantFonts [13 0 R] >>".into(), None), + ( + 13, + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Inter /CIDToGIDMap /Identity /W [{h} [700]] {desc_extra} /FontDescriptor 11 0 R >>" + ), + None, + ), + (11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None), + (12, "<< >>".into(), Some(inter.clone())), + ], + ); + let d = parse(&bytes).unwrap(); + assert!(d.skipped.is_empty(), "{:?}", d.skipped); + ink_box(&render(&d)) + }; + // Defaults: an advance of 1 em down; the vertical origin (350, 880) from the horizontal one. + let base = doc(""); + // /W2: a 2 em advance and vx 250. + let w2 = doc(&format!("/W2 [{h} [-2000 250 880]]")); + assert_eq!(w2.1, base.1, "the first glyph's top stays: {base:?} {w2:?}"); + assert!(((w2.3 - base.3) - 20).abs() <= 1, "second glyph 20 pt lower: {base:?} {w2:?}"); + assert!(((w2.0 - base.0) - 2).abs() <= 1, "vx 250 vs 350 → 2 pt right: {base:?} {w2:?}"); + // The range form gives the same; /DW2 with vy 780 draws the glyphs 2 pt higher. + let range = doc(&format!("/W2 [{h} {h} -2000 250 880]")); + assert_eq!(range, w2); + let dw2 = doc("/DW2 [780 -1000]"); + assert!(((base.1 - dw2.1) - 2).abs() <= 1, "vy 780 → 2 pt higher: {base:?} {dw2:?}"); +} From d0df520bc15e4526b057cc6238b5b68ca0992ccc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:54:05 -0400 Subject: [PATCH 04/10] M13.12: pdf CCITT G3/G4 images, parser robustness tests and panic fixes Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/ccitt.rs | 798 +++++++++++++++++++++++++++++++++ crates/pdf/src/image.rs | 2 +- crates/pdf/src/lib.rs | 3 + crates/pdf/src/object.rs | 25 +- crates/pdf/src/ps.rs | 22 +- crates/pdf/src/tests_import.rs | 214 +++++++++ 6 files changed, 1059 insertions(+), 5 deletions(-) create mode 100644 crates/pdf/src/ccitt.rs create mode 100644 crates/pdf/src/tests_import.rs diff --git a/crates/pdf/src/ccitt.rs b/crates/pdf/src/ccitt.rs new file mode 100644 index 00000000..ebd4cad7 --- /dev/null +++ b/crates/pdf/src/ccitt.rs @@ -0,0 +1,798 @@ +//! `CCITTFaxDecode` (ISO 32000-1 §7.4.6): ITU-T T.4 Group 3 one-dimensional (Modified +//! Huffman) and two-dimensional (Modified READ) coding and ITU-T T.6 Group 4 (Modified Modified +//! READ), implemented from the ITU-T recommendations. The result is 1-bit rows, `0` black +//! unless `BlackIs1`. + +use std::collections::HashMap; +use std::sync::OnceLock; + +/// The decoding parameters (`/DecodeParms`). +#[derive(Clone, Debug, PartialEq)] +pub struct Params { + /// `< 0` Group 4, `0` Group 3 1-D, `> 0` Group 3 mixed 1-D / 2-D. + pub k: i64, + pub end_of_line: bool, + pub byte_align: bool, + pub columns: usize, + /// `0`: as many rows as the data holds. + pub rows: usize, + pub end_of_block: bool, + pub black_is_1: bool, +} + +impl Default for Params { + fn default() -> Self { + Params { k: 0, end_of_line: false, byte_align: false, columns: 1728, rows: 0, end_of_block: true, black_is_1: false } + } +} + +/// White terminating codes (T.4 Table 2), run lengths 0–63, as (code, length). +const WHITE_TERM: [(u16, u8); 64] = [ + (0b00110101, 8), + (0b000111, 6), + (0b0111, 4), + (0b1000, 4), + (0b1011, 4), + (0b1100, 4), + (0b1110, 4), + (0b1111, 4), + (0b10011, 5), + (0b10100, 5), + (0b00111, 5), + (0b01000, 5), + (0b001000, 6), + (0b000011, 6), + (0b110100, 6), + (0b110101, 6), + (0b101010, 6), + (0b101011, 6), + (0b0100111, 7), + (0b0001100, 7), + (0b0001000, 7), + (0b0010111, 7), + (0b0000011, 7), + (0b0000100, 7), + (0b0101000, 7), + (0b0101011, 7), + (0b0010011, 7), + (0b0100100, 7), + (0b0011000, 7), + (0b00000010, 8), + (0b00000011, 8), + (0b00011010, 8), + (0b00011011, 8), + (0b00010010, 8), + (0b00010011, 8), + (0b00010100, 8), + (0b00010101, 8), + (0b00010110, 8), + (0b00010111, 8), + (0b00101000, 8), + (0b00101001, 8), + (0b00101010, 8), + (0b00101011, 8), + (0b00101100, 8), + (0b00101101, 8), + (0b00000100, 8), + (0b00000101, 8), + (0b00001010, 8), + (0b00001011, 8), + (0b01010010, 8), + (0b01010011, 8), + (0b01010100, 8), + (0b01010101, 8), + (0b00100100, 8), + (0b00100101, 8), + (0b01011000, 8), + (0b01011001, 8), + (0b01011010, 8), + (0b01011011, 8), + (0b01001010, 8), + (0b01001011, 8), + (0b00110010, 8), + (0b00110011, 8), + (0b00110100, 8), +]; + +/// White make-up codes (T.4 Table 3), run lengths 64, 128, … 1728. +const WHITE_MAKEUP: [(u16, u8); 27] = [ + (0b11011, 5), + (0b10010, 5), + (0b010111, 6), + (0b0110111, 7), + (0b00110110, 8), + (0b00110111, 8), + (0b01100100, 8), + (0b01100101, 8), + (0b01101000, 8), + (0b01100111, 8), + (0b011001100, 9), + (0b011001101, 9), + (0b011010010, 9), + (0b011010011, 9), + (0b011010100, 9), + (0b011010101, 9), + (0b011010110, 9), + (0b011010111, 9), + (0b011011000, 9), + (0b011011001, 9), + (0b011011010, 9), + (0b011011011, 9), + (0b010011000, 9), + (0b010011001, 9), + (0b010011010, 9), + (0b011000, 6), + (0b010011011, 9), +]; + +/// Black terminating codes (T.4 Table 2), run lengths 0–63. +const BLACK_TERM: [(u16, u8); 64] = [ + (0b0000110111, 10), + (0b010, 3), + (0b11, 2), + (0b10, 2), + (0b011, 3), + (0b0011, 4), + (0b0010, 4), + (0b00011, 5), + (0b000101, 6), + (0b000100, 6), + (0b0000100, 7), + (0b0000101, 7), + (0b0000111, 7), + (0b00000100, 8), + (0b00000111, 8), + (0b000011000, 9), + (0b0000010111, 10), + (0b0000011000, 10), + (0b0000001000, 10), + (0b00001100111, 11), + (0b00001101000, 11), + (0b00001101100, 11), + (0b00000110111, 11), + (0b00000101000, 11), + (0b00000010111, 11), + (0b00000011000, 11), + (0b000011001010, 12), + (0b000011001011, 12), + (0b000011001100, 12), + (0b000011001101, 12), + (0b000001101000, 12), + (0b000001101001, 12), + (0b000001101010, 12), + (0b000001101011, 12), + (0b000011010010, 12), + (0b000011010011, 12), + (0b000011010100, 12), + (0b000011010101, 12), + (0b000011010110, 12), + (0b000011010111, 12), + (0b000001101100, 12), + (0b000001101101, 12), + (0b000011011010, 12), + (0b000011011011, 12), + (0b000001010100, 12), + (0b000001010101, 12), + (0b000001010110, 12), + (0b000001010111, 12), + (0b000001100100, 12), + (0b000001100101, 12), + (0b000001010010, 12), + (0b000001010011, 12), + (0b000000100100, 12), + (0b000000110111, 12), + (0b000000111000, 12), + (0b000000100111, 12), + (0b000000101000, 12), + (0b000001011000, 12), + (0b000001011001, 12), + (0b000000101011, 12), + (0b000000101100, 12), + (0b000001011010, 12), + (0b000001100110, 12), + (0b000001100111, 12), +]; + +/// Black make-up codes (T.4 Table 3), run lengths 64, 128, … 1728. +const BLACK_MAKEUP: [(u16, u8); 27] = [ + (0b0000001111, 10), + (0b000011001000, 12), + (0b000011001001, 12), + (0b000001011011, 12), + (0b000000110011, 12), + (0b000000110100, 12), + (0b000000110101, 12), + (0b0000001101100, 13), + (0b0000001101101, 13), + (0b0000001001010, 13), + (0b0000001001011, 13), + (0b0000001001100, 13), + (0b0000001001101, 13), + (0b0000001110010, 13), + (0b0000001110011, 13), + (0b0000001110100, 13), + (0b0000001110101, 13), + (0b0000001110110, 13), + (0b0000001110111, 13), + (0b0000001010010, 13), + (0b0000001010011, 13), + (0b0000001010100, 13), + (0b0000001010101, 13), + (0b0000001011010, 13), + (0b0000001011011, 13), + (0b0000001100100, 13), + (0b0000001100101, 13), +]; + +/// Extended make-up codes for both colours (T.4 Table 3a), run lengths 1792, 1856, … 2560. +const EXT_MAKEUP: [(u16, u8); 13] = [ + (0b00000001000, 11), + (0b00000001100, 11), + (0b00000001101, 11), + (0b000000010010, 12), + (0b000000010011, 12), + (0b000000010100, 12), + (0b000000010101, 12), + (0b000000010110, 12), + (0b000000010111, 12), + (0b000000011100, 12), + (0b000000011101, 12), + (0b000000011110, 12), + (0b000000011111, 12), +]; + +/// A colour's code table: (length, code) → run length. +type Table = HashMap<(u8, u16), u32>; + +fn table(term: &[(u16, u8); 64], makeup: &[(u16, u8); 27]) -> Table { + let mut t = HashMap::new(); + for (run, (code, len)) in term.iter().enumerate() { + t.insert((*len, *code), run as u32); + } + for (i, (code, len)) in makeup.iter().enumerate() { + t.insert((*len, *code), 64 * (i as u32 + 1)); + } + for (i, (code, len)) in EXT_MAKEUP.iter().enumerate() { + t.insert((*len, *code), 1792 + 64 * i as u32); + } + t +} + +fn tables() -> &'static (Table, Table) { + static T: OnceLock<(Table, Table)> = OnceLock::new(); + T.get_or_init(|| (table(&WHITE_TERM, &WHITE_MAKEUP), table(&BLACK_TERM, &BLACK_MAKEUP))) +} + +/// The code (code, length) for a run of `run` (≥ 0, any length) as make-up codes then a +/// terminating code (used by the test encoder). +#[cfg(test)] +pub(crate) fn run_codes(white: bool, mut run: usize) -> Vec<(u16, u8)> { + let (term, makeup) = if white { (&WHITE_TERM, &WHITE_MAKEUP) } else { (&BLACK_TERM, &BLACK_MAKEUP) }; + let mut out = vec![]; + while run >= 2560 { + out.push(EXT_MAKEUP[12]); + run -= 2560; + } + if run >= 1792 { + out.push(EXT_MAKEUP[(run - 1792) / 64]); + run -= 1792 + (run - 1792) / 64 * 64; + } else if run >= 64 { + out.push(makeup[run / 64 - 1]); + run %= 64; + } + out.push(term[run]); + out +} + +struct Reader<'a> { + data: &'a [u8], + bit: usize, +} + +impl Reader<'_> { + fn bit_at(&self, i: usize) -> u16 { + self.data.get(i / 8).map(|b| ((b >> (7 - i % 8)) & 1) as u16).unwrap_or(0) + } + + fn peek(&self, n: usize) -> u16 { + (0..n).fold(0, |v, k| (v << 1) | self.bit_at(self.bit + k)) + } + + fn eof(&self) -> bool { + self.bit >= self.data.len() * 8 + } + + fn take(&mut self, n: usize) -> u16 { + let v = self.peek(n); + self.bit += n; + v + } + + fn align(&mut self) { + self.bit = self.bit.div_ceil(8) * 8; + } + + /// An EOL (eleven or more 0 bits then 1) at the read position: consume it. + fn eol(&mut self) -> bool { + let mut zeros = 0; + while zeros < 64 && self.bit + zeros < self.data.len() * 8 && self.bit_at(self.bit + zeros) == 0 { + zeros += 1; + } + if zeros >= 11 && self.bit + zeros < self.data.len() * 8 { + self.bit += zeros + 1; + return true; + } + false + } + + /// One run (make-up codes then a terminating code), `None` on an invalid code. + fn run(&mut self, t: &Table) -> Option { + let mut total = 0u32; + loop { + if self.eof() { + return None; + } + let mut found = None; + for len in 2..=13u8 { + if let Some(r) = t.get(&(len, self.peek(len as usize))) { + found = Some((len, *r)); + break; + } + } + let (len, r) = found?; + self.bit += len as usize; + total = total.saturating_add(r); + if r < 64 { + return Some(total); + } + } + } +} + +/// 2-D mode codes (T.4 Table 4). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mode { + Pass, + Horizontal, + Vertical(i64), + Eol, +} + +fn mode(r: &mut Reader) -> Option { + if r.eof() { + return None; + } + let m = if r.peek(1) == 1 { + r.bit += 1; + Mode::Vertical(0) + } else if r.peek(3) == 0b011 { + r.bit += 3; + Mode::Vertical(1) + } else if r.peek(3) == 0b010 { + r.bit += 3; + Mode::Vertical(-1) + } else if r.peek(3) == 0b001 { + r.bit += 3; + Mode::Horizontal + } else if r.peek(4) == 0b0001 { + r.bit += 4; + Mode::Pass + } else if r.peek(6) == 0b000011 { + r.bit += 6; + Mode::Vertical(2) + } else if r.peek(6) == 0b000010 { + r.bit += 6; + Mode::Vertical(-2) + } else if r.peek(7) == 0b0000011 { + r.bit += 7; + Mode::Vertical(3) + } else if r.peek(7) == 0b0000010 { + r.bit += 7; + Mode::Vertical(-3) + } else if r.eol() { + Mode::Eol + } else { + return None; + }; + Some(m) +} + +/// The first changing element of `refl` after `a0` whose colour is the opposite of `white` +/// (index parity: even indices start black runs), and the one after it. +fn b1b2(refl: &[usize], a0: i64, white: bool, columns: usize) -> (usize, usize) { + let want = if white { 0 } else { 1 }; + let mut i = 0; + while i < refl.len() && (refl[i] as i64 <= a0 || i % 2 != want) { + i += 1; + } + let b1 = refl.get(i).copied().unwrap_or(columns); + let b2 = refl.get(i + 1).copied().unwrap_or(columns); + (b1, b2) +} + +/// Decode a 1-D (Modified Huffman) row into changing elements. +fn row_1d(r: &mut Reader, columns: usize) -> Option> { + let (wt, bt) = tables(); + let mut changes = vec![]; + let mut a0 = 0usize; + let mut white = true; + while a0 < columns { + let run = r.run(if white { wt } else { bt })? as usize; + a0 = (a0 + run).min(columns); + changes.push(a0); + white = !white; + } + Some(changes) +} + +/// Decode a 2-D row against the reference row's changing elements. `Err(true)` at an EOL. +fn row_2d(r: &mut Reader, refl: &[usize], columns: usize) -> Result, bool> { + let (wt, bt) = tables(); + let mut changes: Vec = vec![]; + let mut a0: i64 = -1; + let mut white = true; + let mut guard = 0; + while a0 < columns as i64 { + guard += 1; + if guard > 4 * columns + 16 { + return Err(false); + } + let (b1, b2) = b1b2(refl, a0, white, columns); + match mode(r).ok_or(false)? { + Mode::Pass => { + // The run continues under b2 (no change in the coding line). + a0 = b2 as i64; + } + Mode::Horizontal => { + let start = a0.max(0) as usize; + let r1 = r.run(if white { wt } else { bt }).ok_or(false)? as usize; + let r2 = r.run(if white { bt } else { wt }).ok_or(false)? as usize; + let a1 = (start + r1).min(columns); + let a2 = (a1 + r2).min(columns); + changes.push(a1); + changes.push(a2); + a0 = a2 as i64; + } + Mode::Vertical(d) => { + let a1 = (b1 as i64 + d).clamp(0, columns as i64); + if a1 < a0 { + return Err(false); + } + changes.push(a1 as usize); + a0 = a1; + white = !white; + } + Mode::Eol => return Err(true), + } + } + Ok(changes) +} + +/// Changing elements → packed bits (`black` bit value for black pixels). +fn pack(changes: &[usize], columns: usize, black_is_1: bool, out: &mut Vec) { + let row_bytes = columns.div_ceil(8); + let base = out.len(); + out.resize(base + row_bytes, if black_is_1 { 0 } else { 0xFF }); + let mut x = 0usize; + let mut white = true; + for &c in changes.iter().chain(std::iter::once(&columns)) { + let c = c.min(columns); + if !white { + for p in x..c { + let byte = &mut out[base + p / 8]; + let m = 0x80u8 >> (p % 8); + if black_is_1 { *byte |= m } else { *byte &= !m } + } + } + x = x.max(c); + white = !white; + if x >= columns { + break; + } + } +} + +/// Decode CCITT fax data. Damaged data ends the image at the last good row (rows already +/// decoded are kept); `None` only when no row decodes. +pub fn decode(data: &[u8], p: &Params) -> Option> { + let columns = p.columns.clamp(1, 1 << 16); + let max_rows = if p.rows > 0 { p.rows.min(1 << 20) } else { 1 << 20 }; + let mut r = Reader { data, bit: 0 }; + let mut out = vec![]; + let mut refl: Vec = vec![]; + let mut rows = 0; + while rows < max_rows && !r.eof() { + let line: Option> = if p.k < 0 { + if p.byte_align { + r.align(); + } + // An error is EOFB (two EOLs) or damage. + row_2d(&mut r, &refl, columns).ok() + } else { + // Group 3: optional fill and EOL before each row, then a tag bit when K > 0. + let had_eol = r.eol(); + if !had_eol && p.byte_align { + r.align(); + r.eol(); + } + // RTC: six consecutive EOLs (the first already consumed). + if had_eol && p.end_of_block { + let save = r.bit; + if r.eol() { + break; + } + r.bit = save; + } + if r.eof() { + break; + } + let two_d = p.k > 0 && r.take(1) == 0; + if two_d { row_2d(&mut r, &refl, columns).ok() } else { row_1d(&mut r, columns) } + }; + let Some(line) = line else { break }; + pack(&line, columns, p.black_is_1, &mut out); + refl = line; + rows += 1; + } + // Rows missing from damaged or short data stay white. + if p.rows > 0 && rows > 0 && rows < p.rows { + let row_bytes = columns.div_ceil(8); + out.resize(p.rows.min(1 << 20) * row_bytes, if p.black_is_1 { 0 } else { 0xFF }); + } + (rows > 0).then_some(out) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// A bit writer for the test encoder. + #[derive(Default)] + pub(crate) struct W { + pub bytes: Vec, + n: usize, + } + + impl W { + pub fn put(&mut self, code: u16, len: u8) { + for k in (0..len).rev() { + if self.n.is_multiple_of(8) { + self.bytes.push(0); + } + if (code >> k) & 1 == 1 { + *self.bytes.last_mut().unwrap() |= 0x80 >> (self.n % 8); + } + self.n += 1; + } + } + fn run(&mut self, white: bool, run: usize) { + for (c, l) in run_codes(white, run) { + self.put(c, l); + } + } + } + + /// Changing elements of a row of pixels (`true` = black). + fn changes(row: &[bool]) -> Vec { + let mut out = vec![]; + let mut cur = false; + for (i, &b) in row.iter().enumerate() { + if b != cur { + out.push(i); + cur = b; + } + } + out + } + + fn encode_1d(w: &mut W, row: &[bool]) { + let cols = row.len(); + let mut ch = changes(row); + ch.push(cols); + let mut a0 = 0; + let mut white = true; + for c in ch { + w.run(white, c - a0); + a0 = c; + white = !white; + if a0 >= cols { + break; + } + } + } + + fn encode_2d(w: &mut W, row: &[bool], refl: &[usize]) { + let cols = row.len(); + let cur = changes(row); + let mut a0: i64 = -1; + let mut white = true; + let next = |a0: i64| cur.iter().copied().find(|&c| c as i64 > a0).unwrap_or(cols); + while a0 < cols as i64 { + let a1 = next(a0); + let (b1, b2) = b1b2(refl, a0, white, cols); + if b2 < a1 { + w.put(0b0001, 4); + a0 = b2 as i64; + } else if (a1 as i64 - b1 as i64).abs() <= 3 { + let d = a1 as i64 - b1 as i64; + let (c, l) = match d { + 0 => (1, 1), + 1 => (0b011, 3), + -1 => (0b010, 3), + 2 => (0b000011, 6), + -2 => (0b000010, 6), + 3 => (0b0000011, 7), + _ => (0b0000010, 7), + }; + w.put(c, l); + a0 = a1 as i64; + white = !white; + } else { + let a2 = next(a1 as i64); + w.put(0b001, 3); + let start = a0.max(0) as usize; + w.run(white, a1 - start); + w.run(!white, a2 - a1); + a0 = a2 as i64; + } + } + } + + pub(crate) fn encode(img: &[Vec], k: i64) -> Vec { + let mut w = W::default(); + let mut refl: Vec = vec![]; + for (i, row) in img.iter().enumerate() { + match k { + k if k < 0 => encode_2d(&mut w, row, &refl), + 0 => { + w.put(1, 12); + encode_1d(&mut w, row); + } + _ => { + w.put(1, 12); + if i as i64 % k == 0 { + w.put(1, 1); + encode_1d(&mut w, row); + } else { + w.put(0, 1); + encode_2d(&mut w, row, &refl); + } + } + } + refl = changes(row); + } + if k < 0 { + w.put(1, 12); + w.put(1, 12); + } + w.bytes + } + + pub(crate) fn pattern(cols: usize, rows: usize, seed: u32) -> Vec> { + let mut s = seed; + let mut rnd = || { + s = s.wrapping_mul(1_103_515_245).wrapping_add(12345); + (s >> 16) & 0x7FFF + }; + let mut img = vec![]; + let mut prev = vec![false; cols]; + for y in 0..rows { + let mut row = prev.clone(); + // Mostly like the row above (2-D coding's case), with random edits and runs. + for _ in 0..(rnd() % 5) { + let a = rnd() as usize % cols; + let len = 1 + rnd() as usize % (cols / 3).max(1); + let v = rnd() % 2 == 0; + for p in row.iter_mut().skip(a).take(len) { + *p = v; + } + } + if y % 7 == 3 { + row.iter_mut().for_each(|p| *p = rnd() % 3 == 0); + } + img.push(row.clone()); + prev = row; + } + img + } + + fn unpack(data: &[u8], cols: usize, rows: usize) -> Vec> { + let rb = cols.div_ceil(8); + (0..rows).map(|y| (0..cols).map(|x| data[y * rb + x / 8] & (0x80 >> (x % 8)) == 0).collect()).collect() + } + + #[test] + fn code_tables_are_prefix_free() { + for (white, term, makeup) in [(true, &WHITE_TERM, &WHITE_MAKEUP), (false, &BLACK_TERM, &BLACK_MAKEUP)] { + let mut all: Vec<(u16, u8)> = term.iter().chain(makeup.iter()).chain(EXT_MAKEUP.iter()).copied().collect(); + all.push((1, 12)); // EOL + let mut kraft = 0.0; + for (i, a) in all.iter().enumerate() { + kraft += 0.5f64.powi(a.1 as i32); + for (j, b) in all.iter().enumerate() { + if i != j && a.1 <= b.1 { + assert_ne!(b.0 >> (b.1 - a.1), a.0, "white {white}: {a:?} is a prefix of {b:?}"); + } + } + } + assert!(kraft <= 1.0, "white {white}: Kraft sum {kraft}"); + } + } + + #[test] + fn round_trips_group_3_and_group_4() { + for (cols, rows, seed) in [(1, 3, 1), (8, 8, 2), (37, 29, 3), (300, 40, 4), (2600, 6, 5)] { + let img = pattern(cols, rows, seed); + for k in [-1, 0, 1, 3] { + let data = encode(&img, k); + let p = Params { k, columns: cols, rows, ..Params::default() }; + let out = decode(&data, &p).unwrap_or_else(|| panic!("{cols}×{rows} K {k}: no rows")); + assert_eq!(unpack(&out, cols, rows), img, "{cols}×{rows} K {k}"); + // Rows unknown: stop at the end of the data / EOFB. + let out = decode(&data, &Params { rows: 0, ..p.clone() }).unwrap(); + assert_eq!(unpack(&out, cols, rows), img, "{cols}×{rows} K {k}, no /Rows"); + // BlackIs1 inverts. + let inv = decode(&data, &Params { black_is_1: true, ..p }).unwrap(); + assert!(inv.iter().zip(&decode(&data, &Params { k, columns: cols, rows, ..Params::default() }).unwrap()).all(|(a, b)| a ^ b == 0xFF)); + } + } + } + + fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() + } + + /// Data from an independent encoder (libtiff's `tiffcp -c g4 / g3:1d / g3:2d / g3:1d:fill`, + /// run once as an external fixture generator) for a 61×23 generated image (1 = black). + #[test] + fn decodes_an_independent_encoders_output() { + let raw = hex( + "0000000000000008380000000000000042d05803696223a87fc00003697fffe87f00007ffd7fffe87f00007ffd7fffe87f00007fffffffe87f00007ffffffff85cc00128608c10505cc001f860007ff0fffffffffffffff8fffffffffffdfff8fffffffffffffff8f8040003fffffff84140e4180f5002d04140e4180f5002d05ffdfc180f5000005ffdfc180f5000005ffdfc180f5000005ffdf000000ff800270ca82855a00d78ffecf82855a00c00ffecf82855a00c00", + ); + let cases = [ + ( + -1, + "52f3408da23e4747111f34c8f91d17447cc2308c4082588c447fe2223e11ac13ffff88fe3943845f08a72872acf8541f0ac0412ff11e22108e844445c82b0f8e4e6555608e223a12f17408fe58e50e51ca1c104bffffffc8e150ffc447ffffffffc21116449b29cd76088e88e8e223a3688e88e88f8455191d11d8420f88ffffc4e06ffffffc004004", + ), + ( + 0, + "0014b4002f2c8008ead0f8eb10fd31f1d3a1f850a21d0e8004710463e3a7438103a0011c75820e870207400238eb041d0e040e800471d61a43a0011c7586a0008e879f0d3a1d5f8a3c5887438008e879f0cafd028e0026a0b40026a0ac3838009a82d0009a9cd540d20023ac43ae9d67ec743a3a1f1d0e0023ac43ae9d67ec743a3a1f1d0e0023a1c28e3cfd8e87558008e870a38f3f63a1d560023a1c28e3cfd8e87558008e870a39a8126002e9ebdd0e875887568743a1f1d531d0ec004d428fb9e21d5a1d0e87c754fc004d428fb9e21d5a1d0e87c754fc", + ), + ( + 4, + "001a5a001179800c75687c75887e98f8e9d0fc28510e87400288c447fe2223c00638eb041d0e040e8005ff800c71d61a43a00171800c743cf869d0eafc51e2c43a1c005fc47888423800cd416800520ac3c0066a0b400293995540063ac43ae9d67ec743a3a1f1d0e002ffffff800c743851c79fb1d0eab0017fff0018e870a38f3f63a1d56002f84222c893001ba7af743a1d621d5a1d0e87c754c743b0010420f88ffffc4e06e003350a3ee7887568743a1f1d53f0", + ), + ( + 0, + "00014b4001796400011d5a1f1d621fa63e3a743f0a1443a1d000011c4118f8e9d0e040e800011c75820e87020740011c75820e87020740011c758690e800011c7586a000011d0f3e1a743abf1478b10e8700011d0f3e195fa051c0013505a001350561c1c0013505a0013539aa81a400011d621d74eb3f63a1d1d0f8e870011d621d74eb3f63a1d1d0f8e870011d0e1471e7ec743aac00011d0e1471e7ec743aac00011d0e1471e7ec743aac00011d0e14735024c00174f5ee8743ac43ab43a1d0f8ea98e8760001350a3ee7887568743a1f1d53f001350a3ee7887568743a1f1d53f0", + ), + ]; + for (i, (k, data)) in cases.iter().enumerate() { + let p = Params { k: *k, columns: 61, rows: 23, black_is_1: true, end_of_line: *k >= 0, ..Params::default() }; + let out = decode(&hex(data), &p).unwrap(); + assert_eq!(out, raw, "case {i} (K {k})"); + let out = decode(&hex(data), &Params { rows: 0, ..p }).unwrap(); + assert_eq!(out, raw, "case {i} (K {k}), no /Rows"); + } + } + + #[test] + fn damaged_data_keeps_the_good_rows() { + let img = pattern(64, 20, 9); + let data = encode(&img, -1); + for cut in [0, 1, data.len() / 3, data.len() - 3] { + let out = decode(&data[..cut], &Params { k: -1, columns: 64, rows: 20, ..Params::default() }); + if let Some(out) = out { + assert_eq!(out.len(), 20 * 8); + } + } + let mut garbage = data.clone(); + for (i, b) in garbage.iter_mut().enumerate() { + *b ^= (i * 37) as u8; + } + for k in [-1, 0, 2] { + let _ = decode(&garbage, &Params { k, columns: 64, rows: 20, ..Params::default() }); + let _ = decode(&garbage, &Params { k, columns: 1, rows: 0, ..Params::default() }); + } + } +} diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs index 0f0a481d..5e6c49a5 100644 --- a/crates/pdf/src/image.rs +++ b/crates/pdf/src/image.rs @@ -100,7 +100,7 @@ fn samples(file: &File, d: &Dict, raw: &[u8]) -> Result<(Vec, Option) let jpeg = decode_stream(file, &d2, raw).ok_or("image (filters)")?; jpeg_decode(&jpeg).map(|(b, n)| (b, Some(n))) } - Some(f @ ("JPXDecode" | "CCITTFaxDecode" | "JBIG2Decode" | "CCF")) => Err(format!("image ({f})")), + Some(f @ ("JPXDecode" | "JBIG2Decode")) => Err(format!("image ({f})")), _ => decode_stream(file, d, raw).map(|b| (b, None)).ok_or_else(|| "image (filters)".to_string()), } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 985a7083..3db854fc 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -8,6 +8,7 @@ //! (Illustrator layers), with content outside them gathered into groups of its own. mod build; +mod ccitt; mod cff; mod color; mod encoding; @@ -196,4 +197,6 @@ mod tests; #[cfg(test)] mod tests_groups; #[cfg(test)] +mod tests_import; +#[cfg(test)] mod tests_shading; diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs index e24f6ec8..ffd9f9bc 100644 --- a/crates/pdf/src/object.rs +++ b/crates/pdf/src/object.rs @@ -328,6 +328,7 @@ pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option> { lzw(&data, parms.get(i).and_then(|p| p.as_ref()).and_then(|p| p.get("EarlyChange")).and_then(Obj::num).unwrap_or(1.0) != 0.0) } "RunLengthDecode" | "RL" => run_length(&data), + "CCITTFaxDecode" | "CCF" => crate::ccitt::decode(&data, &ccitt_params(file, parms.get(i).and_then(|p| p.as_ref())))?, _ => return None, }; if let Some(Some(p)) = parms.get(i) @@ -339,6 +340,24 @@ pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option> { Some(data) } +/// `/DecodeParms` of a `CCITTFaxDecode` filter. +fn ccitt_params(file: &File, d: Option<&Dict>) -> crate::ccitt::Params { + let mut p = crate::ccitt::Params::default(); + let Some(d) = d else { return p }; + let flag = |k: &str, default: bool| match file.get(d, k) { + Some(Obj::Bool(b)) => *b, + _ => default, + }; + p.k = file.get_num(d, "K").unwrap_or(0.0) as i64; + p.columns = file.get_num(d, "Columns").map(|c| c.max(1.0) as usize).unwrap_or(1728); + p.rows = file.get_num(d, "Rows").map(|r| r.max(0.0) as usize).unwrap_or(0); + p.end_of_line = flag("EndOfLine", false); + p.byte_align = flag("EncodedByteAlign", false); + p.end_of_block = flag("EndOfBlock", true); + p.black_is_1 = flag("BlackIs1", false); + p +} + pub fn inflate(data: &[u8]) -> Option> { miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| { // Truncated streams: keep what inflates. @@ -569,7 +588,7 @@ impl File { Obj::Dict(d) => { let save = lx.pos; lx.skip_ws(); - if data[lx.pos..].starts_with(b"stream") { + if data.get(lx.pos..).is_some_and(|d| d.starts_with(b"stream")) { let mut s = lx.pos + 6; if data.get(s) == Some(&b'\r') { s += 1; @@ -578,8 +597,9 @@ impl File { s += 1; } let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize); + let s = s.min(data.len()); let end = match declared.filter(|l| { - s + l <= data.len() && { + s.checked_add(*l).is_some_and(|e| e <= data.len()) && { let mut k = s + l; while k < data.len() && is_white(data[k]) { k += 1; @@ -599,6 +619,7 @@ impl File { e } }; + let end = end.max(s); i = end; Obj::Stream(d, data[s..end].to_vec()) } else { diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index b9b0bff4..1a980f6f 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -309,6 +309,8 @@ pub(crate) struct Interp { gs: GState, saved: Vec, budget: usize, + /// Nested procedure calls (recursion is cut off at [`MAX_CALL_DEPTH`]). + calls: usize, page: [f64; 4], pub unknown: Vec, } @@ -330,6 +332,9 @@ fn matrix_val(m: Affine) -> V { array(m.as_coeffs().iter().map(|c| V::Num(*c)).collect()) } +/// The deepest procedure nesting run (deeper recursion stops the program). +const MAX_CALL_DEPTH: usize = 256; + impl Interp { pub fn new(page: [f64; 4]) -> Interp { Interp { @@ -351,6 +356,7 @@ impl Interp { }, saved: vec![], budget: 8_000_000, + calls: 0, page, unknown: vec![], } @@ -396,13 +402,25 @@ impl Interp { fn exec_obj(&mut self, v: V, src: &mut Src) -> Result<(), Flow> { match v { V::Proc(p) => { + if self.calls >= MAX_CALL_DEPTH { + self.b.skip("PostScript (recursion limit)"); + return Err(Flow::Quit); + } + self.calls += 1; + let mut r = Ok(()); for x in p.iter() { match x { - V::Name(n) => self.exec_name(n, src)?, + V::Name(n) => { + r = self.exec_name(n, src); + if r.is_err() { + break; + } + } other => self.push(other.clone()), } } - Ok(()) + self.calls -= 1; + r } V::Name(n) => self.exec_name(&n, src), other => { diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs new file mode 100644 index 00000000..3709d4ff --- /dev/null +++ b/crates/pdf/src/tests_import.rs @@ -0,0 +1,214 @@ +//! M13.12: CCITT images in PDF pages, EPS text and parser robustness over truncated and +//! corrupt inputs (generated fixtures). + +use super::*; +use crate::tests::{page_pdf, render}; + +/// An image XObject of `img` (black = true) CCITT-encoded with `k`, drawn over the page. +fn ccitt_page(img: &[Vec], k: i64, mask: bool) -> Doc { + let (w, h) = (img[0].len(), img.len()); + let data = crate::ccitt::tests::encode(img, k); + let kind = if mask { "/ImageMask true" } else { "/ColorSpace /DeviceGray /BitsPerComponent 1" }; + let bytes = page_pdf( + "0 0 1 rg q 200 0 0 100 0 0 cm /Im Do Q", + "/XObject << /Im 10 0 R >>", + vec![( + 10, + format!( + "<< /Type /XObject /Subtype /Image /Width {w} /Height {h} {kind} /Filter /CCITTFaxDecode /DecodeParms << /K {k} /Columns {w} /Rows {h} /EndOfLine {} >> >>", + k >= 0 + ), + Some(data), + )], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + doc +} + +#[test] +fn ccitt_images_and_stencil_masks() { + // Left half black, a white bar on rows 4..6 (20×10 pixels → 10 PDF units per pixel). + let img: Vec> = (0..10).map(|y| (0..20).map(|x| x < 10 && !(4..6).contains(&y)).collect()).collect(); + for k in [-1, 0, 2] { + let doc = ccitt_page(&img, k, false); + let r = render(&doc); + let px = |x: usize, y: usize| r.get(x as i64 * 10 + 5, y as i64 * 10 + 5); + assert!(px(2, 2)[0] < 0.01 && px(2, 2)[3] > 0.99, "K {k}: black {:?}", px(2, 2)); + assert!(px(15, 2)[0] > 0.99, "K {k}: white {:?}", px(15, 2)); + assert!(px(2, 4)[0] > 0.99, "K {k}: the white bar {:?}", px(2, 4)); + // Stencil mask: black (sample 0) pixels paint the fill colour, the rest is clear. + let doc = ccitt_page(&img, k, true); + let r = render(&doc); + let px = |x: usize, y: usize| r.get(x as i64 * 10 + 5, y as i64 * 10 + 5); + assert!(px(2, 2)[2] > 0.99 && px(2, 2)[3] > 0.99, "K {k}: mask {:?}", px(2, 2)); + assert_eq!(px(15, 2)[3], 0.0, "K {k}"); + } +} + +/// Byte-level mutations of `bytes`: truncations, flipped bytes, deleted and duplicated +/// spans (deterministic). +fn mutations(bytes: &[u8]) -> Vec> { + let mut out = vec![]; + let n = bytes.len(); + for k in 1..24 { + out.push(bytes[..n * k / 24].to_vec()); + } + let mut s = 0x9E37_79B9u32; + let mut rnd = move || { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + s + }; + for _ in 0..60 { + let mut b = bytes.to_vec(); + for _ in 0..1 + rnd() % 6 { + let i = rnd() as usize % n; + b[i] = match rnd() % 4 { + 0 => b[i] ^ 0xFF, + 1 => b'0' + (rnd() % 10) as u8, + 2 => b"[]<>()/{}% \n"[(rnd() % 12) as usize], + _ => rnd() as u8, + }; + } + out.push(b); + } + for _ in 0..30 { + let mut b = bytes.to_vec(); + let i = rnd() as usize % n; + let len = (rnd() as usize % 64).min(n - i); + if rnd() % 2 == 0 { + b.drain(i..i + len); + } else { + let span = b[i..i + len].to_vec(); + b.splice(i..i, span); + } + out.push(b); + } + out +} + +/// Parse and render every page of every mutation: errors are fine, panics are not. +fn survive(name: &str, bytes: &[u8]) { + for (i, m) in mutations(bytes).into_iter().enumerate() { + let r = std::panic::catch_unwind(|| { + let pages = page_count(&m); + for p in 0..pages.clamp(1, 3) { + if let Ok(doc) = parse_page(&m, p) { + let (w, h) = doc.pixel_size(); + let s = 64.0 / (w.max(h).max(1) as f64); + let _ = effectcraft_svg::rasterize(&doc, 64, 64, s); + let _ = layer_names(&doc); + } + } + }); + assert!(r.is_ok(), "{name}: mutation {i} panicked"); + } +} + +#[test] +fn truncated_and_corrupt_pdfs_never_panic() { + survive("sample", &crate::tests::sample_pdf(false)); + survive("object streams", &crate::tests::sample_pdf(true)); + let mut fonts = crate::tests::square_font_objs(); + fonts.push((13, "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(vec![255; 12]))); + survive( + "text and images", + &page_pdf("BT /F1 40 Tf 10 10 Td (AAA) Tj ET q 50 0 0 50 0 0 cm /Im Do Q", "/Font << /F1 10 0 R >> /XObject << /Im 13 0 R >>", fonts), + ); + let img: Vec> = (0..8).map(|y| (0..16).map(|x| (x + y) % 3 == 0).collect()).collect(); + let data = crate::ccitt::tests::encode(&img, -1); + survive( + "ccitt", + &page_pdf( + "q 100 0 0 50 0 0 cm /Im Do Q", + "/XObject << /Im 10 0 R >>", + vec![( + 10, + "<< /Type /XObject /Subtype /Image /Width 16 /Height 8 /ImageMask true /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 16 >> >>".into(), + Some(data), + )], + ), + ); +} + +#[test] +fn hostile_structures_return_errors_or_draw_nothing() { + // Not a PDF / EPS at all, empty, and headers alone. + for b in [&b""[..], b"%PDF-", b"%PDF-1.7\n%%EOF", b"%!PS-Adobe-3.0 EPSF-3.0\n", b"\xC5\xD0\xD3\xC6", b"hello"] { + let _ = parse(b); + let _ = page_count(b); + } + assert_eq!(parse(b"hello"), Err(Error::NotVector)); + assert_eq!(parse(b"%PDF-1.7\n%%EOF"), Err(Error::NoPages)); + // A page tree that refers to itself, a content stream that is a reference loop, deep + // form recursion and absurd numbers. + let objs: crate::tests::Objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [2 0 R 3 0 R] /Count 1 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 1e300 -1e300] /Contents 5 0 R /Resources << /XObject << /F 4 0 R >> >> >>".into(), None), + ( + 4, + "<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /F 4 0 R >> >> >>".into(), + Some(b"/F Do 1e308 1e308 m 1e308 0 l f".to_vec()), + ), + (5, "<< >>".into(), Some(b"/F Do q q q q Q Q Q Q Q Q 99999999999 w [1 0 0 1 0 0] cm".to_vec())), + ]; + let bytes = crate::write::pdf(&objs, 1); + if let Ok(doc) = parse(&bytes) { + let _ = effectcraft_svg::rasterize(&doc, 32, 32, 1e-300); + } + // Images with absurd sizes and filters. + let bytes = page_pdf( + "/A Do /B Do /C Do /D Do", + "/XObject << /A 10 0 R /B 11 0 R /C 12 0 R /D 13 0 R >>", + vec![ + (10, "<< /Subtype /Image /Width 100000 /Height 100000 /BitsPerComponent 8 /ColorSpace /DeviceRGB >>".into(), Some(vec![0; 4])), + (11, "<< /Subtype /Image /Width 4 /Height 4 /BitsPerComponent 7 /ColorSpace /DeviceRGB >>".into(), Some(vec![0; 4])), + ( + 12, + "<< /Subtype /Image /Width 4 /Height 4 /BitsPerComponent 1 /ImageMask true /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 0 >> >>" + .into(), + Some(vec![0xFF; 4]), + ), + (13, "<< /Subtype /Image /Width 4 /Height 4 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 300 <00>] >>".into(), Some(vec![9; 16])), + ], + ); + let doc = parse(&bytes).unwrap(); + let _ = render(&doc); + assert!(!doc.skipped.is_empty()); +} + +#[test] +fn truncated_and_corrupt_eps_never_panic() { + let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n\ +/sq { newpath 0 0 moveto 10 0 lineto 10 10 lineto closepath } bind def\n\ +1 0 0 setrgbcolor 0 1 9 { gsave dup 10 mul 0 translate sq fill grestore } for\n\ +/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hello) show\n\ +[1 2] 0 setdash 0 0 moveto 100 100 lineto stroke\n\ +1 1 1 { pop } repeat { exit } loop 0 0 50 0 360 arc closepath eofill\n%%EOF\n"; + survive("eps", eps); + // Stack underflow, unbalanced procedures, runaway loops and huge repeats. + for prog in [ + &b"pop pop pop add mul def"[..], + b"{ { { { {", + b"} } ] >> )", + b"{ } loop", + b"0 1 1e9 { pop } for", + b"1e9 { 1 } repeat", + b"/a { a } def a", + b"(unterminated", + b" Date: Sat, 3 Oct 2026 09:01:10 -0400 Subject: [PATCH 05/10] M13.12: EPS text (show variants, charpath, stringwidth, embedded Type 1/CFF, fallback fonts) Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/cff.rs | 8 + crates/pdf/src/font.rs | 46 +++- crates/pdf/src/ps.rs | 452 +++++++++++++++++++++++++++++---- crates/pdf/src/tests.rs | 3 +- crates/pdf/src/tests_import.rs | 85 ++++++- 5 files changed, 530 insertions(+), 64 deletions(-) diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index 7d647e7c..03493d81 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -366,6 +366,14 @@ pub struct Cff { pub encoding: [u16; 256], } +/// The first font name in a CFF program's Name INDEX. +pub fn font_name(data: &[u8]) -> Option { + let hdr = *data.get(2)? as usize; + let (names, _) = index(data, hdr)?; + let r = names.first()?.clone(); + Some(String::from_utf8_lossy(data.get(r)?).into_owned()) +} + impl Cff { pub fn parse(data: &[u8]) -> Option { let d = data; diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs index c2716fc8..e2ef1693 100644 --- a/crates/pdf/src/font.rs +++ b/crates/pdf/src/font.rs @@ -311,11 +311,9 @@ fn parse_cmap(data: &[u8]) -> CMap { impl Font { /// Read a font dictionary. `skipped` collects what could not be read. - pub fn load(file: &File, d: &Dict, skipped: &mut Vec) -> Font { - let subtype = file.get(d, "Subtype").and_then(Obj::name).unwrap_or("Type1").to_string(); - let base_name = file.get(d, "BaseFont").and_then(Obj::name).unwrap_or("").to_string(); - let mut f = Font { - composite: subtype == "Type0", + fn blank(base_name: &str, composite: bool) -> Font { + Font { + composite, program: Program::None, matrix: Affine::scale(0.001), first_char: 0, @@ -333,8 +331,44 @@ impl Font { dw2: (0.88, -1.0), cache: RefCell::new(HashMap::new()), has_widths: false, - base_name: base_name.clone(), + base_name: base_name.to_string(), + } + } + + /// A simple font outside a PDF (EPS text): `program` is an embedded font program (CFF when + /// the flag is set, else Type 1), otherwise the bundled stand-in for `name` is used; + /// `names` is the encoding (code → glyph name), else the program's built-in encoding or + /// StandardEncoding. Advances are the program's. + pub fn standalone(name: &str, program: Option<(&[u8], bool)>, names: Option>>) -> Font { + let mut f = Font::blank(name, false); + f.program = match program { + Some((data, true)) => Cff::parse(data).map(|c| Program::Cff(Box::new(c))), + Some((data, false)) => Type1::parse(data).map(|t| Program::Type1(Box::new(t))), + None => None, + } + .unwrap_or_else(|| Program::Fallback(fallback(name))); + match &f.program { + Program::Cff(c) => f.matrix = Affine::new(c.font_matrix), + Program::Type1(t) => f.matrix = t.font_matrix.map(Affine::new).unwrap_or(f.matrix), + _ => {} + } + let symbol = matches!(f.program, Program::Fallback(_)) && (name.contains("Symbol") || name.contains("Dingbats")); + f.names = match names { + // Unencoded codes draw .notdef (not the built-in encoding). + Some(n) => (0..256).map(|c| Some(n.get(c).cloned().flatten().unwrap_or_else(|| ".notdef".into()))).collect(), + None => match &f.program { + Program::Type1(t) if !t.encoding.is_empty() => (0..256).map(|c| t.encoding.get(&(c as u8)).cloned()).collect(), + _ if symbol => vec![None; 256], + _ => (0..256).map(|c| Base::Standard.name(c as u8)).collect(), + }, }; + f + } + + pub fn load(file: &File, d: &Dict, skipped: &mut Vec) -> Font { + let subtype = file.get(d, "Subtype").and_then(Obj::name).unwrap_or("Type1").to_string(); + let base_name = file.get(d, "BaseFont").and_then(Obj::name).unwrap_or("").to_string(); + let mut f = Font::blank(&base_name, subtype == "Type0"); if let Some(Obj::Stream(sd, raw)) = file.get(d, "ToUnicode") && let Some(data) = decode_stream(file, sd, raw) { diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 1a980f6f..153956ed 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -2,7 +2,9 @@ //! edition, and the EPSF 3.0 specification) covering what exported vector EPS files use: the //! operand and dictionary stacks, procedures and `def`, arithmetic, control flow, arrays and //! dictionaries, the graphics state, path construction (including arcs) and painting, clipping -//! and device colours. Text is skipped, as are images (their inline data is stepped over) and +//! and device colours, and text (`findfont` / `selectfont`, `show` and its variants, `charpath`, +//! `stringwidth`, re-encoded fonts) with embedded Type 1 and CFF programs or the bundled +//! stand-in fonts. Images are skipped (their inline data is stepped over), as is //! Level 3 smooth shading (`shfill`). use std::cell::RefCell; @@ -13,6 +15,7 @@ use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke}; use kurbo::{PathEl, Point}; use crate::build::Builder; +use crate::font::Font; use crate::object::{ascii85, is_white, parse_num}; type DictRef = Rc>>; @@ -79,6 +82,8 @@ struct GState { cur: Option, start: Option, depth: usize, + /// The current font dictionary. + font: Option, } /// Source tokenizer (the data source for `currentfile`). @@ -311,6 +316,12 @@ pub(crate) struct Interp { budget: usize, /// Nested procedure calls (recursion is cut off at [`MAX_CALL_DEPTH`]). calls: usize, + /// Fonts by name: `definefont`d dictionaries. + font_dir: HashMap, + /// Embedded font programs found in the file (name → (program, CFF)). + embedded: HashMap>, bool)>, + /// Fonts built for drawing, by base font and encoding. + font_cache: HashMap>, page: [f64; 4], pub unknown: Vec, } @@ -353,16 +364,21 @@ impl Interp { cur: None, start: None, depth: 1, + font: None, }, saved: vec![], budget: 8_000_000, calls: 0, + font_dir: HashMap::new(), + embedded: HashMap::new(), + font_cache: HashMap::new(), page, unknown: vec![], } } pub fn run(&mut self, data: &[u8]) { + self.embedded = embedded_fonts(data); let mut src = Src { d: data, pos: 0 }; while let Some(t) = src.token(0) { if let Err(Flow::Quit) = self.exec_token(t, &mut src) { @@ -924,16 +940,24 @@ impl Interp { "countdictstack" => self.push(V::Num(self.dicts.len() as f64)), "cleardictstack" => self.dicts.truncate(1), "findresource" | "findfont" | "findencoding" => { - if n == "findresource" { - self.pop(); + let category = if n == "findresource" { self.pop().key() } else { String::new() }; + let key = self.pop().key(); + match (n, category.as_str()) { + ("findfont", _) | (_, "Font") => { + let f = self.find_font(&key); + self.push(V::Dict(f)); + } + ("findencoding", _) | (_, "Encoding") => self.push(encoding_array(&key)), + _ => self.push(V::Dict(new_dict())), } - self.pop(); - self.push(V::Dict(new_dict())); } "defineresource" => { - self.pop(); + let category = self.pop().key(); let v = self.pop(); - self.pop(); + let key = self.pop().key(); + if let (V::Dict(d), "Font") = (&v, category.as_str()) { + self.font_dir.insert(key, d.clone()); + } self.push(v); } "resourcestatus" => { @@ -1355,55 +1379,10 @@ impl Interp { self.pop(); self.b.skip("smooth shading (shfill)"); } - // Text (skipped). - "scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "setcachedevice" | "setcharwidth" => { - match n { - "scalefont" | "makefont" => { - self.pop(); - } - "selectfont" => { - self.pop(); - self.pop(); - } - "charpath" => { - self.pop(); - self.pop(); - } - "setcachedevice" => { - for _ in 0..6 { - self.pop(); - } - } - "setcharwidth" => { - self.pop(); - self.pop(); - } - "rootfont" => self.push(V::Dict(new_dict())), - _ => { - self.pop(); - } - } - if matches!(n, "show" | "charpath" | "glyphshow") { - self.b.skip("text"); - } - } - "currentfont" => self.push(V::Dict(new_dict())), - "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" => { - let k = match n { - "ashow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" => 3, - "widthshow" => 4, - _ => 6, - }; - for _ in 0..k.min(self.st.len()) { - self.pop(); - } - self.b.skip("text"); - } - "stringwidth" => { - self.pop(); - self.push(V::Num(0.0)); - self.push(V::Num(0.0)); - } + // Text (see `text_op`). + "scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "currentfont" | "setcachedevice" + | "setcharwidth" | "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" | "stringwidth" | "definefont" + | "StandardEncoding" | "ISOLatin1Encoding" | "eexec" | "StartData" | "undefinefont" => self.text_op(n, src)?, // Images: operands popped, inline data skipped. "image" | "imagemask" | "colorimage" => { let mut reads_file = false; @@ -1478,3 +1457,364 @@ pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> { let bbox = find_box(b"%%HiResBoundingBox:").or_else(|| find_box(b"%%BoundingBox:"))?; Some((ps, bbox)) } + +// ------------------------------------------------------------------ text + +/// An encoding array of glyph names (`StandardEncoding`; `ISOLatin1Encoding` and others are +/// approximated by WinAnsi, which agrees with ISO Latin-1 on the printable codes). +fn encoding_array(name: &str) -> V { + let base = if name == "StandardEncoding" { crate::encoding::Base::Standard } else { crate::encoding::Base::WinAnsi }; + array((0..256).map(|c| V::Lit(base.name(c as u8).unwrap_or_else(|| ".notdef".into()).into())).collect()) +} + +fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option { + hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from) +} + +fn rfind_before(hay: &[u8], needle: &[u8], to: usize) -> Option { + hay.get(..to)?.windows(needle.len()).rposition(|w| w == needle) +} + +/// The name after `key` (`/FontName /Name`). +fn name_after(b: &[u8], key: &[u8]) -> Option { + let p = find_from(b, key, 0)? + key.len(); + let rest = &b[p..]; + let s = rest.iter().position(|c| *c == b'/')? + 1; + let e = rest[s..].iter().position(|c| is_white(*c) || matches!(c, b'/' | b'[' | b'(' | b'{')).map_or(rest.len(), |q| s + q); + (e > s).then(|| String::from_utf8_lossy(&rest[s..e]).into_owned()) +} + +/// Embedded font programs of an EPS file: Type 1 fonts (`… /FontName /X … currentfile eexec +/// … cleartomark`, hexadecimal or binary) and CFF font sets (`… StartData `). +fn embedded_fonts(ps: &[u8]) -> HashMap>, bool)> { + let mut out = HashMap::new(); + let mut from = 0; + while let Some(ee) = find_from(ps, b"eexec", from) { + from = ee + 5; + let start = [b"%!PS-AdobeFont".as_slice(), b"%!FontType1"] + .iter() + .filter_map(|h| rfind_before(ps, h, ee)) + .max() + .or_else(|| rfind_before(ps, b"/FontName", ee).map(|p| p.saturating_sub(512))); + let Some(start) = start else { continue }; + let end = find_from(ps, b"cleartomark", ee).map_or(ps.len(), |p| p + 11); + if let Some(name) = name_after(&ps[start..ee], b"/FontName") { + out.insert(name, (Rc::new(ps[start..end].to_vec()), false)); + } + from = end; + } + let mut from = 0; + while let Some(sd) = find_from(ps, b"StartData", from) { + from = sd + 9; + let head = String::from_utf8_lossy(&ps[sd.saturating_sub(64)..sd]).into_owned(); + let Some(len) = head.split_whitespace().last().and_then(|t| t.parse::().ok()) else { continue }; + let s = sd + 10; + let Some(data) = ps.get(s..s.saturating_add(len)) else { continue }; + if let Some(name) = crate::cff::font_name(data) { + out.insert(name, (Rc::new(data.to_vec()), true)); + } + from = s + len; + } + out +} + +fn dict_matrix(d: &DictRef, key: &str) -> Affine { + d.borrow().get(key).and_then(matrix_of).unwrap_or(Affine::IDENTITY) +} + +/// A copy of a font dictionary with `m` applied after its matrix (`scalefont`, `makefont`). +fn transformed(font: &DictRef, m: Affine) -> DictRef { + let copy: HashMap = font.borrow().clone(); + let d = Rc::new(RefCell::new(copy)); + let old = dict_matrix(&d, "__m"); + d.borrow_mut().insert("__m".into(), matrix_val(m * old)); + d +} + +impl Interp { + /// A font dictionary by name: one `definefont` made, else a new dictionary for an + /// embedded program or a bundled stand-in. + fn find_font(&mut self, key: &str) -> DictRef { + if let Some(d) = self.font_dir.get(key) { + return d.clone(); + } + let d = new_dict(); + { + let mut m = d.borrow_mut(); + m.insert("FontName".into(), V::Lit(key.into())); + m.insert("FontType".into(), V::Num(1.0)); + m.insert("FontMatrix".into(), matrix_val(Affine::scale(0.001))); + m.insert("FID".into(), V::Null); + m.insert("__base".into(), V::Lit(key.into())); + m.insert("__m".into(), matrix_val(Affine::IDENTITY)); + } + self.font_dir.insert(key.to_string(), d.clone()); + d + } + + /// The drawable font of a font dictionary (its base program and encoding) and the matrix + /// from text space (font size 1) to user space. + fn font_of(&mut self, d: &DictRef) -> Option<(Rc, Affine)> { + let (base, names) = { + let m = d.borrow(); + let base = m.get("__base").or_else(|| m.get("FontName")).map(V::key)?; + let names: Option>> = m.get("Encoding").and_then(V::items).map(|a| { + a.iter() + .map(|v| match v { + V::Lit(n) | V::Name(n) if &**n != ".notdef" => Some(n.to_string()), + _ => None, + }) + .collect() + }); + (base, names) + }; + let key = match &names { + Some(n) => format!("{base}\u{0}{}", n.iter().map(|x| x.as_deref().unwrap_or("")).collect::>().join(" ")), + None => base.clone(), + }; + let font = match self.font_cache.get(&key) { + Some(f) => f.clone(), + None => { + // Not embedded: a bundled stand-in, as for PDF. + let prog = self.embedded.get(&base).cloned(); + let f = Rc::new(Font::standalone(&base, prog.as_ref().map(|(d, cff)| (d.as_slice(), *cff)), names)); + self.font_cache.insert(key, f.clone()); + f + } + }; + Some((font, dict_matrix(d, "__m"))) + } + + /// A string's glyph outlines (device space) from the current point, with an extra + /// displacement `extra(index, code)` (user space) after each glyph; moves the current + /// point. + fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Option<(BezPath, String)> { + let d = self.gs.font.clone()?; + let (font, m) = self.font_of(&d)?; + let mut p = self.current_user(); + let mut path = BezPath::new(); + let mut text = String::new(); + let ctm = self.gs.ctm; + for (i, &code) in bytes.iter().enumerate() { + let code = code as u32; + if let Some(g) = font.glyph(code) { + path.extend(ctm * Affine::translate(p.to_vec2()) * m * (*g).clone()); + } + text.push_str(&font.unicode(code)); + let adv = m * Point::new(font.width(code), 0.0) - m * Point::ZERO; + let (ex, ey) = extra(i, code); + p += adv + kurbo::Vec2::new(ex, ey); + } + self.move_to(p); + Some((path, text)) + } + + fn fill_text(&mut self, path: BezPath, text: &str) { + let label: String = text.chars().filter(|c| !c.is_control()).take(40).collect(); + let name = if label.trim().is_empty() { "Text".to_string() } else { format!("Text: {}", label.trim()) }; + self.b.fill_stroke_named(&name, path, Affine::IDENTITY, Some((Paint::Color(self.gs.color), 1.0, FillRule::NonZero)), None); + } + + fn pop_string(&mut self) -> Vec { + match self.pop() { + V::Str(s) => s.borrow().clone(), + _ => vec![], + } + } + + fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) { + match self.text_path(s, extra) { + Some((path, text)) => self.fill_text(path, &text), + None => self.b.skip("text (no font)"), + } + } + + /// Text and font operators (PostScript Language Reference §5.1, §8.2). + fn text_op(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { + match n { + "scalefont" | "makefont" => { + let m = if n == "scalefont" { + Affine::scale(self.popn()) + } else { + let v = self.pop(); + matrix_of(&v).unwrap_or(Affine::IDENTITY) + }; + let font = match self.pop() { + V::Dict(d) => d, + _ => self.find_font("Helvetica"), + }; + self.push(V::Dict(transformed(&font, m))); + } + "selectfont" => { + let size = self.pop(); + let key = self.pop().key(); + let font = self.find_font(&key); + let m = match &size { + V::Num(s) => Affine::scale(*s), + other => matrix_of(other).unwrap_or(Affine::IDENTITY), + }; + self.gs.font = Some(transformed(&font, m)); + } + "setfont" => { + if let V::Dict(d) = self.pop() { + self.gs.font = Some(d); + } + } + "currentfont" | "rootfont" => { + let d = self.gs.font.clone().unwrap_or_else(new_dict); + self.push(V::Dict(d)); + } + "definefont" => { + let v = self.pop(); + let key = self.pop().key(); + if let V::Dict(d) = &v { + if !d.borrow().contains_key("__base") { + // A font the file defines itself: its embedded program, by name. + d.borrow_mut().insert("__base".into(), V::Lit(key.as_str().into())); + d.borrow_mut().insert("__m".into(), matrix_val(Affine::IDENTITY)); + } + self.font_dir.insert(key, d.clone()); + } + self.push(v); + } + "undefinefont" => { + let key = self.pop().key(); + self.font_dir.remove(&key); + } + "StandardEncoding" | "ISOLatin1Encoding" => self.push(encoding_array(n)), + "show" => { + let s = self.pop_string(); + self.show_with(&s, |_, _| (0.0, 0.0)); + } + "ashow" => { + let s = self.pop_string(); + let ay = self.popn(); + let ax = self.popn(); + self.show_with(&s, |_, _| (ax, ay)); + } + "widthshow" => { + let s = self.pop_string(); + let ch = self.popn() as u32; + let cy = self.popn(); + let cx = self.popn(); + self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) }); + } + "awidthshow" => { + let s = self.pop_string(); + let ay = self.popn(); + let ax = self.popn(); + let ch = self.popn() as u32; + let cy = self.popn(); + let cx = self.popn(); + self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) }); + } + "kshow" | "cshow" => { + // The procedure between characters is not run. + let s = self.pop_string(); + self.pop(); + self.show_with(&s, |_, _| (0.0, 0.0)); + } + "xshow" | "yshow" | "xyshow" => { + let disp: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); + let s = self.pop_string(); + self.explicit_show(&s, &disp, n); + } + "glyphshow" => { + let name = self.pop().key(); + let Some(d) = self.gs.font.clone() else { return Ok(()) }; + // Drawn through a one-glyph encoding. + let mut e: Vec = (0..256).map(|_| V::Lit(".notdef".into())).collect(); + e[65] = V::Lit(name.as_str().into()); + let tmp = transformed(&d, Affine::IDENTITY); + tmp.borrow_mut().insert("Encoding".into(), array(e)); + self.gs.font = Some(tmp); + self.show_with(b"A", |_, _| (0.0, 0.0)); + self.gs.font = Some(d); + } + "charpath" => { + self.pop(); + let s = self.pop_string(); + let saved = std::mem::take(&mut self.gs.path); + let start = self.gs.start; + match self.text_path(&s, |_, _| (0.0, 0.0)) { + Some((glyphs, _)) => { + let mut p = saved; + p.extend(glyphs); + if let Some(c) = self.gs.cur { + p.move_to(c); + } + self.gs.path = p; + self.gs.start = start; + } + None => self.gs.path = saved, + } + } + "stringwidth" => { + let s = self.pop_string(); + let (mut w, mut h) = (0.0, 0.0); + if let Some(d) = self.gs.font.clone() + && let Some((font, m)) = self.font_of(&d) + { + for &c in &s { + let v = m * Point::new(font.width(c as u32), 0.0) - m * Point::ZERO; + w += v.x; + h += v.y; + } + } + self.push(V::Num(w)); + self.push(V::Num(h)); + } + "setcachedevice" => { + for _ in 0..6 { + self.pop(); + } + } + "setcharwidth" => { + self.pop(); + self.pop(); + } + // An embedded Type 1 program's encrypted part (read by `embedded_fonts`). + "eexec" => { + if matches!(self.pop(), V::File) { + let rest = &src.d[src.pos.min(src.d.len())..]; + src.pos += rest.windows(11).position(|w| w == b"cleartomark").map_or(rest.len(), |p| p + 11); + } + } + // A CFF font set's binary data (read by `embedded_fonts`). + "StartData" => { + let len = self.popn().max(0.0) as usize; + self.pop(); + src.pos = src.pos.saturating_add(1).saturating_add(len).min(src.d.len()); + } + _ => {} + } + Ok(()) + } + + /// `xshow` / `yshow` / `xyshow`: glyph displacements from a number array instead of the + /// advances. + fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) { + let step = if n == "xyshow" { 2 } else { 1 }; + let d: Vec<(f64, f64)> = (0..s.len()) + .map(|k| { + let i = k * step; + let v = |j: usize| disp.get(j).copied().unwrap_or(0.0); + match n { + "xshow" => (v(i), 0.0), + "yshow" => (0.0, v(i)), + _ => (v(i), v(i + 1)), + } + }) + .collect(); + let Some(fd) = self.gs.font.clone() else { + self.b.skip("text (no font)"); + return; + }; + let Some((font, m)) = self.font_of(&fd) else { return }; + self.show_with(s, move |k, c| { + let adv = m * Point::new(font.width(c), 0.0) - m * Point::ZERO; + let (dx, dy) = d.get(k).copied().unwrap_or((0.0, 0.0)); + (dx - adv.x, dy - adv.y) + }); + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b3e054a3..dac34bee 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -119,7 +119,8 @@ showpage\n%%EOF\n"; fn eps_postscript_subset() { let doc = parse(EPS.as_bytes()).unwrap(); assert_eq!((doc.width, doc.height), (100.0, 100.0)); - assert!(doc.skipped.contains(&"text".to_string())); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert!(shape_names(&doc).contains(&"Text: text".to_string()), "EPS text is drawn: {:?}", shape_names(&doc)); let at = |x, y| px(&doc, x, y); assert!(at(25, 75)[0] > 0.99 && at(25, 75)[3] > 0.99, "red box {:?}", at(25, 75)); assert!(at(70, 50)[2] > 0.99, "blue disc {:?}", at(70, 50)); diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 3709d4ff..b6b96b80 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -2,7 +2,90 @@ //! corrupt inputs (generated fixtures). use super::*; -use crate::tests::{page_pdf, render}; +use crate::tests::{coverage, page_pdf, render, shape_names}; + +fn eps(body: &[u8]) -> Doc { + let mut b = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n".to_vec(); + b.extend_from_slice(body); + let doc = parse(&b).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + doc +} + +/// The ink columns `(x0, x1)` of a render (alpha > 0.5). +fn ink_x(img: &effectcraft_raster::Image) -> (i64, i64) { + let (mut x0, mut x1) = (i64::MAX, i64::MIN); + for y in 0..img.height as i64 { + for x in 0..img.width as i64 { + if img.get(x, y)[3] > 0.5 { + (x0, x1) = (x0.min(x), x1.max(x)); + } + } + } + (x0, x1) +} + +#[test] +fn eps_text_with_bundled_fonts_and_show_variants() { + // Not embedded: drawn with the bundled stand-in, like the same text in a PDF. + let doc = eps(b"/Helvetica findfont 40 scalefont setfont 10 30 moveto (Hi) show\n"); + assert_eq!(shape_names(&doc), vec!["Text: Hi"]); + let pdf = parse(&page_pdf("BT /F1 40 Tf 10 30 Td (Hi) Tj ET", "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>", vec![])).unwrap(); + let (a, b) = (render(&doc), render(&pdf)); + let (ia, ib) = (coverage(&a, 0, 0, 200, 100), coverage(&b, 0, 0, 200, 100)); + assert!(ia > 100.0 && (ia - ib).abs() < 1.0, "eps {ia} vs pdf {ib}"); + assert_eq!(ink_x(&a), ink_x(&b)); + // ashow spreads the letters; widthshow widens spaces only; selectfont; stringwidth centres. + let plain = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (HHH) show\n"))); + let spread = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto 10 0 (HHH) ashow\n"))); + assert_eq!(spread.0, plain.0); + assert!(((spread.1 - plain.1) - 20).abs() <= 1, "{plain:?} {spread:?}"); + let ws = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto 15 0 72 (HHH) widthshow\n"))); + assert!(((ws.1 - plain.1) - 30).abs() <= 1, "{plain:?} {ws:?}"); + let centred = ink_x(&render(&eps(b"/Helvetica 30 selectfont 100 (HHH) stringwidth pop 2 div sub 30 moveto (HHH) show\n"))); + let mid = (centred.0 + centred.1) as f64 / 2.0; + assert!((mid - 100.0).abs() < 3.0, "{centred:?}"); + // xshow: explicit advances. + let xs = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (HH) [50 0] xshow\n"))); + let one = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (H) show\n"))); + assert!(((xs.1 - one.1) - 50).abs() <= 1, "{one:?} {xs:?}"); + // makefont mirrors; charpath clips; colour applies. + let doc = eps(b"1 0 0 setrgbcolor /Helvetica findfont [40 0 0 40 0 0] makefont setfont 10 30 moveto (I) false charpath clip 0 0 200 100 rectfill\n"); + let img = render(&doc); + assert!(coverage(&img, 0, 0, 200, 100) > 50.0); + assert!(coverage(&img, 60, 0, 200, 100) < 1.0, "only the glyph is painted"); +} + +#[test] +fn eps_text_with_an_embedded_type1_font_and_reencoding() { + // hsbw 50 600; 0 0 rmoveto; 500 hlineto 500 vlineto -500 hlineto closepath endchar + let cs = vec![50 + 139, 248, 236, 13, 139, 139, 21, 248, 136, 6, 248, 136, 7, 252, 136, 6, 9, 14]; + let raw = crate::type1::write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(66, "box")]); + // Give the program a name. + let mut font = vec![]; + let nl = raw.iter().position(|c| *c == b'\n').unwrap() + 1; + font.extend_from_slice(&raw[..nl]); + font.extend_from_slice(b"12 dict begin /FontName /BoxFont def /FontType 1 def\n"); + font.extend_from_slice(&raw[nl..]); + let mut body = font.clone(); + body.extend_from_slice(b"\n/BoxFont findfont 100 scalefont setfont 0 0 moveto (BB) show\n"); + let doc = eps(&body); + let img = render(&doc); + // Boxes 50..550 / 650..1150 font units at size 100: x 5..55 and 65..115, y 0..50. + for (x, on) in [(30, true), (60, false), (90, true), (120, false)] { + assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}"); + } + // Re-encoded copy (`forall` / `definefont`): code 65 → box. + let mut body = font; + body.extend_from_slice( + b"\n/BoxFont findfont dup length dict begin { 1 index /FID ne { def } { pop pop } ifelse } forall \ +/Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for Encoding 65 /box put currentdict end \ +/BoxA exch definefont pop /BoxA findfont 100 scalefont setfont 0 0 moveto (AB) show\n", + ); + let img = render(&eps(&body)); + assert!(img.get(30, 75)[3] > 0.99, "A is the box now"); + assert!(img.get(90, 75)[3] < 0.01, "B is unencoded"); +} /// An image XObject of `img` (black = true) CCITT-encoded with `k`, drawn over the page. fn ccitt_page(img: &[Vec], k: i64, mask: bool) -> Doc { From fdeb146fb2c26542fa0fb85984b503d9221d8322 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:23:29 -0400 Subject: [PATCH 06/10] M13.12: Create Shapes from Vector Layer keeps images as parented footage layers in paint order Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/create.rs | 75 ++++++++++++++-- crates/engine/src/vector.rs | 128 ++++++++++++++++++++++++++- crates/host/tests/vector_import.rs | 61 +++++++++++++ 3 files changed, 256 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/commands/create.rs index 07e6af7d..850d38ad 100644 --- a/crates/engine/src/commands/create.rs +++ b/crates/engine/src/commands/create.rs @@ -36,14 +36,56 @@ fn create(s: &mut Session, p: &Value) -> Result { let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page) .unwrap_or_else(|| Err(format!("{}: not a vector file", f.path))) .map_err(EngineError::Other)?; - let id = s.edit("Create Shapes from Vector Layer", None, |proj, st| { - let l = crate::vector::shapes_from_vector(proj, &comp, &src, &doc); - let id = l.id; - insert_above(proj, cid, lid, l, true)?; - st.selected_layers = vec![id]; - Ok(id) + // Images become footage layers (PNG files written next to the source and + // imported), parented to the shape layer below it. + let (images, masks) = crate::vector::vector_images(&doc); + if masks > 0 { + skipped.push(format!("{}: {masks} soft mask(s) not converted (shape layers have no soft masks)", src.name)); + } + let mut image_items = vec![]; + for (i, img) in images.iter().enumerate() { + match image_footage(s, &f.path, i + 1, img) { + Ok(item) => image_items.push((item, i)), + Err(e) => skipped.push(format!("{}: image {} not converted ({e})", src.name, i + 1)), + } + if img.clipped { + skipped.push(format!("{}: image {} is clipped or masked; its footage layer is not", src.name, i + 1)); + } + } + let mut notes = vec![]; + let ids = s.edit("Create Shapes from Vector Layer", None, |proj, st| { + // Without images: one shape layer. With images: the shapes between them + // in layers of their own, so the stack keeps the document's paint order. + let segments = if image_items.is_empty() { vec![doc.clone()] } else { crate::vector::split_at_images(&doc) }; + let top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc)); + // Top to bottom: the last segment, image n, segment n − 1, … image 1, segment 0. + let mut stack = vec![]; + for k in (0..segments.len().saturating_sub(1)).rev() { + if let Some((item, i)) = image_items.iter().find(|(_, i)| *i == k) { + let (il, note) = crate::vector::image_layer(proj, &comp, *item, &images[*i], &top); + notes.extend(note); + stack.push(il); + } + let seg = &segments[k]; + if seg.root.children.is_empty() { + continue; + } + let mut sl = crate::vector::shapes_from_vector(proj, &comp, &src, seg); + sl.name = format!("{} {}", top.name, k + 1); + stack.push(sl); + } + let mut ids = vec![top.id]; + ids.extend(stack.iter().filter(|l| matches!(l.source, LayerSource::Shape)).map(|l| l.id)); + // Each directly above the source: the top layer first. + insert_above(proj, cid, lid, top, true)?; + for l in stack { + insert_above(proj, cid, lid, l, true)?; + } + st.selected_layers = vec![ids[0]]; + Ok(ids) })?; - created.push(id.0); + skipped.extend(notes); + created.extend(ids.iter().map(|i| i.0)); } "shapesFromText" => { if !matches!(src.source, LayerSource::Text) { @@ -118,6 +160,25 @@ fn create(s: &mut Session, p: &Value) -> Result { Ok(json!({"layers": created, "skipped": skipped})) } +/// An image of a vector file written as ` Image .png` next to it and imported: the +/// footage item. +fn image_footage(s: &mut Session, source: &str, n: usize, img: &crate::vector::VectorImage) -> Result { + let src = std::path::Path::new(source); + let stem = src.file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_else(|| "Vector".into()); + let name = format!("{stem} Image {n}.png"); + let path = match src.parent().filter(|p| !p.as_os_str().is_empty()) { + Some(dir) => dir.join(&name).to_string_lossy().into_owned(), + None => name, + }; + let png = super::comp_more::encode_png(&img.rgba, img.width, img.height).map_err(EngineError::Other)?; + s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + let r = super::file::import(s, &json!({"paths": [path]}))?; + if let Some(e) = r["errors"].as_array().and_then(|e| e.first()) { + return Err(EngineError::Other(e.as_str().unwrap_or("import failed").to_string())); + } + r["items"].get(0).and_then(Value::as_u64).map(effectcraft_project::ItemId).ok_or_else(|| EngineError::Other(format!("{path} was not imported"))) +} + /// Insert `l` directly above layer `below`; optionally switch the source's video off (as After /// Effects does after converting). fn insert_above( diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 23fc3f24..34edf5b7 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -162,7 +162,8 @@ fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[ /// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with /// the clip paths through Merge Paths ▸ Intersect (see [`clipped_items`]). Clips enclosing the /// whole document are layer masks instead ([`vector_clip_masks`], used by -/// [`shapes_from_vector`]). Images and soft masks are not converted. +/// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see +/// [`split_at_images`]); soft masks are not converted. pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec { fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec { let mut out = vec![]; @@ -340,6 +341,131 @@ fn like_source(dst: &mut Layer, src: &Layer) { dst.switches.three_d = src.switches.three_d; } +/// A raster image of a vector document (Create Shapes from Vector Layer makes it a footage +/// layer: shape layers have no images). +pub struct VectorImage { + pub name: String, + pub width: u32, + pub height: u32, + /// Straight RGBA8. + pub rgba: std::sync::Arc>, + /// Image pixels → document pixels. + pub transform: Affine, + pub opacity: f64, + /// Inside a clipping group or under a soft mask (not carried over). + pub clipped: bool, +} + +/// The document's images, bottom first, and how many soft-masked groups it has. +pub fn vector_images(doc: &Doc) -> (Vec, usize) { + fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec, masks: &mut usize) { + let m = m * g.transform; + let opacity = opacity * g.opacity; + let clipped = clipped || !g.clip.is_empty() || g.mask.is_some(); + if g.mask.is_some() { + *masks += 1; + } + for c in &g.children { + match c { + Node::Group(sub) => walk(sub, m, opacity, clipped, out, masks), + Node::Image(im) => out.push(VectorImage { + name: im.name.clone(), + width: im.width, + height: im.height, + rgba: im.rgba.clone(), + transform: m * im.transform, + opacity: opacity * im.opacity, + clipped, + }), + Node::Shape(_) => {} + } + } + } + let (_, doc) = vector_clip_masks(doc); + let mut out = vec![]; + let mut masks = 0; + walk(&doc.root, Affine::IDENTITY, 1.0, false, &mut out, &mut masks); + (out, masks) +} + +/// The document split at its images (paint order): segment `k` keeps the shapes drawn between +/// image `k − 1` and image `k` (groups, clips and blend modes kept, images dropped), so shape +/// layers and image footage layers can stack in the document's order. One more segment than +/// images. +pub fn split_at_images(doc: &Doc) -> Vec { + fn count(g: &effectcraft_svg::Group) -> usize { + g.children + .iter() + .map(|c| match c { + Node::Group(s) => count(s), + Node::Image(_) => 1, + Node::Shape(_) => 0, + }) + .sum() + } + /// Keep the shapes between images `seg − 1` and `seg`; `seen` counts images passed. + fn prune(g: &effectcraft_svg::Group, seg: usize, seen: &mut usize) -> effectcraft_svg::Group { + let mut out = g.clone(); + out.children = vec![]; + for c in &g.children { + match c { + Node::Image(_) => *seen += 1, + Node::Shape(_) => { + if *seen == seg { + out.children.push(c.clone()); + } + } + Node::Group(s) => { + let p = prune(s, seg, seen); + if !p.children.is_empty() { + out.children.push(Node::Group(p)); + } + } + } + } + out + } + let n = count(&doc.root); + (0..=n) + .map(|seg| { + let mut d = doc.clone(); + d.root = prune(&doc.root, seg, &mut 0); + d + }) + .collect() +} + +/// A footage layer for an image of a vector document, parented to `parent` (the shape layer, +/// whose layer space is document pixels) and placed by the image's transform (anchor at its +/// top-left corner; position, rotation and scale from the matrix). Returns a note when the +/// placement has a skew, which layer transforms cannot express. +pub fn image_layer(proj: &mut Project, comp: &Comp, item: ItemId, img: &VectorImage, parent: &Layer) -> (Layer, Option) { + let mut l = build::layer(proj, comp, &img.name, LayerSource::Footage { item }, (img.width, img.height), None); + let c = img.transform.as_coeffs(); + let theta = c[1].atan2(c[0]); + let (sn, cs) = theta.sin_cos(); + let sx = (c[0] * c[0] + c[1] * c[1]).sqrt(); + let sy = -c[2] * sn + c[3] * cs; + let skew = c[2] * cs + c[3] * sn; + let set = |l: &mut Layer, path: &str, v: Value| { + if let Some(p) = l.props.prop_mut(path) { + p.value = v; + } + }; + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut l, "transform/position", Value::Vec3([c[4], c[5], 0.0])); + set(&mut l, "transform/scale", Value::Vec3([sx * 100.0, sy * 100.0, 100.0])); + set(&mut l, "transform/rotation", Value::Scalar(theta.to_degrees())); + set(&mut l, "transform/opacity", Value::Scalar((img.opacity * 100.0).clamp(0.0, 100.0))); + l.start_time = parent.start_time; + l.in_point = parent.in_point; + l.out_point = parent.out_point; + l.stretch = parent.stretch; + l.parent = Some(parent.id); + let note = (skew.abs() > 1e-3 * sy.abs().max(1e-9)).then(|| format!("{}: skewed image placed without its skew", img.name)); + (l, note) +} + /// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. A clip /// enclosing the whole document becomes the layer's masks (Add, then Intersect); nested clips /// are kept with Merge Paths (see [`svg_contents`]). diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index d8082573..44d2073f 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -461,3 +461,64 @@ fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() { assert!(top[0] > 0.5 || top[1] > 0.5, "top centre covered {top:?}"); assert!(img.get(21, 11)[0] < 0.05 && img.get(21, 11)[1] < 0.05, "corner region empty {:?}", img.get(21, 11)); } + +/// A PDF with two placed images (one turned a quarter) between a background and a shape. +fn image_pdf() -> Vec { + let content = "0.9 0.9 0.9 rg 0 0 120 80 re f\nq 60 0 0 30 10 40 cm /Im Do Q\nq 0 40 -20 0 110 10 cm /Im Do Q\n0 0 1 rg 20 5 30 20 re f\n"; + // 8×8 pixels in quadrants: red, green / white, black. + let quad = |x: usize, y: usize| -> [u8; 3] { [[[255, 0, 0], [0, 255, 0]], [[255, 255, 255], [0, 0, 0]]][y / 4][x / 4] }; + let px: Vec = (0..64).flat_map(|i| quad(i % 8, i / 8)).collect(); + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(content.as_bytes().to_vec())), + (5, "<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(px)), + ], + 1, + ) +} + +#[test] +fn shapes_from_vector_layer_keeps_images_as_footage_layers() { + let bytes = image_pdf(); + let doc = effectcraft_pdf::parse(&bytes).unwrap(); + let (w, h) = doc.pixel_size(); + let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0); + let path = tmp("images.pdf"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let item = s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "I", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap(); + let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let t = effectcraft_time::Tick::ZERO; + let foot = s.render(cid, t, RenderOpts::default()); + assert!(mean_diff(&foot, &direct) < 1.0 / 255.0); + s.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{r}"); + let sid = r["layers"][0].as_u64().unwrap(); + let c = s.active_comp().unwrap(); + // In the document's paint order: the shape drawn last, the two images (the later one + // higher), the background drawn first, then the hidden source. + let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["images.pdf Outlines", "Image", "Image", "images.pdf Outlines 1", "images.pdf"]); + assert_eq!(c.layers[0].id.0, sid); + assert_eq!(r["layers"].as_array().unwrap().len(), 2); + for l in &c.layers[1..3] { + assert_eq!(l.parent.map(|p| p.0), Some(sid), "{}", l.name); + assert!(matches!(l.source, effectcraft_project::LayerSource::Footage { .. })); + } + assert!(!c.layers[4].switches.video); + let shapes = s.render(cid, t, RenderOpts::default()); + let d = mean_diff(&shapes, &direct); + assert!(d < 0.02, "converted vs page: mean diff {d}"); + // Image pixels in place: quarters of the upright image and of the turned one, and the + // shape over the background. + for (x, y) in [(25, 17), (55, 17), (25, 32), (55, 32), (95, 60), (95, 40), (105, 40), (30, 65)] { + let (a, b) = (shapes.get(x, y), direct.get(x, y)); + assert!((0..3).all(|k| (a[k] - b[k]).abs() < 0.05), "({x}, {y}): {a:?} vs {b:?}"); + } +} From c3532df7ef2817432328526bf605ef77ac5fca74 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:34:15 -0400 Subject: [PATCH 07/10] M13.12: Variable Font Axes animators change advances (text re-spaced) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_tail.rs | 19 +++++++++++++++++++ crates/render/src/text.rs | 19 ++++++++++++++----- crates/text/src/variable.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 705425c0..7cd855c6 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -314,13 +314,32 @@ fn variable_font_axes_animator() { let l = comp.layer(LayerId(t)).unwrap(); effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum::() }; + // The distance between the two H's left edges: the H advance. + let gap = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time()); + let l = comp.layer(LayerId(t)).unwrap(); + let x0: Vec = effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::bounding_box(p).x0).collect(); + x0[1] - x0[0] + }; let before = area(&s); + let gap_before = gap(&s); let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap().clone(); let pg = l.props.find_group(anim).unwrap().sub("properties").unwrap().props().next().unwrap().uid; let range = (axis.max - axis.min) as f64; s.execute("prop.set", json!({"layer": t, "prop": pg, "value": range})).unwrap(); let after = area(&s); assert!((after - before).abs() > 1.0, "{} axis moved the outline: {before} → {after}", axis.tag); + // Advances follow the axis too (the text is re-spaced, not only redrawn). + let a = axes.iter().find(|a| a.tag == tag).unwrap(); + let f = effectcraft_text::fonts::face(face); + let gid = f.glyph('H').unwrap(); + let upem = f.units_per_em() as f64; + let units = |v: f32| effectcraft_text::variable::advance_units_at(face, gid, &[(a.tag.clone(), v)]).unwrap() as f64; + let want = (units((a.default + range as f32).min(a.max)) - units(a.default)) * 60.0 / upem; + let moved = gap(&s) - gap_before; + assert!((moved - want).abs() < 0.5, "{} axis: advance change {moved}, expected {want}", a.tag); s.execute("edit.undo", json!({})).unwrap(); assert!((area(&s) - before).abs() < 1e-6); let _ = info; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index ca37a951..3bfb5593 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -568,8 +568,17 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { (lay, xfs) }; - // Tracking (before / after each character) and Line Anchor, per line. + // Variable Font Axes: the advance at the animated design-space position (later characters + // on the line move with it). let n = lay.glyphs.len(); + let vadv: Vec = (0..n) + .map(|gi| match (subs[gi], xfs[gi].axis_deltas()) { + (None, d) if !d.is_empty() => effectcraft_text::variable::char_advance_delta(&lay.glyphs[gi], &d).unwrap_or(0.0), + _ => 0.0, + }) + .collect(); + let advance = |gi: usize| lay.glyphs[gi].advance + vadv[gi]; + // Tracking (before / after each character) and Line Anchor, per line. let mut shift = vec![0.0f64; n]; let mut li_start = 0; while li_start < n { @@ -585,7 +594,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0; pen += x.track_before * em; shift[gi] = pen; - pen += x.track_after * em; + pen += x.track_after * em + vadv[gi]; if x.line_anchor_k > la_k { la = x.line_anchor; la_k = x.line_anchor_k; @@ -622,7 +631,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let o = origin(gi); let e = extents.entry(group_key(g)).or_insert([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]); e[0] = e[0].min(o[0]); - e[2] = e[2].max(o[0] + g.advance); + e[2] = e[2].max(o[0] + advance(gi)); e[1] = e[1].min(o[1]); e[3] = e[3].max(o[1]); } @@ -645,7 +654,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { while j < n && lay.glyphs[j].line_index == line { j += 1; } - let xs: Vec = (i..j).map(|gi| origin(gi)[0] + lay.glyphs[gi].advance / 2.0).collect(); + let xs: Vec = (i..j).map(|gi| origin(gi)[0] + advance(gi) / 2.0).collect(); let lb = lay.line_boxes.get(line).copied().unwrap_or([0.0; 4]); let pos = path_text::arc_positions(&xs, [lb[0], lb[0] + lb[2]], align, pm.length(), *fm, *lm, *force); arc[i..j].copy_from_slice(&pos); @@ -684,7 +693,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { } // Variable Font Axes: the outline redrawn at the animated design-space position. None => match x.axis_deltas() { - d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), g.advance), + d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), advance(gi)), _ => (g.path.clone(), g.advance), }, }; diff --git a/crates/text/src/variable.rs b/crates/text/src/variable.rs index 2072c294..6594e2dc 100644 --- a/crates/text/src/variable.rs +++ b/crates/text/src/variable.rs @@ -123,6 +123,37 @@ pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option Option { + let f = fonts::face(face); + let font = f.font()?; + let loc = font.axes().location(coords.iter().map(|(t, v)| (tag_of(t), *v))); + font.glyph_metrics(Size::unscaled(), LocationRef::from(&loc)).advance_width(GlyphId::new(gid)) +} + +/// The change of a laid-out character's advance (layout units) when its face's axes move by +/// `deltas` (as [`char_outline_varied`]): Variable Font Axes animators re-space the text. +pub fn char_advance_delta(g: &CharGlyph, deltas: &[(String, f32)]) -> Option { + let axes = font_axes(g.face); + let base = |t: &str, d: f32| g.variations.iter().find(|(x, _)| x.trim_end() == t.trim_end()).map(|(_, v)| *v).unwrap_or(d); + let mut coords: Vec<(String, f32)> = + deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (base(&a.tag, a.default) + d).clamp(a.min, a.max)))).collect(); + if coords.is_empty() { + return None; + } + for (t, v) in &g.variations { + if !coords.iter().any(|(c, _)| c == t) { + coords.push((t.clone(), *v)); + } + } + let varied = advance_units_at(g.face, g.gid, &coords)?; + let own = advance_units_at(g.face, g.gid, &g.variations)?; + // Font units → layout units: the outline transform's horizontal scale. + let sx = g.outline_xf.as_coeffs()[0]; + Some((varied - own) as f64 * sx) +} + /// The first installed face with variation axes (system fonts are scanned), for tests and /// demos: (face, its axes). pub fn find_variable_face() -> Option<(FaceId, Vec)> { From 88384197f97dbe29597081343889ec8c6f3bb001 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:42:44 -0400 Subject: [PATCH 08/10] M13.12: plug-in ABI hardening (memory limit, manifest length, slider schema) and tests Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/plugin.rs | 10 +++ crates/host/tests/plugins.rs | 55 ++++++++++++ crates/plugin/src/tests.rs | 161 ++++++++++++++++++++++++++++++++++- crates/plugin/src/wasm.rs | 18 ++-- 4 files changed, 236 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/plugin.rs b/crates/effects/src/plugin.rs index 242f52b9..874050f4 100644 --- a/crates/effects/src/plugin.rs +++ b/crates/effects/src/plugin.rs @@ -137,6 +137,7 @@ pub struct PluginManifest { impl PluginManifest { /// Check the manifest against this API version. pub fn validate(&self) -> Result<(), String> { + const MAX_PARAMS: usize = 256; if self.api != PLUGIN_API_VERSION { return Err(format!("plug-in `{}` targets plug-in API {} but this build implements API {PLUGIN_API_VERSION}", self.id, self.api)); } @@ -147,6 +148,9 @@ impl PluginManifest { if self.name.trim().is_empty() || self.category.trim().is_empty() { return Err(format!("plug-in `{}` needs a name and a category", self.id)); } + if self.params.len() > MAX_PARAMS { + return Err(format!("plug-in `{}` has {} parameters (at most {MAX_PARAMS})", self.id, self.params.len())); + } let mut seen = std::collections::HashSet::new(); for p in &self.params { if p.id.is_empty() || !p.id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || !seen.insert(p.id.as_str()) { @@ -157,6 +161,12 @@ impl PluginManifest { { return Err(format!("plug-in `{}`: popup `{}` needs options and a default index in range", self.id, p.id)); } + if let PluginParamKind::Slider { default, min, max, slider_min, slider_max, .. } = &p.kind { + let (lo, hi) = (slider_min.unwrap_or(*min), slider_max.unwrap_or(*max)); + if [*default, *min, *max, lo, hi].iter().any(|v| !v.is_finite()) || min > max || lo > hi || default < min || default > max { + return Err(format!("plug-in `{}`: slider `{}` needs min ≤ default ≤ max (and sliderMin ≤ sliderMax)", self.id, p.id)); + } + } } Ok(()) } diff --git a/crates/host/tests/plugins.rs b/crates/host/tests/plugins.rs index c8a9b368..83891148 100644 --- a/crates/host/tests/plugins.rs +++ b/crates/host/tests/plugins.rs @@ -104,3 +104,58 @@ fn example_wasm_plugin_loads_and_renders() { let list = s.execute("effect.plugins.list", json!({})).unwrap(); assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["source"].as_str().unwrap_or("").ends_with(".wasm"))); } + +/// A parameterless plug-in module (WebAssembly text): `render` is `ec_render`'s body. +fn wat_plugin(id: &str, api: i32, render: &str) -> String { + let manifest = format!(r#"{{"api":1,"id":"{id}","name":"Host {id}","category":"Test Plug-ins","version":"1","params":[]}}"#); + let len = manifest.len(); + let escaped = manifest.replace('"', "\\\""); + format!( + r#"(module + (memory (export "memory") 2) + (data (i32.const 16) "{escaped}") + (func (export "ec_api_version") (result i32) i32.const {api}) + (func (export "ec_manifest_ptr") (result i32) i32.const 16) + (func (export "ec_manifest_len") (result i32) i32.const {len}) + (func (export "ec_alloc") (param i32) (result i32) i32.const 1024) + (func (export "ec_render") (param i32 i32 i32 i32 i32 f64 f64) (result i32) {render}))"# + ) +} + +#[test] +fn plugin_loading_reports_bad_modules_and_survives_failing_renders() { + let dir = std::env::temp_dir().join(format!("effectcraft-host-plugins-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let files = [ + ("a-good.wasm", wat_plugin("org.test.host.good", 1, "i32.const 0").into_bytes()), + ("b-garbage.wasm", b"\0asm\x01\0\0\0garbage".to_vec()), + ("c-v2.wasm", wat_plugin("org.test.host.v2", 2, "i32.const 0").into_bytes()), + ("d-empty.wasm", vec![]), + ("e-trap.wasm", wat_plugin("org.test.host.trap", 1, "unreachable").into_bytes()), + ("notes.txt", b"not a plug-in".to_vec()), + ]; + for (name, bytes) in &files { + std::fs::write(dir.join(name), bytes).unwrap(); + } + let (mut s, l) = setup(); + let r = s.execute("effect.plugins.load", json!({"folder": dir.to_string_lossy()})).unwrap(); + let ids: Vec<&str> = r["loaded"].as_array().unwrap().iter().filter_map(|v| v["id"].as_str()).collect(); + assert_eq!(ids, ["org.test.host.good", "org.test.host.trap"], "{r}"); + let errs: Vec = r["errors"].as_array().unwrap().iter().map(|e| e["path"].as_str().unwrap().to_string()).collect(); + assert_eq!(errs.len(), 3, "{r}"); + assert!(errs.iter().all(|p| !p.ends_with(".txt"))); + assert!(r["errors"].to_string().contains("API 2"), "{r}"); + // A single file: errors come back as command errors; loading twice is refused. + let good = dir.join("a-good.wasm").to_string_lossy().to_string(); + assert!(s.execute("effect.plugins.load", json!({"path": good})).unwrap_err().to_string().contains("already registered")); + assert!(s.execute("effect.plugins.load", json!({"path": dir.join("missing.wasm").to_string_lossy()})).is_err()); + assert!(s.execute("effect.plugins.load", json!({"folder": dir.join("missing").to_string_lossy()})).is_err()); + assert!(s.execute("effect.plugins.load", json!({})).is_err()); + // A plug-in that traps on every frame leaves the layer as it was. + let before = px(&s); + s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.trap"})).unwrap(); + assert_eq!(px(&s), before); + s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.good"})).unwrap(); + assert_eq!(px(&s), before); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 9363ab3e..f68fde00 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -12,11 +12,20 @@ fn load_err(r: Result<&'static effectcraft_effects::EffectSpec, String>) -> Stri } } +/// The gain plug-in's manifest. +fn gain_manifest(id: &str) -> String { + format!( + r#"{{"api":1,"id":"{id}","name":"Gain {id}","category":"Test Plug-ins","version":"1.0","params":[{{"id":"gain","name":"Gain","type":"slider","default":0.5,"min":0,"max":4}},{{"id":"on","name":"Lift","type":"checkbox","default":false}},{{"id":"tint","name":"Tint","type":"color","default":[0,0,1,1]}}]}}"# + ) +} + /// A plug-in in WebAssembly text: multiplies RGB by `gain`, adds `lift` × `tint` when `on`. fn gain_module(id: &str, render_body: &str) -> String { - let manifest = format!( - r#"{{"api":1,"id":"{id}","name":"Gain {id}","category":"Test Plug-ins","version":"1.0","params":[{{"id":"gain","name":"Gain","type":"slider","default":0.5,"min":0,"max":4}},{{"id":"on","name":"Lift","type":"checkbox","default":false}},{{"id":"tint","name":"Tint","type":"color","default":[0,0,1,1]}}]}}"# - ); + module_with(&gain_manifest(id), render_body) +} + +/// The gain module with its own manifest JSON. +fn module_with(manifest: &str, render_body: &str) -> String { let len = manifest.len(); let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\""); format!( @@ -162,3 +171,149 @@ fn wasm_plugins_are_sandboxed() { let reserved = gain_module("ec.blur.mine", ""); assert!(load_err(load_wasm(reserved.as_bytes(), "reserved.wat")).contains("reserved")); } + +/// `base` with `from` replaced by `to` (which must occur). +fn edit(base: &str, from: &str, to: &str) -> String { + assert!(base.contains(from), "{from}"); + base.replace(from, to) +} + +#[test] +fn abi_violations_fail_to_load_with_reasons() { + let base = gain_module("org.test.abi", ""); + // Missing exports. + for export in ["ec_render", "ec_alloc", "ec_manifest_ptr", "ec_manifest_len", "ec_api_version"] { + let m = edit(&base, &format!("(export \"{export}\")"), ""); + let e = load_err(load_wasm(m.as_bytes(), "x.wat")); + assert!(e.contains(export), "{export}: {e}"); + } + let m = edit(&base, "(memory (export \"memory\") 2)", "(memory 2)"); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("memory")); + // Wrong signatures. + let m = edit(&base, "(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result f64) f64.const 1)"); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_api_version")); + let m = edit(&base, "(param $scale f64) (result i32)", "(param $scale f32) (result i32)"); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_render")); + // API versions other than 1. + for v in ["0", "-1", "2147483647"] { + let m = edit( + &base, + "(func (export \"ec_api_version\") (result i32) i32.const 1)", + &format!("(func (export \"ec_api_version\") (result i32) i32.const {v})"), + ); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains(&format!("API {v}")), "{v}"); + } + // Manifest out of bounds, negative or absurd lengths, not JSON. + let ptr = "(func (export \"ec_manifest_ptr\") (result i32) i32.const 16)"; + let m = edit(&base, ptr, "(func (export \"ec_manifest_ptr\") (result i32) i32.const 200000)"); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("out of bounds")); + let len = gain_manifest("org.test.abi").len(); + for bad in ["-5", "2000000000"] { + let m = edit(&base, &format!("(result i32) i32.const {len})"), &format!("(result i32) i32.const {bad})")); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("manifest length"), "{bad}"); + } + let m = edit(&base, ptr, "(func (export \"ec_manifest_ptr\") (result i32) i32.const 4000)"); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("manifest JSON")); + // Traps and runaway code while loading. + let version = "(func (export \"ec_api_version\") (result i32) i32.const 1)"; + let m = edit(&base, version, "(func (export \"ec_api_version\") (result i32) unreachable)"); + assert!(load_wasm(m.as_bytes(), "x.wat").is_err()); + let m = edit(&base, version, "(func (export \"ec_api_version\") (result i32) (loop $l br $l) i32.const 1)"); + assert!(load_wasm(m.as_bytes(), "x.wat").is_err(), "fuel runs out in ec_api_version"); + let m = edit(&base, "(memory (export \"memory\") 2)", "(memory (export \"memory\") 2) (func $s (loop $l br $l)) (start $s)"); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("instantiation"), "fuel runs out in the start function"); + // Memory beyond the sandbox's limit. + let m = edit(&base, "(memory (export \"memory\") 2)", "(memory (export \"memory\") 20000)"); + assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("instantiation")); + // Not a module at all. + for junk in [&b""[..], b"\0asm", b"\0asm\x01\0\0\0\xff\xff", b"(module", b"(module (func (export \"x\") unknown_op))"] { + assert!(load_wasm(junk, "junk").is_err()); + } +} + +#[test] +fn manifest_parameter_schemas_are_validated() { + let p = |params: &str| format!(r#"{{"api":1,"id":"org.test.schema","name":"Schema","category":"Test Plug-ins","version":"1","params":[{params}]}}"#); + let bad = [ + (r#"{"id":"a","name":"A","type":"checkbox"},{"id":"a","name":"B","type":"checkbox"}"#, "duplicate"), + (r#"{"id":"a b","name":"A","type":"checkbox"}"#, "parameter id"), + (r#"{"id":"","name":"A","type":"checkbox"}"#, "parameter id"), + (r#"{"id":"p","name":"P","type":"popup","options":[],"default":0}"#, "popup"), + (r#"{"id":"p","name":"P","type":"popup","options":["x"],"default":3}"#, "popup"), + (r#"{"id":"s","name":"S","type":"slider","default":1,"min":5,"max":0}"#, "slider"), + (r#"{"id":"s","name":"S","type":"slider","default":9,"min":0,"max":4}"#, "slider"), + (r#"{"id":"s","name":"S","type":"slider","default":1,"min":0,"max":4,"sliderMin":3,"sliderMax":2}"#, "slider"), + (r#"{"id":"s","name":"S","type":"wobble"}"#, "manifest JSON"), + (r#"{"id":"s","name":"S","type":"slider"}"#, "manifest JSON"), + ]; + for (params, want) in bad { + let m = module_with(&p(params), ""); + let e = load_err(load_wasm(m.as_bytes(), "schema.wat")); + assert!(e.contains(want), "{params}: {e}"); + } + let many: Vec = (0..300).map(|i| format!(r#"{{"id":"c{i}","name":"C","type":"checkbox"}}"#)).collect(); + let e = load_err(load_wasm(module_with(&p(&many.join(",")), "").as_bytes(), "many.wat")); + assert!(e.contains("at most"), "{e}"); + // Missing name, another API in the manifest. + let e = load_err(load_wasm(module_with(r#"{"api":1,"id":"org.test.noname","name":" ","category":"X","version":"1","params":[]}"#, "").as_bytes(), "x.wat")); + assert!(e.contains("name"), "{e}"); + let e = load_err(load_wasm(module_with(r#"{"api":3,"id":"org.test.api3","name":"N","category":"X","version":"1","params":[]}"#, "").as_bytes(), "x.wat")); + assert!(e.contains("API 3"), "{e}"); + // A valid schema loads. + let ok = p( + r#"{"id":"s","name":"S","type":"slider","default":2,"min":0,"max":4,"sliderMin":1,"sliderMax":3},{"id":"p","name":"P","type":"popup","options":["x","y"],"default":1}"#, + ); + let spec = load_wasm(module_with(&ok, "").as_bytes(), "ok.wat").unwrap(); + assert_eq!(spec.params.len(), 2); +} + +#[test] +fn render_failures_leave_the_frame_and_recover() { + let orig = [0.8, 0.6, 0.2, 1.0]; + // ec_render traps (when time > 0.5), returns an error code, or ec_alloc hands back memory + // out of bounds: the frame passes through unchanged, and the plug-in keeps working. + let trap = load_wasm(gain_module("org.test.trap", "local.get $t f64.const 0.5 f64.gt if unreachable end").as_bytes(), "trap.wat").unwrap(); + let mut next = 1; + let g = effectcraft_effects::instantiate(trap, &mut Ids(&mut next), trap.name, [4.0, 2.0]); + let params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone()); + let at = |time: f64| { + let ctx = EffectCtx { params: ¶ms, time, layer_size: [4.0, 2.0], seed: 1, adjustment: false, env: EffectEnv::default() }; + let buf = Buf { img: Image::filled(4, 2, orig), offset: [0.0; 2], scale: 1.0 }; + (trap.render)(&ctx, buf).img + }; + assert_eq!(at(1.0).get(0, 0), orig, "trapped"); + assert_eq!(at(0.0).get(0, 0), [0.4, 0.3, 0.1, 1.0], "a fresh instance after the trap"); + assert_eq!(at(1.0).get(0, 0), orig); + assert_eq!(at(0.0).get(1, 1), [0.4, 0.3, 0.1, 1.0]); + let code = load_wasm(gain_module("org.test.code", "i32.const 7 return").as_bytes(), "code.wat").unwrap(); + assert_eq!(render(code, 0.5, false).get(0, 0), orig); + let oob = edit(&gain_module("org.test.oob", ""), " i32.const 65536)", " i32.const -16)"); + let oob = load_wasm(oob.as_bytes(), "oob.wat").unwrap(); + assert_eq!(render(oob, 0.5, false).get(0, 0), orig); + // Runaway rendering: out of fuel every time, never hangs. + let spin = load_wasm(gain_module("org.test.spin2", "(loop $spin br $spin)").as_bytes(), "spin2.wat").unwrap(); + for _ in 0..3 { + assert_eq!(render(spin, 0.5, false).get(0, 0), orig); + } +} + +#[test] +fn wasm_rendering_is_deterministic() { + // Float edge cases (NaN from sqrt(−x), infinities from x / 0) come out bit-identical + // across instances, threads and repeated runs. + let body = "local.get $px local.get $px f32.load f32.const -1 f32.mul f32.sqrt f32.store \ + local.get $px local.get $px f32.load offset=4 f32.const 0 f32.div f32.store offset=4"; + let spec = load_wasm(gain_module("org.test.det", body).as_bytes(), "det.wat").unwrap(); + let bits = |img: &Image| -> Vec { (0..2).flat_map(|y| (0..4).map(move |x| (x, y))).flat_map(|(x, y)| img.get(x, y).map(f32::to_bits)).collect() }; + let first = bits(&render(spec, 0.37, true)); + let runs: Vec> = std::thread::scope(|sc| { + let hs: Vec<_> = (0..6).map(|_| sc.spawn(|| bits(&render(spec, 0.37, true)))).collect(); + hs.into_iter().map(|h| h.join().unwrap()).collect() + }); + for r in runs { + assert_eq!(r, first); + } + // The same module loaded under another id renders the same bits. + let twin = load_wasm(gain_module("org.test.det2", body).as_bytes(), "det2.wat").unwrap(); + assert_eq!(bits(&render(twin, 0.37, true)), first); +} diff --git a/crates/plugin/src/wasm.rs b/crates/plugin/src/wasm.rs index 8015f7ee..71f1f136 100644 --- a/crates/plugin/src/wasm.rs +++ b/crates/plugin/src/wasm.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; -use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, TypedFunc, WasmParams, WasmResults}; +use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, WasmParams, WasmResults}; use crate::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams}; @@ -10,12 +10,16 @@ use crate::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, Plugi /// work, but a runaway loop stops instead of hanging the render. const FUEL_BASE: u64 = 50_000_000; const FUEL_PER_PIXEL: u64 = 20_000; +/// The most linear memory a plug-in instance may have (a 4K float frame is ~130 MB). +const MAX_MEMORY: usize = 1 << 30; +/// The longest manifest read. +const MAX_MANIFEST: i32 = 1 << 20; type RenderFn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64), i32>; /// One instantiated module (renders running on several threads each take their own). struct Inst { - store: Store<()>, + store: Store, instance: Instance, memory: Memory, alloc: TypedFunc, @@ -37,9 +41,10 @@ fn err(e: impl std::fmt::Display) -> String { } fn instantiate(engine: &Engine, module: &Module) -> Result { - let mut store = Store::new(engine, ()); + let mut store = Store::new(engine, StoreLimitsBuilder::new().memory_size(MAX_MEMORY).instances(1).build()); + store.limiter(|l| l); store.set_fuel(FUEL_BASE).map_err(err)?; - let linker = Linker::<()>::new(engine); + let linker = Linker::::new(engine); let instance = linker.instantiate_and_start(&mut store, module).map_err(|e| format!("instantiation failed: {e}"))?; let memory = instance.get_memory(&store, "memory").ok_or("the module exports no `memory`")?; let alloc = instance.get_typed_func::(&store, "ec_alloc").map_err(|_| missing("ec_alloc"))?; @@ -74,7 +79,10 @@ impl WasmPlugin { } let ptr = inst.func::<(), i32>("ec_manifest_ptr")?.call(&mut inst.store, ()).map_err(err)?; let len = inst.func::<(), i32>("ec_manifest_len")?.call(&mut inst.store, ()).map_err(err)?; - let mut text = vec![0u8; len.max(0) as usize]; + if !(0..=MAX_MANIFEST).contains(&len) { + return Err(format!("bad manifest length {len}")); + } + let mut text = vec![0u8; len as usize]; inst.memory.read(&inst.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?; let manifest: PluginManifest = serde_json::from_slice(&text).map_err(|e| format!("bad manifest JSON: {e}"))?; manifest.validate()?; From 2f1788bdf64fd475e72cf81acd08aa303a7ac10d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:48:14 -0400 Subject: [PATCH 09/10] M13.12: script socket port validation, resource-string trailing commas, parser and socket edge-case tests Co-Authored-By: Claude Opus 5.5 --- crates/script/src/scriptui.js | 6 ++ crates/script/src/socket.rs | 5 +- crates/script/src/tests_ui_more.rs | 134 +++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 57e0f445..525479c6 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -143,6 +143,12 @@ function __uiParseRes(src) { ws(); if (s.charAt(i) === ",") { i++; + // A trailing comma, as JavaScript allows. + ws(); + if (s.charAt(i) === "]") { + i++; + return a; + } continue; } if (s.charAt(i) === "]") { diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 8b1074d4..6ac0d41d 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -144,7 +144,10 @@ mod imp { err(format!("cannot connect to {host}: {last}")) } "listen" => { - let port = p.get("port").and_then(J::as_u64).unwrap_or(0) as u16; + let port = match p.get("port").and_then(J::as_f64).unwrap_or(0.0) { + v if v.fract() == 0.0 && (0.0..=65535.0).contains(&v) => v as u16, + v => return err(format!("bad port {v} (0 to 65535)")), + }; match TcpListener::bind(("127.0.0.1", port)).and_then(|l| l.set_nonblocking(true).map(|_| l)) { Ok(l) => { let port = l.local_addr().map(|a| a.port()).unwrap_or(port); diff --git a/crates/script/src/tests_ui_more.rs b/crates/script/src/tests_ui_more.rs index 2c24329a..69abb61d 100644 --- a/crates/script/src/tests_ui_more.rs +++ b/crates/script/src/tests_ui_more.rs @@ -240,3 +240,137 @@ fn sockets_talk_tcp_behind_the_network_preference() { let o = run_code(&mut s, "var l = new Socket(); l.listen(0); var r = [l.port > 0, l.poll() === null].join('|'); l.close(); r", "net.jsx"); assert_eq!(o.result, json!("true|true")); } + +/// `__uiParseRes(src)` as JSON, or the error message. +fn parse_res(s: &mut Session, src: &str) -> Result { + let code = format!("JSON.stringify(__uiParseRes({}))", serde_json::to_string(src).unwrap()); + let o = run_code(s, &code, "res.jsx"); + match o.error { + Some(e) => Err(e.message), + None => Ok(serde_json::from_str(o.result.as_str().unwrap_or("null")).unwrap_or_default()), + } +} + +#[test] +fn resource_string_parser_edge_cases() { + let mut s = session(); + // Accepted: trailing commas, quoted keys, number forms, escapes, comments, empty bodies, + // null / undefined, bare words, nested arrays and object literals. + let ok = parse_res( + &mut s, + "group { 'quoted key': 1, \"dq\": -.5, n: +3, e: 1e2, arr: [[1, [2]], 'x', ], \ + esc: 'it\\'s\\n\\\\', /* block */ nul: null, u: undefined, w: fill, // line\n \ + obj: { a: { b: true } }, kid: Button {}, }", + ) + .unwrap(); + assert_eq!(ok["type"], "group"); + let list = ok["list"].as_array().unwrap(); + let get = |k: &str| list.iter().find(|e| e[0] == k).map(|e| e[1].clone()).unwrap(); + assert_eq!(get("quoted key"), json!(1)); + assert_eq!(get("dq"), json!(-0.5)); + assert_eq!(get("n"), json!(3)); + assert_eq!(get("e"), json!(100)); + assert_eq!(get("arr"), json!([[1, [2]], "x"])); + assert_eq!(get("esc"), json!("it's\n\\")); + assert_eq!(get("nul"), json!(null)); + assert_eq!(get("w"), json!("fill")); + assert_eq!(get("kid"), json!({"__element": "Button", "list": []})); + // Rejected with a located message (never a crash). + for bad in [ + "", + " ", + "dialog", + "dialog {", + "dialog { text: 'unterminated }", + "dialog { text 'x' }", + "dialog { a: [1, 2 }", + "dialog { a: 1 } trailing", + "dialog { /* unterminated comment", + "dialog { a: 'x\\", + "{ }", + "123 { }", + "dialog { a: 1 b: 2 }", + "dialog { : 1 }", + "dialog { a: }", + "dialog { a: Button { }", + ] { + let e = parse_res(&mut s, bad).expect_err(bad); + assert!(e.contains("resource string"), "{bad:?}: {e}"); + assert!(e.contains("offset"), "{bad:?}: {e}"); + } + // Deep nesting builds; absurd nesting fails cleanly (the script engine's recursion limit). + let deep = |n: usize| format!("group {{ {}{} }}", "g: Group { ".repeat(n), "}".repeat(n)); + let d = parse_res(&mut s, &deep(40)).unwrap(); + assert_eq!(d["list"][0][1]["__element"], "Group"); + let _ = parse_res(&mut s, &deep(5000)); + // A window from a deep resource. + let o = run_code( + &mut s, + &format!( + "var w = new Window({}); w.children.length", + serde_json::to_string(&format!("palette {{ {}{} }}", "g: Group { ".repeat(20), "}".repeat(20))).unwrap() + ), + "deep.jsx", + ); + assert_eq!(o.result, json!(1), "{:?}", o.error); +} + +#[test] +fn socket_edge_cases() { + let mut s = session(); + s.prefs.scripting.allow_scripts_write_files = true; + // Bad addresses and ports: false with an error, never a throw or crash. + for host in ["", "nohostport", "127.0.0.1", "127.0.0.1:99999", "[::1", "a:b:c", "127.0.0.1:-5"] { + let code = format!( + "var s = new Socket(); s.timeout = 1; var ok = s.open({}); [ok, s.error.length > 0, s.connected, s.eof].join('|')", + serde_json::to_string(host).unwrap() + ); + let o = run_code(&mut s, &code, "net.jsx"); + assert_eq!(o.result, json!("false|true|false|true"), "{host:?}: {:?}", o.error); + } + for port in ["70000", "-1", "1.5"] { + let o = run_code(&mut s, &format!("var l = new Socket(); [l.listen({port}), l.error.length > 0].join('|')"), "net.jsx"); + assert_eq!(o.result, json!("false|true"), "{port}: {:?}", o.error); + } + // Never opened or closed: reads are empty, writes fail, close is idempotent. + let o = run_code( + &mut s, + "var s = new Socket(); var r = [s.read(), s.read(5), s.readln(), s.write('x'), s.connected, s.eof, s.poll(), s.close(), s.close()]; r.join('|')", + "net.jsx", + ); + assert_eq!(o.result, json!("|||false|false|true||true|true"), "{:?}", o.error); + // Binary bytes round trip in BINARY encoding; invalid UTF-8 is replaced in UTF-8; partial + // and zero-length reads; read stops at the timeout without data. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + use std::io::Write; + for _ in 0..2 { + let (mut c, _) = listener.accept().unwrap(); + let bytes: Vec = (0..=255u8).collect(); + c.write_all(&bytes).unwrap(); + } + }); + let code = format!( + "var s = new Socket(); s.open('127.0.0.1:{port}', 'BINARY'); var z = s.read(0); var a = s.read(10); var b = s.read(); s.close(); \ + var ok = true; for (var i = 0; i < 10; i++) ok = ok && a.charCodeAt(i) === i; for (var j = 0; j < 246; j++) ok = ok && b.charCodeAt(j) === j + 10; \ + [z.length, a.length, b.length, ok].join('|')" + ); + let o = run_code(&mut s, &code, "net.jsx"); + assert_eq!(o.result, json!("0|10|246|true"), "{:?}", o.error); + let code = format!( + "var s = new Socket(); s.open('127.0.0.1:{port}', 'UTF-8'); var d = s.read(); s.close(); [d.charCodeAt(0), d.charCodeAt(127), d.indexOf('\\ufffd') > 0].join('|')" + ); + let o = run_code(&mut s, &code, "net.jsx"); + assert_eq!(o.result, json!("0|127|true"), "{:?}", o.error); + server.join().unwrap(); + // A listener with nobody connecting: a short read timeout returns quickly. + let t = std::time::Instant::now(); + let o = run_code( + &mut s, + "var l = new Socket(); l.listen(0); l.timeout = 0; var c = new Socket(); c.timeout = 0.2; c.open(l.host); var x = c.read(); c.close(); l.close(); x.length", + "net.jsx", + ); + assert_eq!(o.result, json!(0), "{:?}", o.error); + assert!(t.elapsed().as_secs_f64() < 5.0); +} From 5db4bff4612433b805aa4435684f843567ed1557 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:51:22 -0400 Subject: [PATCH 10/10] M13.12: docs (pdf README, parity, plug-ins), EPS Type 3 fonts reported Co-Authored-By: Claude Opus 5.5 --- crates/pdf/README.md | 75 +++++++++++++++++++++++++++++++++----------- crates/pdf/src/ps.rs | 5 +++ docs/parity.md | 38 ++++++++++++++++++---- docs/plugins.md | 6 ++-- 4 files changed, 98 insertions(+), 26 deletions(-) diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 43452da9..b7678b09 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -12,17 +12,22 @@ fonts, images, patterns, transparency (blend modes, soft masks) and optional con **Technical Note #5176** (The Compact Font Format Specification) and **#5177** (The Type 2 Charstring Format) for CFF font programs; **Adobe Type 1 Font Format** (version 1.1) for Type 1 font programs; the **PostScript Language Reference, third edition** and the **Encapsulated -PostScript File Format Specification 3.0** for EPS. TrueType font programs are read through -skrifa and JPEG (DCT) images through zune-jpeg (both permissively licensed, pure Rust). No -third-party PDF/PostScript/font code was read; test files (including the CFF and Type 1 fonts) -are generated in code ([`write`](src/write.rs) and the tests). +PostScript File Format Specification 3.0** for EPS; **ITU-T T.4** (07/2003) and **ITU-T T.6** +(11/1988) for CCITT fax images. TrueType font programs are read through skrifa and JPEG (DCT) +images through zune-jpeg (both permissively licensed, pure Rust). No third-party +PDF/PostScript/font/codec code was read; test files (including the CFF and Type 1 fonts and the +CCITT data) are generated in code ([`write`](src/write.rs) and the tests). One CCITT fixture was +produced once by libtiff's `tiffcp` (an external encoder used only as a fixture generator, like +ffmpeg elsewhere) to check the code tables against an independent implementation. ## What is read - **Structure:** indirect objects found by scanning (damaged or incremental cross-reference sections do not matter), object streams, the page tree with inherited resources, MediaBox / CropBox and `/Rotate`. Filters: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85, - RunLength. Encrypted files are refused. + RunLength, CCITTFax (Group 3 one- and two-dimensional, Group 4; `K`, `Columns`, `Rows`, + `EndOfLine`, `EncodedByteAlign`, `EndOfBlock`, `BlackIs1`; damaged data keeps the rows + decoded before the damage). Encrypted files are refused. - **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`, `LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd), clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by @@ -39,7 +44,15 @@ are generated in code ([`write`](src/write.rs) and the tests). construction including `arc`/`arcn` and relative operators, `fill`/`eofill`/`stroke`, `clip`/`eoclip`, `rectfill`/`rectstroke`/`rectclip`, gray / RGB / CMYK / HSB colour. DOS EPS binary headers and `%%HiResBoundingBox` / `%%BoundingBox` are honoured; `%%BeginData` / - `%%BeginBinary` / preview sections are skipped. + `%%BeginBinary` / preview sections are skipped. **Text:** `findfont` / `findresource`, + `scalefont`, `makefont`, `selectfont`, `setfont`, `currentfont`, `definefont` (re-encoded + copies made with `forall` and a new `/Encoding`), `StandardEncoding` / `ISOLatin1Encoding`, + `show`, `ashow`, `widthshow`, `awidthshow`, `xshow` / `yshow` / `xyshow`, `kshow` / `cshow` + (the procedure is not run), `glyphshow`, `charpath` (glyph outlines join the path, e.g. for + clipping) and `stringwidth`. Fonts embedded in the file — Type 1 programs (`… currentfile + eexec … cleartomark`, binary or hexadecimal) and CFF font sets (`StartData`) — are found by + name before the program runs; other fonts are drawn with the bundled fonts as for PDF. Each + `show` becomes one compound shape named "Text: …". - **Text** (§9): text objects and state (`Tc`, `Tw`, `Tz`, `TL`, `Ts`, `Tr`, `Tf`, `Td`, `TD`, `Tm`, `T*`), `Tj`, `TJ` (with kerning), `'` and `"`; render modes fill / stroke / @@ -47,8 +60,9 @@ are generated in code ([`write`](src/write.rs) and the tests). become outlines (one compound shape per show operator, named "Text: …" after the string through `ToUnicode` or the glyph names). Fonts: simple fonts with `/Widths`, base encodings (Standard, WinAnsi, MacRoman), `/Differences` and built-in encodings; composite (Type 0) fonts - with Identity-H / Identity-V and embedded CMaps (`cidrange` / `cidchar`), `/W` and `/DW` - widths and `CIDToGIDMap`. Embedded programs: TrueType (`FontFile2`, cmap (3,1) / (3,0) / + with Identity-H / Identity-V and embedded CMaps (`cidrange` / `cidchar`, `/WMode`), `/W` and + `/DW` widths, vertical metrics (`/W2`, `/DW2`: the vertical displacement and the position + vector from the horizontal to the vertical origin) and `CIDToGIDMap`. Embedded programs: TrueType (`FontFile2`, cmap (3,1) / (3,0) / (1,0) and `post` names), CFF (`FontFile3` `/Type1C`, `/CIDFontType0C`, `/OpenType`; name-keyed and CID-keyed with FDArray / FDSelect, flex, hint masks, `seac`), Type 1 (`FontFile`, eexec, `/Subrs`, flex and hint replacement through othersubrs, `seac`) and Type 3 @@ -64,29 +78,54 @@ are generated in code ([`write`](src/write.rs) and the tests). of the render tree (bilinear, box-filtered when minified). - **Transparency:** blend modes (`/BM`, all 16 PDF modes) and soft masks in the graphics state (`/SMask` with `/S /Luminosity` and `/BC` backdrops, or `/S /Alpha`) as groups of the render - tree, open until the state changes or `Q`. + tree, open until the state changes or `Q`: what is painted while a soft mask is set is + rendered to an offscreen buffer and the mask applies to that combined result. Transparency + group XObjects (§11.6.6) composite as one object: the current alpha, blend mode and soft mask + apply to the group's result, and its contents start from the initial alpha. Isolated groups + (`/I true`) composite over a transparent backdrop; non-isolated groups over the page, with + the backdrop's contribution removed again (§11.4.8) so group alpha and masks apply only to + the group; knockout groups (`/K true`) composite each child with the group's initial + backdrop. Clipping groups are not transparency groups: blend modes inside them reach the + backdrop. - **Patterns:** tiling patterns (coloured and uncoloured, `XStep` / `YStep`, the cell clipped to `/BBox`, at most 4096 tiles per fill) for fills; strokes with a tiling pattern use its colour (uncoloured) or grey. -- **Functions:** sampled, exponential, stitching and PostScript calculator (type 4) functions. +- **Shadings** (§8.7.4.5): axial and radial shadings become gradients. Function-based shadings + (type 1, `Domain` and `Matrix`) and the mesh shadings — free-form (type 4, edge flags 0–2) + and lattice-form (type 5) Gouraud-shaded triangle meshes, Coons patch meshes (type 6) and + tensor-product patch meshes (type 7, flags 0–3 sharing the previous patch's edges) with + `BitsPerCoordinate` / `BitsPerComponent` / `BitsPerFlag` / `Decode` and an optional + parametric `Function` — are rasterised into an image node in page space (up to 4 pixels per + point, at most 1024 pixels a side, 2×2 supersampled): patches are tessellated into triangles + (up to 48×48 cells), colours (or `t`) interpolated across each triangle. They paint through + `sh` (their own extent only) and as shading-pattern fills (clipped to the path). +- **Functions:** sampled (one or several inputs, multilinear interpolation), exponential, + stitching and PostScript calculator (type 4) functions. - **Pages:** any page ([`parse_page`], [`page_count`]); File ▸ Import takes `page` (from 1) and the Import dialog asks for it; footage remembers its page (`Footage::page`). ## Limitations -- Mesh shadings (types 4–7) and function-based shadings (type 1), JPX / CCITT / JBIG2 images, - predefined CJK CMaps other than Identity, vertical metrics (`/W2`; vertical text advances by - the font size) and transfer functions (`/TR`) are not read; knockout and isolated transparency - groups composite as normal groups, and a soft mask applies to each painting operation while - it is set rather than to their composite. What a document skipped is listed in `Doc::skipped`. +- JPX and JBIG2 images, predefined CJK CMaps other than Identity-H / Identity-V, and transfer + functions (`/TR`) are not read. JBIG2 is deferred. Predefined CMaps (`UniGB-UCS2-H`, + `90ms-RKSJ-H` …) need the CMap data, and there is no permissively licensed pure-Rust source + of it (Adobe's CMap resources are Adobe assets, which this project does not ship); text in + such fonts is reported in `Doc::skipped`. Mesh shadings are raster images in the render tree + (sharp to about 4× the document size). Knockout uses each child's coverage as its shape (a + child with constant opacity knocks out fully where it covers). What a document skipped is + listed in `Doc::skipped`. - Illustrator EPS files whose drawing depends on Adobe's procedure-set resources (defined in the file through constructs beyond this subset) and Level 3 `shfill` gradients in EPS draw only what the subset understands; save such artwork as PDF-compatible `.ai` or PDF instead. - EPS text is not drawn. + EPS images are skipped; Type 3 (procedure) fonts defined in EPS files draw nothing (reported). - **Create Shapes from Vector Layer:** clips that enclose the whole page become layer masks (the first clip Add, later ones Intersect). Nested clipping groups become Merge Paths: each closed shape inside them is a group with its geometry merged into one compound path, the clip paths, and Merge Paths ▸ Intersect before its fill and stroke, so fills match exactly and strokes follow the clipped outline; open stroked paths inside clips stay unclipped. Blend - modes become the groups' Blend Mode; images and soft masks are not converted (shape layers - have no images; use the footage, or Import As: Composition). + modes become the groups' Blend Mode. Images become footage layers: each is written as + ` Image .png` next to the source and imported, placed by its matrix (anchor at its + top-left corner; position, rotation and scale — a skew is reported) and parented to the shape + layer; the shapes drawn between images go to shape layers of their own (` Outlines + `), so the layer stack keeps the document's paint order. Soft masks, and the clips and + masks of images, are not converted (the command's `skipped` list says so). diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 153956ed..22d2e231 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -1555,6 +1555,11 @@ impl Interp { /// The drawable font of a font dictionary (its base program and encoding) and the matrix /// from text space (font size 1) to user space. fn font_of(&mut self, d: &DictRef) -> Option<(Rc, Affine)> { + if matches!(d.borrow().get("FontType"), Some(V::Num(t)) if *t == 3.0) { + // Type 3 fonts (glyph procedures) are not run. + self.b.skip("EPS Type 3 font"); + return None; + } let (base, names) = { let m = d.borrow(); let base = m.get("__base").or_else(|| m.get("FontName")).map(V::key)?; diff --git a/docs/parity.md b/docs/parity.md index 089a72a1..4c9cf5f3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -35,8 +35,8 @@ Tracking 96%, Effects 85%, Web 65%. What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; -approximated effects (Key Cleaner) and Liquify's viewer brush; mesh shadings and JPX images in -imported PDF/AI files and Illustrator procset EPS; and more codecs. Face tracking and a true Subspace Warp +approximated effects (Key Cleaner) and Liquify's viewer brush; JPX / JBIG2 images in +imported PDF/AI files (mesh shadings and CCITT landed in M13.12) and Illustrator procset EPS; and more codecs. Face tracking and a true Subspace Warp landed in M13.3. ScriptUI (dialogs that block `show()`, palettes, dockable ScriptUI panels, File ▸ Scripts install and sample scripts), puppet pin recording and the remaining "better than After Effects" items — a versioned effect plug-in API with sandboxed WebAssembly plug-ins ([plugins.md](plugins.md)), @@ -128,12 +128,12 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit | | Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control. Left: Opus is CELT-only, Photoshop sequence output, overflow for movies only checks at file creation | | Audio | 85% | 0.5 | audio to keyframes | -| Import | 96% | 0.6 | mesh shadings (types 4–7), JPX / CCITT / JBIG2 images and non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) | +| Import | 97% | 0.5 | JPX / JBIG2 images and predefined non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.12: mesh shadings — free-form / lattice Gouraud triangles, Coons and tensor patches — and function-based shadings, CCITT G3 / G4 images, knockout / isolated transparency groups with group alpha and soft masks on the group's result, `/W2` vertical metrics, EPS text with embedded Type 1 / CFF or bundled fonts, and Create Shapes from Vector Layer keeping images as parented footage layers in paint order; M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) | | Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | | Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) | | Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' mirrored properties (CMP-7: Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | | Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | -| Text | ≈ 95% | 0.4 | no extruded strokes, the Variable Font Axes animator changes outlines but not advances (M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | +| Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | | 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | | Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | @@ -180,8 +180,8 @@ Tate-Chu-Yoko, forced LTR paragraphs, the editing caret on animated / path text style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and layer ends (ANM-4; the reference graph existed); viewer ROI resize handles, Pan Behind snapping and 3D Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry -taper/wave and the Variable Font Axes animator doesn't change advances (the Character panel's -axes do since M13.6; Advanced 3D's DOF has iris shapes since M13.8). +taper/wave (the Variable Font Axes animator changes advances since M13.12, the Character panel's +axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). ## Update: M13.8 Preview panel, Align panel, Advanced 3D completion @@ -241,6 +241,32 @@ menu entries. Not yet: creating a reference frame by editing a still (After Effe Photoshop), Content-Aware Fill's learned model (ours is PatchMatch + flow propagation), and Media Browser in the web app. +## Update: M13.12 import and text leftovers, test hardening + +- **PDF / AI**: mesh shadings — free-form and lattice-form Gouraud triangle meshes (types 4, 5), + Coons and tensor-product patch meshes (6, 7, tessellated to triangles) — and function-based + shadings (type 1, with multi-input sampled functions), for `sh` and shading-pattern fills, + rasterised into image nodes; CCITT Group 3 (1-D and 2-D) and Group 4 images from ITU-T T.4 / + T.6 (checked against an independent encoder's output); knockout and isolated transparency + groups, the current alpha / blend mode / soft mask applied to a transparency group XObject's + combined result, and blend modes inside clipping groups reaching the backdrop; `/W2` / `/DW2` + vertical metrics and CMap `WMode`. Not done: JBIG2 images (deferred) and predefined CJK CMaps (no permissively licensed + pure-Rust source of the CMap data; Adobe's CMap resources are Adobe assets). +- **EPS**: text through the PostScript interpreter — `findfont` / `scalefont` / `makefont` / + `selectfont` / `setfont`, `show`, `ashow`, `widthshow`, `awidthshow`, `xshow` / `yshow` / + `xyshow`, `kshow`, `glyphshow`, `charpath`, `stringwidth`, re-encoded fonts (`definefont` + copies with a new `/Encoding`) — with embedded Type 1 and CFF programs or the bundled fonts. +- **Create Shapes from Vector Layer**: images become PNG footage layers parented to the shape + layer, and the shapes between images are split into layers of their own so the stack keeps + the document's paint order; soft masks and clipped images are reported. +- **Text**: the Variable Font Axes animator re-spaces the text (advances at the animated + design-space position), not only the outlines. +- **Hardening**: PDF / EPS parsers survive truncated and corrupted files (fixed an + out-of-range stream slice and unbounded PostScript recursion); WebAssembly plug-ins get a + memory cap, a manifest length limit and slider schema checks, with tests for missing exports, + wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script + `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. + ## Highest-value gaps, in order 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10). diff --git a/docs/plugins.md b/docs/plugins.md index 5d5b39c0..47ce5caa 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -12,7 +12,7 @@ There are two ways to write one: |---|---|---| | What | a `.wasm` (or `.wat`) module | a type implementing `effectcraft_effects::plugin::EffectPlugin` | | Loaded by | Effect ▸ Load Effect Plug-in…, `effect.plugins.load {path \| folder}`, or the `Plug-ins` folder next to the settings at start-up | `register_plugin(Arc::new(MyEffect))` in an app that links EffectCraft's crates | -| Sandbox | yes: no imports (no files, clock or network), fuel-limited, deterministic floats | none (it is your code) | +| Sandbox | yes: no imports (no files, clock or network), fuel-limited, at most 1 GiB of memory, deterministic floats | none (it is your code) | | Platforms | desktop and CLI (the wasmi interpreter, crate feature `effectcraft-plugin/wasm`); not the web build | everywhere | `effect.plugins.list` lists what is loaded: `{api, wasm, plugins: [{id, name, category, version, @@ -60,7 +60,9 @@ Every plug-in describes itself with a JSON manifest: * Parameter types: `slider` (`default`, `min`, `max`, optional `sliderMin`, `sliderMax`, `decimals`), `angle` (`default` degrees), `checkbox` (`default` bool), `popup` (`options`, `default` index), `point` (`default` as fractions of the layer size, `[0.5, 0.5]` = centre), - `color` (`default` RGBA 0–1). Parameter ids are letters, digits and `_`. + `color` (`default` RGBA 0–1). Parameter ids are letters, digits and `_`, unique; at most 256 + parameters; a slider needs `min ≤ default ≤ max` (and `sliderMin ≤ sliderMax`); the manifest + is at most 1 MiB. ## WebAssembly ABI (v1)