Merge branch 'worktree-agent-a75983ccef1ea55ee'

This commit is contained in:
Brandon Thomas 2026-10-03 10:18:49 -04:00
commit 95766755f3
33 changed files with 3766 additions and 153 deletions

View File

@ -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(())
}

View File

@ -36,14 +36,56 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
Ok(json!({"layers": created, "skipped": skipped}))
}
/// An image of a vector file written as `<file> Image <n>.png` next to it and imported: the
/// footage item.
fn image_footage(s: &mut Session, source: &str, n: usize, img: &crate::vector::VectorImage) -> Result<effectcraft_project::ItemId> {
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(

View File

@ -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::<f64>()
};
// 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<f64> = 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;

View File

@ -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<PropGroup> {
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
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<Vec<u8>>,
/// 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<VectorImage>, usize) {
fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec<VectorImage>, 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<Doc> {
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<String>) {
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`]).

View File

@ -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<String> = 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);
}

View File

@ -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<u8> {
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<u8> = (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:?}");
}
}

View File

@ -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
`<file> Image <n>.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 (`<name> Outlines
<n>`), 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).

View File

@ -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));
}

798
crates/pdf/src/ccitt.rs Normal file
View File

@ -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<u32> {
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<Mode> {
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<Vec<usize>> {
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<Vec<usize>, bool> {
let (wt, bt) = tables();
let mut changes: Vec<usize> = 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<u8>) {
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<Vec<u8>> {
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<usize> = vec![];
let mut rows = 0;
while rows < max_rows && !r.eof() {
let line: Option<Vec<usize>> = 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<u8>,
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<usize> {
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<bool>], k: i64) -> Vec<u8> {
let mut w = W::default();
let mut refl: Vec<usize> = 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<Vec<bool>> {
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<Vec<bool>> {
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<u8> {
(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() });
}
}
}

View File

@ -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<String> {
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<Cff> {
let d = data;

View File

@ -167,6 +167,16 @@ pub enum Func {
decode: Vec<f64>,
samples: Vec<f64>,
},
/// Type 0 with several inputs (function-based shadings): multilinear interpolation, the
/// first input varying fastest in the sample table.
SampledN {
domain: Vec<f64>,
size: Vec<usize>,
outputs: usize,
encode: Vec<f64>,
decode: Vec<f64>,
samples: Vec<f64>,
},
/// An array of one-output functions.
Array(Vec<Func>),
/// 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<usize> = 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<f64> {
@ -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],
}
}

View File

@ -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<u32, (f64, f64, f64)>,
/// `/DW2`: default (vy, w1y).
dw2: (f64, f64),
cache: RefCell<HashMap<u32, Option<Arc<BezPath>>>>,
/// 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<u32, String>,
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();
@ -297,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<String>) -> 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,
@ -315,10 +327,48 @@ 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(),
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<Vec<Option<String>>>) -> 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<String>) -> 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)
{
@ -366,6 +416,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<f64> = 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 +459,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 +612,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 {

View File

@ -100,7 +100,7 @@ fn samples(file: &File, d: &Dict, raw: &[u8]) -> Result<(Vec<u8>, Option<usize>)
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()),
}
}

View File

@ -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;
@ -16,6 +17,7 @@ mod image;
mod object;
mod page;
mod ps;
mod shading;
mod type1;
pub mod write;
@ -192,3 +194,9 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_groups;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_shading;

View File

@ -328,6 +328,7 @@ pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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 {

View File

@ -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<Tiling>),
Shading(Rc<ShadingFill>),
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();
@ -876,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);
@ -980,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());
@ -993,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));
}

View File

@ -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<RefCell<HashMap<String, V>>>;
@ -79,6 +82,8 @@ struct GState {
cur: Option<Point>,
start: Option<Point>,
depth: usize,
/// The current font dictionary.
font: Option<DictRef>,
}
/// Source tokenizer (the data source for `currentfile`).
@ -309,6 +314,14 @@ pub(crate) struct Interp {
gs: GState,
saved: Vec<GState>,
budget: usize,
/// Nested procedure calls (recursion is cut off at [`MAX_CALL_DEPTH`]).
calls: usize,
/// Fonts by name: `definefont`d dictionaries.
font_dir: HashMap<String, DictRef>,
/// Embedded font programs found in the file (name → (program, CFF)).
embedded: HashMap<String, (Rc<Vec<u8>>, bool)>,
/// Fonts built for drawing, by base font and encoding.
font_cache: HashMap<String, Rc<Font>>,
page: [f64; 4],
pub unknown: Vec<String>,
}
@ -330,6 +343,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 {
@ -348,15 +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) {
@ -396,13 +418,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 => {
@ -906,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" => {
@ -1337,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;
@ -1460,3 +1457,369 @@ 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<usize> {
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from)
}
fn rfind_before(hay: &[u8], needle: &[u8], to: usize) -> Option<usize> {
hay.get(..to)?.windows(needle.len()).rposition(|w| w == needle)
}
/// The name after `key` (`/FontName /Name`).
fn name_after(b: &[u8], key: &[u8]) -> Option<String> {
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 (`… <length> StartData <bytes>`).
fn embedded_fonts(ps: &[u8]) -> HashMap<String, (Rc<Vec<u8>>, 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::<usize>().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<String, V> = 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<Font>, 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)?;
let names: Option<Vec<Option<String>>> = 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::<Vec<_>>().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<u8> {
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<f64> = 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<V> = (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)
});
}
}

580
crates/pdf/src/shading.rs Normal file
View File

@ -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<u64> {
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<f64>,
}
impl Layout {
fn read(file: &File, sh: &Dict, n: usize) -> Option<Layout> {
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<Point> {
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<Vec<f64>> {
(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<String>) -> Option<SvgImage> {
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<V> = 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<V>> = 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<u8>, 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<SvgImage> {
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<String>) -> Option<SvgImage> {
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]);
}
}

View File

@ -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));
@ -152,11 +153,11 @@ fn filters_decode() {
// ------------------------------------------------------------------ M13.6: text, images,
// patterns, soft masks, blend modes, pages
type Objs = Vec<(u32, String, Option<Vec<u8>>)>;
pub(crate) type Objs = Vec<(u32, String, Option<Vec<u8>>)>;
/// 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<u8> {
pub(crate) fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec<u8> {
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 +168,12 @@ fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec<u8> {
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 +183,7 @@ fn coverage(img: &effectcraft_raster::Image, x0: i64, y0: i64, x1: i64, y1: i64)
s
}
fn shape_names(doc: &Doc) -> Vec<String> {
pub(crate) fn shape_names(doc: &Doc) -> Vec<String> {
fn walk(g: &effectcraft_svg::Group, out: &mut Vec<String>) {
for c in &g.children {
match c {
@ -223,7 +224,7 @@ fn square_cff() -> Vec<u8> {
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),

View File

@ -0,0 +1,153 @@
//! 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:?}");
}
/// 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:?}");
}

View File

@ -0,0 +1,297 @@
//! M13.12: CCITT images in PDF pages, EPS text and parser robustness over truncated and
//! corrupt inputs (generated fixtures).
use super::*;
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<bool>], 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<Vec<bool>> = (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<Vec<u8>> {
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<Vec<bool>> = (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"<abc",
b"100 100 scale 0 0 moveto 1e308 1e308 lineto stroke",
] {
let mut b = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n".to_vec();
b.extend_from_slice(prog);
let r = std::panic::catch_unwind(|| {
if let Ok(doc) = parse(&b) {
let _ = effectcraft_svg::rasterize(&doc, 10, 10, 1.0);
}
});
assert!(r.is_ok(), "{}", String::from_utf8_lossy(prog));
}
}

View File

@ -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<u8>);
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<u8>) -> 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<u8>>(), 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);
}

View File

@ -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<String> = (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: &params, 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<u32> { (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<Vec<u32>> = 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);
}

View File

@ -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<StoreLimits>,
instance: Instance,
memory: Memory,
alloc: TypedFunc<i32, i32>,
@ -37,9 +41,10 @@ fn err(e: impl std::fmt::Display) -> String {
}
fn instantiate(engine: &Engine, module: &Module) -> Result<Inst, String> {
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::<StoreLimits>::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::<i32, i32>(&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()?;

View File

@ -568,8 +568,17 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
(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<f64> = (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<TextGeom> {
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<TextGeom> {
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<TextGeom> {
while j < n && lay.glyphs[j].line_index == line {
j += 1;
}
let xs: Vec<f64> = (i..j).map(|gi| origin(gi)[0] + lay.glyphs[gi].advance / 2.0).collect();
let xs: Vec<f64> = (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<TextGeom> {
}
// 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),
},
};

View File

@ -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) === "]") {

View File

@ -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);

View File

@ -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<serde_json::Value, String> {
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<u8> = (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);
}

View File

@ -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<Box<SoftMask>>,
/// 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<Doc, Error> {
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),
})
}

View File

@ -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<f32> {
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] {

View File

@ -123,6 +123,37 @@ pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<Be
Some(g.outline_xf * (*units).clone())
}
/// A glyph's advance in font units at the given axis values (user units, unspecified axes at
/// their defaults).
pub fn advance_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Option<f32> {
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<f64> {
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<FontAxis>)> {

View File

@ -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 codec depth (B-frames, SAO/CDEF; HEVC and AV1 export and Opus SILK/hybrid landed in M13.4). 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 codec depth (B-frames, SAO/CDEF; HEVC and AV1 export and Opus SILK/hybrid landed in M13.4). 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; M13.4: HEVC export (MP4 `hvc1`, `effectcraft-hevcenc`: Main / Main 10, IDR + P slices, quarter-pel motion, deblocking, bit-exact with ffmpeg) and AV1 export (MP4 `av01` and WebM, `effectcraft-av1enc`: 8/10-bit key + inter frames, quarter-pel motion, loop filter, bit-exact with libdav1d) with profile, level, bitrate / constant-quality and key-frame interval options; Opus SILK (NB/MB/WB, mid/side stereo) and hybrid (SWB/FB) modes chosen by bitrate and Audio/Voice tuning, with the Opus bitrate in the Output Module. HEVC/AV1 import decodes through FilmCraft (MP4/MOV/MKV/WebM). Left: HEVC/AV1 have no B-frames, multi-reference, SAO/CDEF/restoration or alpha (compression below mature encoders); Opus has no FEC/DTX and only 20 ms frames; 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 | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) |
| 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 | ≈ 85% | 3.0 | GPU versions of the remaining effects (152 run on the GPU since M13.9, EFF-5: keying, mattes, channel, colour correction, stylize, distort, noise, blurs and time effects joined the 58 of M12.7; Warp, Bezier Warp, Reshape, Smear, Cartoon, CC Page Turn and others still render on the CPU) 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 (landed in M13.10: browser storage and File System Access folders).
## 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).

View File

@ -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)