From da0ec526f3736c895d4e2cfdf261d314bcf33859 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:12:21 -0400 Subject: [PATCH] M13.2: PDF / Illustrator (.ai) / EPS vector footage (effectcraft-pdf): continuous rasterisation, layered composition import, Create Shapes from Vector Layer; SVG render tree clip groups Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 + Cargo.toml | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/commands/create.rs | 8 +- crates/engine/src/commands/file.rs | 33 +- crates/engine/src/psd_import.rs | 2 +- crates/engine/src/vector.rs | 73 +- crates/host/Cargo.toml | 1 + crates/host/tests/vector_import.rs | 113 ++ crates/media/Cargo.toml | 1 + crates/media/src/layered.rs | 46 +- crates/media/src/lib.rs | 3 +- crates/media/src/pool.rs | 2 +- crates/pdf/Cargo.toml | 15 + crates/pdf/README.md | 48 + crates/pdf/src/build.rs | 93 ++ crates/pdf/src/color.rs | 288 +++++ crates/pdf/src/lib.rs | 185 ++++ crates/pdf/src/object.rs | 708 +++++++++++++ crates/pdf/src/page.rs | 573 ++++++++++ crates/pdf/src/ps.rs | 1462 ++++++++++++++++++++++++++ crates/pdf/src/tests.rs | 150 +++ crates/pdf/src/write.rs | 32 + crates/project/src/lib.rs | 11 +- crates/render/src/lib.rs | 2 +- crates/svg/src/lib.rs | 15 +- crates/svg/src/raster.rs | 21 + crates/ui-egui/src/menus.rs | 2 +- xtask/src/main.rs | 2 + 29 files changed, 3875 insertions(+), 28 deletions(-) create mode 100644 crates/pdf/Cargo.toml create mode 100644 crates/pdf/README.md create mode 100644 crates/pdf/src/build.rs create mode 100644 crates/pdf/src/color.rs create mode 100644 crates/pdf/src/lib.rs create mode 100644 crates/pdf/src/object.rs create mode 100644 crates/pdf/src/page.rs create mode 100644 crates/pdf/src/ps.rs create mode 100644 crates/pdf/src/tests.rs create mode 100644 crates/pdf/src/write.rs diff --git a/Cargo.lock b/Cargo.lock index c7934100..87cd95d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1213,6 +1213,7 @@ dependencies = [ "effectcraft-lottie", "effectcraft-model", "effectcraft-path", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1309,6 +1310,7 @@ dependencies = [ "effectcraft-export", "effectcraft-expr", "effectcraft-media", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1352,6 +1354,7 @@ name = "effectcraft-media" version = "0.1.0" dependencies = [ "effectcraft-model", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1398,6 +1401,15 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "effectcraft-pdf" +version = "0.1.0" +dependencies = [ + "effectcraft-svg", + "kurbo", + "miniz_oxide 0.8.9", +] + [[package]] name = "effectcraft-project" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 22709f60..cda7c302 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ effectcraft-media = { path = "crates/media" } effectcraft-export = { path = "crates/export" } effectcraft-expr = { path = "crates/expr" } effectcraft-lottie = { path = "crates/lottie" } +effectcraft-pdf = { path = "crates/pdf" } effectcraft-psd = { path = "crates/psd" } effectcraft-vp9enc = { path = "crates/vp9enc" } effectcraft-svg = { path = "crates/svg" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 45508a71..7a7adb30 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -26,6 +26,7 @@ effectcraft-lottie = { workspace = true } effectcraft-psd = { workspace = true } kurbo = { workspace = true } effectcraft-svg = { workspace = true } +effectcraft-pdf = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/commands/create.rs index 72cb2420..a1d22fb5 100644 --- a/crates/engine/src/commands/create.rs +++ b/crates/engine/src/commands/create.rs @@ -28,12 +28,14 @@ fn create(s: &mut Session, p: &Value) -> Result { }); match op { "shapesFromVector" => { - let Some(f) = footage.filter(|f| f.codec == "SVG" || f.path.to_ascii_lowercase().ends_with(".svg")) else { - skipped.push(format!("{}: not a vector (SVG) footage layer", src.name)); + let Some(f) = footage.filter(crate::vector::is_vector) else { + skipped.push(format!("{}: not a vector (SVG, PDF, AI or EPS) footage layer", src.name)); continue; }; let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?; - let doc = effectcraft_svg::parse(&bytes).map_err(|e| EngineError::Other(format!("{}: {e}", f.path)))?; + let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref()) + .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; diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 5a69a213..661169e0 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -101,6 +101,17 @@ fn import(s: &mut Session, p: &Value) -> Result { for path in paths { let bytes = match s.services.read_file(&path) { Ok(b) if effectcraft_psd::is_psd(&b) => b, + Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => { + // PDF / Illustrator / EPS: one layer per file layer. + match import_vector_comp(s, &path, &b) { + Ok((comp, items)) => { + out_comps.push(comp); + ids.extend(items); + } + Err(e) => errors.push(e.to_string()), + } + continue; + } _ => { rest.push(path); continue; @@ -153,7 +164,9 @@ fn import(s: &mut Session, p: &Value) -> Result { && f.codec == "PSD" { match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) { - Some((index, name)) => f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false }), + Some((index, name)) => { + f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None }) + } None => { errors.push(format!("{path}: no layer {sel}")); continue; @@ -217,6 +230,22 @@ fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, retain: bool) -> Ok((r.comp.0, items, r.warnings)) } +/// Import a PDF / Illustrator / EPS file as a composition (one undo step). Returns (comp, items). +fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8]) -> Result<(u64, Vec)> { + let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into()); + let rate = effectcraft_time::FrameRate::FPS_29_97; + let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 }; + let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs)); + let (comp, folder, items) = s.edit("Import", None, |proj, st| { + let r = crate::vector::import_vector_comp(proj, path, bytes, &name, rate, duration).map_err(EngineError::Other)?; + st.project_selection = vec![r.0]; + Ok(r) + })?; + let mut out = vec![folder.0]; + out.extend(items.iter().map(|i| i.0)); + Ok((comp.0, out)) +} + /// Extensions imported as data footage. pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"]; @@ -381,7 +410,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer)}", + "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer)}", always, import ), diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index f64fd3af..2f012eee 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -156,7 +156,7 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> ( missing: false, sequence: vec![], color_profile: None, - layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty() }), + layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None }), ..Default::default() }; let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index a3f3e040..3465d385 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -1,5 +1,6 @@ -//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG footage → -//! shape layer), Create Shapes from Text and Create Masks from Text (glyph outlines). +//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG, PDF, +//! Illustrator and EPS footage → shape layer), Create Shapes from Text and Create Masks from +//! Text (glyph outlines), and vector files imported as compositions (one layer per file layer). use effectcraft_keyframe::{Gradient, ShapePath, Value}; use effectcraft_project::build::{self, Ids}; @@ -149,6 +150,74 @@ pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec { if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items } } +/// Whether footage is a vector file (SVG / PDF / AI / EPS). +pub fn is_vector(f: &effectcraft_project::Footage) -> bool { + effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg") +} + +/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted +/// to one layer when `layer` names it. +pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option> { + if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) { + return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); + } + effectcraft_pdf::sniff(bytes)?; + Some( + effectcraft_pdf::parse(bytes) + .map(|d| match layer { + Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + None => d, + }) + .map_err(|e| format!("{path}: {e}")), + ) +} + +/// Import a PDF / Illustrator / EPS document as a composition the size of its page: one footage +/// item per file layer (each showing only that layer, continuously rasterisable), top layer +/// first. Returns (comp, folder, items). +pub fn import_vector_comp( + proj: &mut Project, + path: &str, + bytes: &[u8], + name: &str, + rate: effectcraft_time::FrameRate, + duration: effectcraft_time::Tick, +) -> Result<(ItemId, ItemId, Vec), String> { + use effectcraft_color::Label; + use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer}; + let doc = effectcraft_pdf::parse(bytes).map_err(|e| format!("{path}: {e}"))?; + let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF"); + let (w, h) = doc.pixel_size(); + let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder); + let mut comp = Comp::new(w, h, rate, duration); + let mut items = vec![]; + let names = effectcraft_pdf::layer_names(&doc); + for (i, lname) in names.iter().enumerate() { + let f = Footage { + path: path.to_string(), + kind: FootageKind::Still, + width: w, + height: h, + pixel_aspect: 1.0, + frame_rate: rate, + has_video: true, + alpha: AlphaMode::Straight, + loop_count: 1, + codec: codec.into(), + layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }), + ..Default::default() + }; + let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f)); + items.push(id); + let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None); + l.name = comp.unique_layer_name(lname); + comp.layers.insert(0, l); + } + let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(std::sync::Arc::new(comp))); + Ok((cid, folder, items)) +} + /// A copy of `src`'s transform group with fresh uids. fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) { let Some(tr) = src.props.sub("transform") else { return }; diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 635e3260..564efb3b 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -21,5 +21,6 @@ effectcraft-export = { workspace = true } serde_json = { workspace = true } effectcraft-psd = { workspace = true } effectcraft-svg = { workspace = true } +effectcraft-pdf = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-time = { workspace = true } diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 590112c9..7b516a2a 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -184,3 +184,116 @@ fn svg_footage_continuously_rasterizes() { assert!(sharp < 0.004, "continuously rasterised diff {sharp}"); assert!(sharp < soft, "sharper than the upscaled pixels ({sharp} vs {soft})"); } + +/// A two-layer Illustrator-style PDF (optional-content layers "Sky" and "Art"): a gradient sky, +/// a red curved shape with a dark outline, and a clipped green bar. +fn layered_pdf() -> Vec { + let content = "/OC /L0 BDC\n/Pattern cs /P0 scn 0 0 120 80 re f\nEMC\n\ +/OC /L1 BDC\n1 0 0 rg 0.1 0.1 0.1 RG 3 w 20 20 m 20 60 l 60 70 100 60 v 100 20 l h B\n\ +q 30 0 40 80 re W n 0 0.8 0 rg 0 35 120 10 re f Q\nEMC\n"; + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>"; + let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 0 80] /Function << /FunctionType 2 /Domain [0 1] /C0 [0.9 0.9 1] /C1 [0.2 0.4 0.9] /N 1 >> /Extend [true true] >> >>"; + 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, page.into(), None), + (4, "<< >>".into(), Some(content.as_bytes().to_vec())), + (5, "<< /Type /OCG /Name (Sky) >>".into(), None), + (6, "<< /Type /OCG /Name (Art) >>".into(), None), + (7, pattern.into(), None), + ], + 1, + ) +} + +#[test] +fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() { + let bytes = layered_pdf(); + let doc = effectcraft_pdf::parse(&bytes).unwrap(); + let (w, h) = doc.pixel_size(); + assert_eq!((w, h), (120, 80)); + let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0); + for name in ["art.pdf", "art.ai"] { + let path = tmp(name); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let item = r["items"][0].as_u64().unwrap(); + let effectcraft_project::ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!() }; + assert_eq!(f.codec, if name.ends_with(".ai") { "AI" } else { "PDF" }); + assert_eq!((f.width, f.height), (120, 80)); + s.execute("comp.new", json!({"name": "V", "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, "{name}: footage = rasterised page"); + // Continuously Rasterize at 300 %. + for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] { + s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap(); + } + let want = effectcraft_svg::rasterize(&doc, w, h, 3.0); + let soft = mean_diff(&s.render(cid, t, RenderOpts::default()), &want); + s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap(); + let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want); + assert!(sharp < 0.004 && sharp < soft, "{name}: continuously rasterised {sharp} vs {soft}"); + s.execute("prop.set", json!({"layer": lid, "path": "transform/scale", "value": [100, 100]})).unwrap(); + // Create Shapes from Vector Layer. + s.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap(); + let sid = r["layers"][0].as_u64().unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!(c.layers[0].id.0, sid); + let shapes = s.render(cid, t, RenderOpts::default()); + // Clipping groups are not shape-layer features: the clipped bar (rows 33–47) draws in + // full; everything else matches. + let rows = |img: &Image| Image { + width: w, + height: h - 15, + data: img.data.iter().enumerate().filter(|(i, _)| !(33..48).contains(&(*i as u32 / w))).map(|(_, p)| *p).collect(), + }; + let d = mean_diff(&rows(&shapes), &rows(&direct)); + assert!(d < 0.01, "{name}: shapes vs page mean diff {d}"); + let fill = shapes.get(60, 30); + assert!(fill[0] > 0.9 && fill[1] < 0.1, "red shape {fill:?}"); + } + // Import As: Composition — one layer per file layer, top first, together like the page. + let path = tmp("layers.ai"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["Art", "Sky"]); + assert_eq!((comp.width, comp.height), (120, 80)); + let art = comp.layers[0].id.0; + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + let d = mean_diff(&img, &direct); + assert!(d < 1.0 / 255.0, "layered comp vs page {d}"); + // Each layer's footage shows only its layer. + s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap(); + let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + assert_eq!(sky.get(5, 5), img.get(5, 5)); + let p = sky.get(60, 30); + assert!(p[2] > p[0], "only sky under the art {p:?}"); +} + +#[test] +fn eps_footage_imports() { + let eps = + b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n1 0 0 setrgbcolor 0 0 20 20 rectfill 0 0 1 setrgbcolor newpath 30 10 8 0 360 arc fill\n%%EOF\n"; + let path = tmp("shape.eps"); + std::fs::write(&path, eps).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let item = r["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "E", "width": 40, "height": 20, "frameRate": 30, "duration": 1})).unwrap(); + s.execute_checked("layer.addItem", json!({"item": item})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + assert!(img.get(10, 10)[0] > 0.99 && img.get(30, 10)[2] > 0.99, "{:?} {:?}", img.get(10, 10), img.get(30, 10)); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 10998031..3db4b1a9 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -16,6 +16,7 @@ effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } effectcraft-psd = { workspace = true } effectcraft-svg = { workspace = true } +effectcraft-pdf = { workspace = true } effectcraft-model = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 204312a5..111140a4 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -1,5 +1,7 @@ //! Layered and vector stills: Photoshop documents (`effectcraft-psd`: the merged image, or one -//! layer for footage imported as a composition) and SVG (`effectcraft-svg`, rasterised). +//! layer for footage imported as a composition) and vector files rasterised at any scale: SVG +//! (`effectcraft-svg`), PDF / Illustrator / EPS (`effectcraft-pdf`; one layer of the file for +//! footage imported as a composition). use effectcraft_project::{AlphaMode, Footage}; use effectcraft_raster::Image; @@ -11,7 +13,29 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool { path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) } -/// Footage for a Photoshop or SVG file (`None` for other formats). +/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted +/// to the footage's layer when it names one. +pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option> { + if is_svg(path, bytes) { + return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); + } + effectcraft_pdf::sniff(bytes)?; + Some( + effectcraft_pdf::parse(bytes) + .map(|d| match layer { + Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + None => d, + }) + .map_err(|e| format!("{path}: {e}")), + ) +} + +fn is_pdf_like(path: &str, bytes: &[u8]) -> bool { + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF")) +} + +/// Footage for a Photoshop or vector file (`None` for other formats). pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { if effectcraft_psd::is_psd(bytes) { let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; @@ -28,6 +52,14 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { f.alpha = AlphaMode::Straight; return Ok(Some(f)); } + if is_pdf_like(path, bytes) { + let doc = effectcraft_pdf::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + let (w, h) = doc.pixel_size(); + let mut f = crate::probe::still_footage(path, w, h, image::ImageFormat::Png, true); + f.codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF").into(); + f.alpha = AlphaMode::Straight; + return Ok(Some(f)); + } Ok(None) } @@ -42,8 +74,8 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - .map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); } - if is_svg(path, bytes) { - let doc = effectcraft_svg::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + if is_svg(path, bytes) || is_pdf_like(path, bytes) { + let doc = vector_doc(path, bytes, footage.layer.as_ref()).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; let (w, h) = doc.pixel_size(); let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); // The rasteriser produces premultiplied pixels already. @@ -53,9 +85,9 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - Ok(None) } -/// An SVG rasterised at `scale` × its pixel size (Continuously Rasterize). -pub(crate) fn rasterize_svg(bytes: &[u8], scale: f64) -> Option { - let doc = effectcraft_svg::parse(bytes).ok()?; +/// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize). +pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, scale: f64) -> Option { + let doc = vector_doc(path, bytes, layer)?.ok()?; let (w, h) = doc.pixel_size(); let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384)); Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 6c67f50f..5de2eb8c 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -16,6 +16,7 @@ mod convert; pub mod exr_channels; mod layered; +pub use layered::vector_doc; mod pool; mod probe; @@ -47,7 +48,7 @@ impl From for MediaError { } /// File extensions recognised as stills. -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg"]; +pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; /// File extensions recognised as movies. diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9bdefcb6..ed6521c9 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -516,7 +516,7 @@ impl FootageSource for MediaPool { return None; } let bytes = self.inner.read(&footage.path).ok()?; - crate::layered::rasterize_svg(&bytes, scale).map(Arc::new) + crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), scale).map(Arc::new) } fn aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option> { diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml new file mode 100644 index 00000000..93d082b5 --- /dev/null +++ b/crates/pdf/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "effectcraft-pdf" +description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages and EPS (a PostScript subset) parsed into the SVG render tree (paths, fills, strokes, axial / radial shadings, clipping, optional-content layers)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +effectcraft-svg = { workspace = true } +kurbo = { workspace = true } +miniz_oxide = "0.8" diff --git a/crates/pdf/README.md b/crates/pdf/README.md new file mode 100644 index 00000000..a1a9d0b3 --- /dev/null +++ b/crates/pdf/README.md @@ -0,0 +1,48 @@ +# effectcraft-pdf + +Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, which are PDF +documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG +(`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape +layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one +layer per file layer (File ▸ Import ▸ Composition). + +Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file +structure, filters, content streams, graphics state, colour spaces, functions, shadings and +optional content; the **PostScript Language Reference, third edition** and the **Encapsulated +PostScript File Format Specification 3.0** for EPS. No third-party PDF/PostScript code was read; +test files are generated in code ([`write`](src/write.rs) and the tests). + +## 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. +- **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 + component count), CalGray / CalRGB, Lab, Indexed, Separation and DeviceN (tint transforms of + function types 0, 2 and 3), axial and radial shadings as pattern fills and through `sh`, form + XObjects (matrix, bounding-box clip, nested resources). +- **Layers:** top-level optional-content marked sequences (`/OC … BDC`, Illustrator's layers) + become the document's layers, named after their OCG; content outside them is gathered into + layers "Layer N". +- **EPS:** a PostScript interpreter for the subset exported vector EPS uses: operand and + dictionary stacks, procedures and `def`/`bind`/`load`/`where`, arithmetic, comparisons, + `if`/`ifelse`/`for`/`repeat`/`loop`/`forall`, arrays and strings, `gsave`/`grestore`, + `save`/`restore`, matrices (`concat`, `translate`, `scale`, `rotate`, `transform` …), path + 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. + +## Limitations + +- Text is not drawn (embedded fonts are not read); images, tiling patterns, mesh shadings + (types 4–7), PostScript calculator functions, soft masks and blend modes are skipped. 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. +- Shape-layer conversion has no clipping: clipped artwork converts unclipped. +- Only the first page of a multi-page PDF is imported. diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs new file mode 100644 index 00000000..ca063dee --- /dev/null +++ b/crates/pdf/src/build.rs @@ -0,0 +1,93 @@ +//! The output side shared by the PDF and PostScript interpreters: an open-group stack (clipping +//! groups, optional-content layers) that collects shapes into the SVG render tree. + +use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Plain, + Clip, + Layer, +} + +pub(crate) struct Builder { + stack: Vec<(Group, Kind)>, + /// Which of the finished top-level nodes are optional-content layers. + pub top_layers: Vec, + pub skipped: Vec, + pub shapes: usize, +} + +pub(crate) fn group(name: &str) -> Group { + Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![] } +} + +impl Builder { + pub fn new() -> Builder { + Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0 } + } + + pub fn depth(&self) -> usize { + self.stack.len() + } + + pub fn skip(&mut self, what: &str) { + if !self.skipped.iter().any(|s| s == what) { + self.skipped.push(what.to_string()); + } + } + + fn add(&mut self, n: Node, layer: bool) { + if self.stack.len() == 1 { + self.top_layers.push(layer); + } + if let Some((g, _)) = self.stack.last_mut() { + g.children.push(n); + } + } + + /// Close every group opened above `depth`. + pub fn close_to(&mut self, depth: usize) { + while self.stack.len() > depth.max(1) { + let Some((g, k)) = self.stack.pop() else { break }; + // Empty clipping groups draw nothing. + if g.children.is_empty() { + continue; + } + self.add(Node::Group(g), k == Kind::Layer); + } + } + + /// Children drawn from now on are clipped by `path` (page space) until the group closes. + pub fn clip(&mut self, path: BezPath, rule: FillRule) { + let mut g = group("Clip Group"); + g.clip.push((path, rule)); + self.stack.push((g, Kind::Clip)); + } + + /// Open an optional-content layer (or a named group). + pub fn open(&mut self, name: &str, layer: bool) { + self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain })); + } + + pub fn fill_stroke(&mut self, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option) { + if path.elements().is_empty() || (fill.is_none() && stroke.is_none()) { + return; + } + self.shapes += 1; + let (f, fo, rule) = match fill { + Some((p, o, r)) => (Some(p), o, r), + None => (None, 1.0, FillRule::NonZero), + }; + let s = Shape { name: "Path".into(), geom: Geom::Path(path), transform, fill: f, fill_opacity: fo, fill_rule: rule, stroke, opacity: 1.0 }; + self.add(Node::Shape(s), false); + } + + /// The finished top-level nodes and their layer flags. + pub fn finish(mut self) -> (Vec, Vec, Vec) { + self.close_to(1); + let root = self.stack.pop().map(|(g, _)| g.children).unwrap_or_default(); + let flags = self.top_layers; + (root, flags, self.skipped) + } +} diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs new file mode 100644 index 00000000..b35e9f39 --- /dev/null +++ b/crates/pdf/src/color.rs @@ -0,0 +1,288 @@ +//! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled, +//! exponential and stitching; PostScript calculator functions are not evaluated). + +use crate::object::{Dict, File, Obj, decode_stream}; + +#[derive(Clone, Debug, PartialEq)] +pub enum Cs { + Gray, + Rgb, + Cmyk, + /// CIE L*a*b* (approximated). + Lab, + Indexed { + base: Box, + hival: usize, + lookup: Vec, + }, + /// Separation / DeviceN: components → alternate space through a tint transform. + Tint { + n: usize, + alt: Box, + func: Func, + }, + Pattern, +} + +impl Cs { + pub fn components(&self) -> usize { + match self { + Cs::Gray | Cs::Indexed { .. } => 1, + Cs::Rgb | Cs::Lab => 3, + Cs::Cmyk => 4, + Cs::Tint { n, .. } => *n, + Cs::Pattern => 0, + } + } + + /// The initial colour of the space (black, or tint 1). + pub fn initial(&self) -> Vec { + match self { + Cs::Cmyk => vec![0.0, 0.0, 0.0, 1.0], + Cs::Tint { n, .. } => vec![1.0; *n], + Cs::Lab => vec![0.0, 0.0, 0.0], + c => vec![0.0; c.components()], + } + } + + pub fn to_rgb(&self, c: &[f64]) -> [f64; 3] { + let g = |i: usize| c.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0); + match self { + Cs::Gray => [g(0); 3], + Cs::Rgb | Cs::Pattern => [g(0), g(1), g(2)], + Cs::Cmyk => { + let k = g(3); + [(1.0 - g(0)) * (1.0 - k), (1.0 - g(1)) * (1.0 - k), (1.0 - g(2)) * (1.0 - k)] + } + Cs::Lab => lab_to_rgb(c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0), c.get(2).copied().unwrap_or(0.0)), + Cs::Indexed { base, hival, lookup } => { + let i = (c.first().copied().unwrap_or(0.0).round().max(0.0) as usize).min(*hival); + let n = base.components(); + let v: Vec = (0..n).map(|k| lookup.get(i * n + k).copied().unwrap_or(0) as f64 / 255.0).collect(); + base.to_rgb(&v) + } + Cs::Tint { alt, func, .. } => alt.to_rgb(&func.eval(c)), + } + } +} + +fn lab_to_rgb(l: f64, a: f64, b: f64) -> [f64; 3] { + let fy = (l + 16.0) / 116.0; + let fx = fy + a / 500.0; + let fz = fy - b / 200.0; + let f = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) }; + let (x, y, z) = (0.9505 * f(fx), f(fy), 1.089 * f(fz)); + let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.204 * y + 1.057 * z]; + lin.map(|v| { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + }) +} + +/// Read a colour space (a name, or an array such as `[/ICCBased 5 0 R]`); named resources are +/// looked up in `res` (`/ColorSpace`). +pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs { + let o = file.resolve(o); + match o { + Obj::Name(n) => match n.as_str() { + "DeviceGray" | "G" | "CalGray" => Cs::Gray, + "DeviceRGB" | "RGB" | "CalRGB" => Cs::Rgb, + "DeviceCMYK" | "CMYK" => Cs::Cmyk, + "Pattern" => Cs::Pattern, + other => match res.and_then(|r| file.get_dict(r, "ColorSpace")).and_then(|cs| file.get(cs, other)) { + Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space(file, x, None), + _ => Cs::Gray, + }, + }, + Obj::Array(a) => { + let head = a.first().map(|h| file.resolve(h)).and_then(Obj::name).unwrap_or(""); + match head { + "ICCBased" => { + let n = a.get(1).map(|s| file.resolve(s)).and_then(Obj::dict).and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize; + match n { + 1 => Cs::Gray, + 4 => Cs::Cmyk, + _ => Cs::Rgb, + } + } + "CalRGB" => Cs::Rgb, + "CalGray" => Cs::Gray, + "Lab" => Cs::Lab, + "Pattern" => Cs::Pattern, + "Indexed" | "I" => { + let base = a.get(1).map(|b| color_space(file, b, res)).unwrap_or(Cs::Rgb); + let hival = a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize; + let lookup = match a.get(3).map(|l| file.resolve(l)) { + Some(Obj::Str(s)) => s.clone(), + Some(Obj::Stream(d, raw)) => decode_stream(file, d, raw).unwrap_or_default(), + _ => vec![], + }; + Cs::Indexed { base: Box::new(base), hival, lookup } + } + "Separation" | "DeviceN" => { + let n = if head == "Separation" { 1 } else { a.get(1).map(|x| file.resolve(x)).and_then(Obj::array).map_or(1, <[Obj]>::len) }; + let alt = a.get(2).map(|b| color_space(file, b, res)).unwrap_or(Cs::Gray); + let func = a.get(3).map(|f| Func::read(file, f)).unwrap_or(Func::Unsupported); + // An unreadable tint transform: tint 1 = black, 0 = white. + if func == Func::Unsupported { + return Cs::Tint { n, alt: Box::new(Cs::Gray), func: Func::Exp { domain: [0.0, 1.0], c0: vec![1.0], c1: vec![0.0], n: 1.0 } }; + } + Cs::Tint { n, alt: Box::new(alt), func } + } + _ => Cs::Rgb, + } + } + _ => Cs::Gray, + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Func { + /// Type 2. + Exp { + domain: [f64; 2], + c0: Vec, + c1: Vec, + n: f64, + }, + /// Type 3. + Stitch { + domain: [f64; 2], + funcs: Vec, + bounds: Vec, + encode: Vec, + }, + /// Type 0 with one input. + Sampled { + domain: [f64; 2], + size: usize, + outputs: usize, + encode: [f64; 2], + decode: Vec, + samples: Vec, + }, + /// An array of one-output functions. + Array(Vec), + Unsupported, +} + +impl Func { + pub fn read(file: &File, o: &Obj) -> Func { + let o = file.resolve(o); + if let Obj::Array(a) = o { + return Func::Array(a.iter().map(|f| Func::read(file, f)).collect()); + } + let Some(d) = o.dict() else { return Func::Unsupported }; + let dom = file.get(d, "Domain").map(|x| file.nums(x)).unwrap_or_default(); + let domain = [dom.first().copied().unwrap_or(0.0), dom.get(1).copied().unwrap_or(1.0)]; + match file.get_num(d, "FunctionType").unwrap_or(-1.0) as i32 { + 2 => Func::Exp { + domain, + c0: file.get(d, "C0").map(|x| file.nums(x)).unwrap_or_else(|| vec![0.0]), + c1: file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]), + n: file.get_num(d, "N").unwrap_or(1.0), + }, + 3 => Func::Stitch { + domain, + funcs: file.get(d, "Functions").and_then(Obj::array).map(|a| a.iter().map(|f| Func::read(file, f)).collect()).unwrap_or_default(), + bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(), + encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(), + }, + 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 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 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 { + let mut v = 0u64; + for _ in 0..bps { + let byte = data.get(bit / 8).copied().unwrap_or(0); + v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64; + bit += 1; + } + samples.push(v as f64 / max); + } + Func::Sampled { domain, size, outputs, encode, decode, samples } + } + _ => Func::Unsupported, + } + } + + /// Evaluate at the first input (the functions used by shadings and tints take one input + /// in practice; extra inputs of DeviceN tints are averaged in). + pub fn eval(&self, input: &[f64]) -> Vec { + let t = input.first().copied().unwrap_or(0.0); + self.eval1(t) + } + + pub fn eval1(&self, t: f64) -> Vec { + match self { + Func::Exp { domain, c0, c1, n } => { + let t = t.clamp(domain[0].min(domain[1]), domain[0].max(domain[1])); + let x = if *n == 1.0 { t } else { t.max(0.0).powf(*n) }; + (0..c0.len().max(c1.len())) + .map(|i| c0.get(i).copied().unwrap_or(0.0) + x * (c1.get(i).copied().unwrap_or(1.0) - c0.get(i).copied().unwrap_or(0.0))) + .collect() + } + Func::Stitch { domain, funcs, bounds, encode } => { + if funcs.is_empty() { + return vec![0.0]; + } + let t = t.clamp(domain[0], domain[1]); + let k = bounds.iter().take_while(|b| t >= **b).count().min(funcs.len() - 1); + let lo = if k == 0 { domain[0] } else { bounds[k - 1] }; + let hi = bounds.get(k).copied().unwrap_or(domain[1]); + let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0)); + let u = if hi > lo { e0 + (t - lo) / (hi - lo) * (e1 - e0) } else { e0 }; + funcs[k].eval1(u) + } + Func::Sampled { domain, size, outputs, encode, decode, samples } => { + let t = t.clamp(domain[0], domain[1]); + let e = if domain[1] > domain[0] { encode[0] + (t - domain[0]) / (domain[1] - domain[0]) * (encode[1] - encode[0]) } else { encode[0] }; + let e = e.clamp(0.0, *size as f64 - 1.0); + let (i0, f) = (e.floor() as usize, e.fract()); + let i1 = (i0 + 1).min(size - 1); + (0..*outputs) + .map(|o| { + let s = samples[i0 * outputs + o] * (1.0 - f) + samples[i1 * outputs + o] * f; + let (d0, d1) = (decode.get(2 * o).copied().unwrap_or(0.0), decode.get(2 * o + 1).copied().unwrap_or(1.0)); + d0 + s * (d1 - d0) + }) + .collect() + } + Func::Array(fs) => fs.iter().map(|f| f.eval1(t).first().copied().unwrap_or(0.0)).collect(), + Func::Unsupported => vec![0.5], + } + } + + /// Input values worth sampling exactly (stitching bounds). + pub fn breakpoints(&self) -> Vec { + match self { + Func::Stitch { bounds, .. } => bounds.clone(), + _ => vec![], + } + } +} + +/// PDF text string → Rust string (UTF-16BE with a byte-order mark, else PDFDocEncoding ≈ Latin-1). +pub fn text_string(b: &[u8]) -> String { + if b.starts_with(&[0xFE, 0xFF]) { + let u: Vec = b[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + return String::from_utf16_lossy(&u); + } + if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8_lossy(rest).into_owned(); + } + b.iter().map(|&c| c as char).collect() +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs new file mode 100644 index 00000000..cd835113 --- /dev/null +++ b/crates/pdf/src/lib.rs @@ -0,0 +1,185 @@ +//! Vector footage: PDF, PDF-compatible Illustrator files (`.ai`) and Encapsulated PostScript +//! (see README.md for what is read and the specifications used). +//! +//! Files parse into the same render tree as SVG ([`effectcraft_svg::Doc`]): document pixels are +//! PostScript points (1/72 in), y down, so footage rasterises at any scale (Continuously +//! Rasterize) and converts to shape layers (Create Shapes from Vector Layer). The root group's +//! children are the document's **layers**: one group per top-level optional-content group +//! (Illustrator layers), with content outside them gathered into groups of its own. + +mod build; +mod color; +mod object; +mod page; +mod ps; +pub mod write; + +pub use effectcraft_svg::Doc; +use effectcraft_svg::{Affine, Group, Node}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Error { + NotVector, + Encrypted, + NoPages, + PageOutOfRange(usize), + NoBoundingBox, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotVector => write!(f, "not a PDF, Illustrator or EPS file"), + Error::Encrypted => write!(f, "encrypted PDF files are not supported"), + Error::NoPages => write!(f, "the PDF has no pages"), + Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n + 1), + Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"), + } + } +} + +impl std::error::Error for Error {} + +/// File extensions read as vector footage by this crate. +pub const EXTENSIONS: &[&str] = &["pdf", "ai", "eps", "epsf", "epsi"]; + +/// The format of a file, by its first bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Pdf, + Eps, +} + +/// Sniff a PDF (`%PDF-` near the start, as PDF-compatible `.ai` files are) or an EPS file. +pub fn sniff(bytes: &[u8]) -> Option { + let head = &bytes[..bytes.len().min(1024)]; + if head.windows(5).any(|w| w == b"%PDF-") { + return Some(Format::Pdf); + } + if bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) || bytes.starts_with(b"%!PS") { + return Some(Format::Eps); + } + None +} + +/// A footage codec name for a sniffed file ("PDF", "AI", "EPS"). +pub fn codec(path: &str, bytes: &[u8]) -> Option<&'static str> { + let f = sniff(bytes)?; + let ai = path.to_ascii_lowercase().ends_with(".ai"); + Some(match (f, ai) { + (_, true) => "AI", + (Format::Pdf, false) => "PDF", + (Format::Eps, false) => "EPS", + }) +} + +/// Parse the first page of a PDF / `.ai`, or an EPS file. +pub fn parse(bytes: &[u8]) -> Result { + parse_page(bytes, 0) +} + +/// Parse page `index` of a PDF (EPS files have one page). +pub fn parse_page(bytes: &[u8], index: usize) -> Result { + match sniff(bytes) { + Some(Format::Pdf) => parse_pdf(bytes, index), + Some(Format::Eps) => parse_eps(bytes), + None => Err(Error::NotVector), + } +} + +/// Number of pages (1 for EPS). +pub fn page_count(bytes: &[u8]) -> usize { + match sniff(bytes) { + Some(Format::Pdf) => page::pages(&object::File::parse(bytes)).len(), + Some(Format::Eps) => 1, + None => 0, + } +} + +/// Build the document: `root` children become layer groups (see the crate docs). +fn finish(nodes: Vec, flags: Vec, skipped: Vec, width: f64, height: f64, to_doc: Affine) -> Doc { + let mut layers: Vec = vec![]; + let mut loose: Vec = vec![]; + let flush = |loose: &mut Vec, layers: &mut Vec| { + if !loose.is_empty() { + let mut g = build::group(""); + g.children = std::mem::take(loose); + layers.push(Node::Group(g)); + } + }; + for (n, is_layer) in nodes.into_iter().zip(flags.into_iter().chain(std::iter::repeat(false))) { + if is_layer { + flush(&mut loose, &mut layers); + layers.push(n); + } else { + loose.push(n); + } + } + flush(&mut loose, &mut layers); + // Name unnamed layers "Layer N" (bottom first, as Illustrator numbers them). + for (i, l) in layers.iter_mut().enumerate() { + if let Node::Group(g) = l + && g.name.is_empty() + { + g.name = format!("Layer {}", i + 1); + } + } + Doc { width, height, root: Group { name: "page".into(), transform: to_doc, opacity: 1.0, children: layers, clip: vec![] }, skipped } +} + +fn parse_pdf(bytes: &[u8], index: usize) -> Result { + let file = object::File::parse(bytes); + if file.encrypted() { + return Err(Error::Encrypted); + } + let pages = page::pages(&file); + if pages.is_empty() { + return Err(Error::NoPages); + } + let pg = pages.get(index).ok_or(Error::PageOutOfRange(index))?; + let [x0, y0, x1, y1] = pg.bbox; + let (w, h) = (x1 - x0, y1 - y0); + // Default user space (y up) → document pixels (y down), with the page's /Rotate. + let (to_doc, dw, dh) = match pg.rotate { + 90 => (Affine::new([0.0, 1.0, 1.0, 0.0, -y0, -x0]), h, w), + 180 => (Affine::new([-1.0, 0.0, 0.0, 1.0, x1, -y0]), w, h), + 270 => (Affine::new([0.0, -1.0, -1.0, 0.0, y1, x1]), h, w), + _ => (Affine::new([1.0, 0.0, 0.0, -1.0, -x0, y1]), w, h), + }; + let content = page::page_content(&file, pg); + let mut it = page::Interp::new(&file, pg.bbox); + it.run(&content, &pg.resources, Affine::IDENTITY, 0); + let (nodes, flags, skipped) = it.b.finish(); + Ok(finish(nodes, flags, skipped, dw.max(1.0), dh.max(1.0), to_doc)) +} + +fn parse_eps(bytes: &[u8]) -> Result { + let (ps, bb) = ps::eps_parts(bytes).ok_or(Error::NoBoundingBox)?; + let mut it = ps::Interp::new(bb); + it.run(ps); + let (nodes, flags, skipped) = it.b.finish(); + let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -bb[0], bb[3]]); + Ok(finish(nodes, flags, skipped, (bb[2] - bb[0]).max(1.0), (bb[3] - bb[1]).max(1.0), to_doc)) +} + +/// The document's layer names (the root's children), bottom first. +pub fn layer_names(doc: &Doc) -> Vec { + doc.root + .children + .iter() + .map(|n| match n { + Node::Group(g) => g.name.clone(), + Node::Shape(s) => s.name.clone(), + }) + .collect() +} + +/// The document with only layer `index` (for one layer of a file imported as a composition). +pub fn layer_doc(doc: &Doc, index: usize) -> Doc { + let mut d = doc.clone(); + d.root.children = doc.root.children.get(index).cloned().into_iter().collect(); + d +} + +#[cfg(test)] +mod tests; diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs new file mode 100644 index 00000000..e24f6ec8 --- /dev/null +++ b/crates/pdf/src/object.rs @@ -0,0 +1,708 @@ +//! PDF objects (ISO 32000-1 §7.2–7.5): the lexer, the object model, stream filters and the +//! indirect-object table. +//! +//! The table is built by scanning the file for `n g obj` headers (later definitions win, as +//! incremental updates do) and by unpacking object streams, so damaged or rewritten +//! cross-reference sections do not matter. + +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq)] +pub enum Obj { + Null, + Bool(bool), + Num(f64), + Name(String), + Str(Vec), + Array(Vec), + Dict(Dict), + Ref(u32, u16), + /// A stream: its dictionary and raw (still encoded) data. + Stream(Dict, Vec), + /// A content-stream operator (or any bare keyword). + Op(String), +} + +pub type Dict = HashMap; + +impl Obj { + pub fn num(&self) -> Option { + match self { + Obj::Num(n) => Some(*n), + _ => None, + } + } + pub fn name(&self) -> Option<&str> { + match self { + Obj::Name(n) => Some(n), + _ => None, + } + } + pub fn array(&self) -> Option<&[Obj]> { + match self { + Obj::Array(a) => Some(a), + _ => None, + } + } + pub fn dict(&self) -> Option<&Dict> { + match self { + Obj::Dict(d) | Obj::Stream(d, _) => Some(d), + _ => None, + } + } +} + +pub fn is_white(c: u8) -> bool { + matches!(c, 0 | 9 | 10 | 12 | 13 | 32) +} +pub fn is_delim(c: u8) -> bool { + matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') +} + +/// A byte lexer producing PDF objects (arrays and dictionaries nested). +pub struct Lexer<'a> { + pub data: &'a [u8], + pub pos: usize, +} + +impl<'a> Lexer<'a> { + pub fn new(data: &'a [u8], pos: usize) -> Self { + Lexer { data, pos } + } + + pub fn skip_ws(&mut self) { + while self.pos < self.data.len() { + let c = self.data[self.pos]; + if is_white(c) { + self.pos += 1; + } else if c == b'%' { + while self.pos < self.data.len() && !matches!(self.data[self.pos], b'\n' | b'\r') { + self.pos += 1; + } + } else { + break; + } + } + } + + fn word(&mut self) -> &'a [u8] { + let s = self.pos; + while self.pos < self.data.len() && !is_white(self.data[self.pos]) && !is_delim(self.data[self.pos]) { + self.pos += 1; + } + &self.data[s..self.pos] + } + + /// The next object, `None` at the end. `]` and `>>` come back as `Op`s. + pub fn next(&mut self) -> Option { + self.next_depth(0) + } + + fn next_depth(&mut self, depth: usize) -> Option { + self.skip_ws(); + let c = *self.data.get(self.pos)?; + match c { + b'/' => { + self.pos += 1; + let w = self.word(); + Some(Obj::Name(decode_name(w))) + } + b'(' => Some(Obj::Str(self.literal_string())), + b'<' if self.data.get(self.pos + 1) == Some(&b'<') => { + self.pos += 2; + let mut d = Dict::new(); + loop { + if depth > 64 { + return Some(Obj::Null); + } + let k = self.next_depth(depth + 1); + match k { + None => break, + Some(Obj::Op(o)) if o == ">>" => break, + Some(Obj::Name(k)) => { + let v = self.next_depth(depth + 1).unwrap_or(Obj::Null); + let v = self.maybe_ref(v); + d.insert(k, v); + } + Some(_) => {} + } + } + Some(Obj::Dict(d)) + } + b'<' => { + self.pos += 1; + let mut hex = vec![]; + while self.pos < self.data.len() && self.data[self.pos] != b'>' { + let h = self.data[self.pos]; + if h.is_ascii_hexdigit() { + hex.push(h); + } + self.pos += 1; + } + self.pos += 1; + if hex.len() % 2 == 1 { + hex.push(b'0'); + } + Some(Obj::Str(hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect())) + } + b'>' if self.data.get(self.pos + 1) == Some(&b'>') => { + self.pos += 2; + Some(Obj::Op(">>".into())) + } + b'[' => { + self.pos += 1; + let mut a = vec![]; + loop { + if depth > 64 { + return Some(Obj::Null); + } + match self.next_depth(depth + 1) { + None => break, + Some(Obj::Op(o)) if o == "]" => break, + Some(v) => { + let v = self.maybe_ref(v); + a.push(v); + } + } + } + Some(Obj::Array(a)) + } + b']' | b'{' | b'}' | b')' | b'>' => { + self.pos += 1; + Some(Obj::Op((c as char).to_string())) + } + _ => { + let w = self.word(); + if w.is_empty() { + self.pos += 1; + return Some(Obj::Op(String::new())); + } + Some(match w { + b"true" => Obj::Bool(true), + b"false" => Obj::Bool(false), + b"null" => Obj::Null, + _ => match parse_num(w) { + Some(n) => Obj::Num(n), + None => Obj::Op(String::from_utf8_lossy(w).into_owned()), + }, + }) + } + } + } + + /// `n g R` after a number inside arrays and dictionaries. + fn maybe_ref(&mut self, v: Obj) -> Obj { + let Obj::Num(n) = v else { return v }; + if n.fract() != 0.0 || n < 0.0 { + return v; + } + let save = self.pos; + self.skip_ws(); + let gp = self.pos; + let g = self.word(); + if let Some(gn) = parse_num(g).filter(|g| g.fract() == 0.0 && *g >= 0.0) { + self.skip_ws(); + if self.data.get(self.pos) == Some(&b'R') && self.data.get(self.pos + 1).is_none_or(|c| is_white(*c) || is_delim(*c)) { + self.pos += 1; + return Obj::Ref(n as u32, gn as u16); + } + } + let _ = gp; + self.pos = save; + v + } + + fn literal_string(&mut self) -> Vec { + self.pos += 1; + let mut out = vec![]; + let mut depth = 1; + while self.pos < self.data.len() { + let c = self.data[self.pos]; + self.pos += 1; + match c { + b'\\' => { + let Some(&e) = self.data.get(self.pos) else { break }; + self.pos += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(8), + b'f' => out.push(12), + b'\r' => { + if self.data.get(self.pos) == Some(&b'\n') { + self.pos += 1; + } + } + b'\n' => {} + b'0'..=b'7' => { + let mut v = (e - b'0') as u32; + for _ in 0..2 { + match self.data.get(self.pos) { + Some(d @ b'0'..=b'7') => { + v = v * 8 + (d - b'0') as u32; + self.pos += 1; + } + _ => break, + } + } + out.push(v as u8); + } + other => out.push(other), + } + } + b'(' => { + depth += 1; + out.push(c); + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + out.push(c); + } + _ => out.push(c), + } + } + out + } +} + +fn hexv(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + b'A'..=b'F' => c - b'A' + 10, + _ => 0, + } +} + +fn decode_name(w: &[u8]) -> String { + let mut out = vec![]; + let mut i = 0; + while i < w.len() { + if w[i] == b'#' && w.get(i + 1).is_some_and(u8::is_ascii_hexdigit) && w.get(i + 2).is_some_and(u8::is_ascii_hexdigit) { + out.push((hexv(w[i + 1]) << 4) | hexv(w[i + 2])); + i += 3; + } else { + out.push(w[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +pub fn parse_num(w: &[u8]) -> Option { + if w.is_empty() || !w.iter().all(|c| c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.')) || !w.iter().any(u8::is_ascii_digit) { + return None; + } + let s = std::str::from_utf8(w).ok()?; + // "--5" and similar malformed numbers: take what parses. + s.parse::().ok().or_else(|| s.trim_start_matches(['+', '-']).parse::().ok().map(|v| if s.starts_with('-') { -v } else { v })) +} + +// ------------------------------------------------------------------ filters + +/// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT, +/// JBIG2) give `None`. +pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option> { + let filters: Vec = match d.get("Filter").map(|f| file.resolve(f)) { + None => vec![], + Some(Obj::Name(n)) => vec![n.clone()], + Some(Obj::Array(a)) => a.iter().filter_map(|f| file.resolve(f).name().map(str::to_string)).collect(), + _ => vec![], + }; + let parms: Vec> = match d.get("DecodeParms").or_else(|| d.get("DP")).map(|p| file.resolve(p)) { + Some(Obj::Dict(p)) => vec![Some(p.clone())], + Some(Obj::Array(a)) => a.iter().map(|p| file.resolve(p).dict().cloned()).collect(), + _ => vec![], + }; + let mut data = raw.to_vec(); + for (i, f) in filters.iter().enumerate() { + data = match f.as_str() { + "FlateDecode" | "Fl" => inflate(&data)?, + "ASCIIHexDecode" | "AHx" => ascii_hex(&data), + "ASCII85Decode" | "A85" => ascii85(&data), + "LZWDecode" | "LZW" => { + 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), + _ => return None, + }; + if let Some(Some(p)) = parms.get(i) + && matches!(f.as_str(), "FlateDecode" | "Fl" | "LZWDecode" | "LZW") + { + data = predict(&data, p); + } + } + Some(data) +} + +pub fn inflate(data: &[u8]) -> Option> { + miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| { + // Truncated streams: keep what inflates. + let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib); + let mut out = vec![0u8; data.len() * 8 + 1024]; + let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish); + (r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec()) + }) +} + +fn ascii_hex(data: &[u8]) -> Vec { + let mut hex: Vec = data.iter().copied().take_while(|c| *c != b'>').filter(u8::is_ascii_hexdigit).collect(); + if hex.len() % 2 == 1 { + hex.push(b'0'); + } + hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect() +} + +pub fn ascii85(data: &[u8]) -> Vec { + let mut out = vec![]; + let mut group = [0u32; 5]; + let mut n = 0; + let mut i = 0; + if data.starts_with(b"<~") { + i = 2; + } + while i < data.len() { + let c = data[i]; + i += 1; + match c { + b'~' => break, + b'z' if n == 0 => out.extend_from_slice(&[0; 4]), + b'!'..=b'u' => { + group[n] = (c - b'!') as u32; + n += 1; + if n == 5 { + let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d)); + out.extend_from_slice(&v.to_be_bytes()); + n = 0; + } + } + _ => {} + } + } + if n > 1 { + for g in group.iter_mut().skip(n) { + *g = 84; + } + let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d)); + out.extend_from_slice(&v.to_be_bytes()[..n - 1]); + } + out +} + +fn lzw(data: &[u8], early: bool) -> Vec { + let mut out = vec![]; + let mut table: Vec> = (0..=255u16).map(|b| vec![b as u8]).collect(); + table.push(vec![]); + table.push(vec![]); + let (mut bits, mut acc, mut nbits) = (9u32, 0u32, 0u32); + let mut prev: Option> = None; + for &b in data { + acc = (acc << 8) | b as u32; + nbits += 8; + while nbits >= bits { + let code = ((acc >> (nbits - bits)) & ((1 << bits) - 1)) as usize; + nbits -= bits; + match code { + 256 => { + table.truncate(258); + bits = 9; + prev = None; + continue; + } + 257 => return out, + _ => {} + } + let entry = if code < table.len() { + table[code].clone() + } else if let Some(p) = &prev { + let mut e = p.clone(); + e.push(p[0]); + e + } else { + return out; + }; + out.extend_from_slice(&entry); + if let Some(p) = prev { + let mut e = p; + e.push(entry[0]); + table.push(e); + } + prev = Some(entry); + let limit = table.len() + usize::from(early); + if limit >= (1 << bits) && bits < 12 { + bits += 1; + } + } + } + out +} + +fn run_length(data: &[u8]) -> Vec { + let mut out = vec![]; + let mut i = 0; + while i < data.len() { + let l = data[i] as usize; + i += 1; + if l < 128 { + out.extend_from_slice(&data[i.min(data.len())..(i + l + 1).min(data.len())]); + i += l + 1; + } else if l > 128 { + if let Some(&b) = data.get(i) { + out.extend(std::iter::repeat_n(b, 257 - l)); + } + i += 1; + } else { + break; + } + } + out +} + +/// PNG / TIFF predictors (`/Predictor`, `/Colors`, `/BitsPerComponent`, `/Columns`). +fn predict(data: &[u8], p: &Dict) -> Vec { + let g = |k: &str, d: f64| p.get(k).and_then(Obj::num).unwrap_or(d) as usize; + let pred = g("Predictor", 1.0); + if pred < 10 { + return data.to_vec(); + } + let bpp = (g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8).max(1); + let row = (g("Columns", 1.0) * g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8); + let mut out = Vec::with_capacity(data.len()); + let mut prev = vec![0u8; row]; + for chunk in data.chunks(row + 1) { + if chunk.len() < row + 1 { + break; + } + let ft = chunk[0]; + let mut cur = chunk[1..].to_vec(); + for i in 0..row { + let a = if i >= bpp { cur[i - bpp] as i32 } else { 0 }; + let b = prev[i] as i32; + let c = if i >= bpp { prev[i - bpp] as i32 } else { 0 }; + let add = match ft { + 1 => a, + 2 => b, + 3 => (a + b) / 2, + 4 => { + let pp = a + b - c; + let (pa, pb, pc) = ((pp - a).abs(), (pp - b).abs(), (pp - c).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } + } + _ => 0, + }; + cur[i] = (cur[i] as i32 + add) as u8; + } + out.extend_from_slice(&cur); + prev = cur; + } + out +} + +// ------------------------------------------------------------------ the file + +/// Every indirect object of a PDF file, plus the trailer dictionaries found. +pub struct File { + pub objects: HashMap, + pub trailers: Vec, +} + +fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + if from >= hay.len() || needle.is_empty() { + return None; + } + hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from) +} + +impl File { + pub fn parse(data: &[u8]) -> File { + let mut f = File { objects: HashMap::new(), trailers: vec![] }; + let mut i = 0; + while let Some(p) = find(data, b"obj", i) { + i = p + 3; + if p >= 3 && &data[p - 3..p] == b"end" { + continue; + } + if data.get(p + 3).is_some_and(|c| !is_white(*c) && !is_delim(*c)) { + continue; + } + // Walk back over "num gen ". + let mut q = p; + let back_ws = |q: &mut usize| { + let s = *q; + while *q > 0 && is_white(data[*q - 1]) { + *q -= 1; + } + *q < s + }; + let back_digits = |q: &mut usize| { + let e = *q; + while *q > 0 && data[*q - 1].is_ascii_digit() { + *q -= 1; + } + (*q < e).then(|| std::str::from_utf8(&data[*q..e]).ok()?.parse::().ok()).flatten() + }; + if !back_ws(&mut q) { + continue; + } + let Some(_gen) = back_digits(&mut q) else { continue }; + if !back_ws(&mut q) { + continue; + } + let Some(num) = back_digits(&mut q) else { continue }; + if q > 0 && !is_white(data[q - 1]) && !is_delim(data[q - 1]) { + continue; + } + let mut lx = Lexer::new(data, p + 3); + let Some(obj) = lx.next() else { continue }; + let obj = lx.maybe_ref(obj); + let obj = match obj { + Obj::Dict(d) => { + let save = lx.pos; + lx.skip_ws(); + if data[lx.pos..].starts_with(b"stream") { + let mut s = lx.pos + 6; + if data.get(s) == Some(&b'\r') { + s += 1; + } + if data.get(s) == Some(&b'\n') { + s += 1; + } + let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize); + let end = match declared.filter(|l| { + s + l <= data.len() && { + let mut k = s + l; + while k < data.len() && is_white(data[k]) { + k += 1; + } + data[k..].starts_with(b"endstream") + } + }) { + Some(l) => s + l, + None => { + let mut e = find(data, b"endstream", s).unwrap_or(data.len()); + if e > s && data[e - 1] == b'\n' { + e -= 1; + } + if e > s && data[e - 1] == b'\r' { + e -= 1; + } + e + } + }; + i = end; + Obj::Stream(d, data[s..end].to_vec()) + } else { + lx.pos = save; + Obj::Dict(d) + } + } + o => o, + }; + if let Obj::Stream(d, _) = &obj + && d.get("Type").and_then(Obj::name) == Some("XRef") + { + f.trailers.push(d.clone()); + } + f.objects.insert(num, obj); + } + // Trailer dictionaries of classic cross-reference sections. + let mut i = 0; + while let Some(p) = find(data, b"trailer", i) { + i = p + 7; + let mut lx = Lexer::new(data, i); + if let Some(Obj::Dict(d)) = lx.next() { + f.trailers.push(d); + } + } + // Object streams. + let streams: Vec<(Dict, Vec)> = f + .objects + .values() + .filter_map(|o| match o { + Obj::Stream(d, raw) if d.get("Type").and_then(Obj::name) == Some("ObjStm") => Some((d.clone(), raw.clone())), + _ => None, + }) + .collect(); + for (d, raw) in streams { + let Some(dec) = decode_stream(&f, &d, &raw) else { continue }; + let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize; + let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize; + let mut lx = Lexer::new(&dec, 0); + let mut heads = vec![]; + for _ in 0..n.min(100_000) { + let (Some(Obj::Num(num)), Some(Obj::Num(off))) = (lx.next(), lx.next()) else { break }; + heads.push((num as u32, off as usize)); + } + for (num, off) in heads { + if f.objects.contains_key(&num) || first + off >= dec.len() { + continue; + } + let mut lx = Lexer::new(&dec, first + off); + if let Some(o) = lx.next() { + let o = lx.maybe_ref(o); + f.objects.insert(num, o); + } + } + } + f + } + + /// Follow references (bounded). + pub fn resolve<'a>(&'a self, o: &'a Obj) -> &'a Obj { + let mut o = o; + for _ in 0..32 { + match o { + Obj::Ref(n, _) => match self.objects.get(n) { + Some(x) => o = x, + None => return &Obj::Null, + }, + _ => return o, + } + } + &Obj::Null + } + + pub fn get<'a>(&'a self, d: &'a Dict, key: &str) -> Option<&'a Obj> { + d.get(key).map(|o| self.resolve(o)).filter(|o| !matches!(o, Obj::Null)) + } + + pub fn get_dict<'a>(&'a self, d: &'a Dict, key: &str) -> Option<&'a Dict> { + self.get(d, key)?.dict() + } + + pub fn get_num(&self, d: &Dict, key: &str) -> Option { + self.get(d, key)?.num() + } + + /// Numbers of an array (references resolved). + pub fn nums(&self, o: &Obj) -> Vec { + self.resolve(o).array().map(|a| a.iter().filter_map(|x| self.resolve(x).num()).collect()).unwrap_or_default() + } + + /// The document catalog. + pub fn catalog(&self) -> Option<&Dict> { + for t in self.trailers.iter().rev() { + if let Some(Obj::Dict(d)) = t.get("Root").map(|r| self.resolve(r)) { + return Some(d); + } + } + let mut cats: Vec<(&u32, &Obj)> = + self.objects.iter().filter(|(_, o)| matches!(o, Obj::Dict(d) if d.get("Type").and_then(Obj::name) == Some("Catalog"))).collect(); + cats.sort_by_key(|(n, _)| **n); + cats.last().and_then(|(_, o)| o.dict()) + } + + pub fn encrypted(&self) -> bool { + self.trailers.iter().any(|t| t.contains_key("Encrypt")) + } +} diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs new file mode 100644 index 00000000..9a67f85c --- /dev/null +++ b/crates/pdf/src/page.rs @@ -0,0 +1,573 @@ +//! Pages and content streams (ISO 32000-1 §7.7.3, §8): the page tree, the graphics state, +//! path construction and painting, clipping, colour, shading patterns and `sh`, form XObjects +//! and optional-content marked sequences. Text, images and tiling patterns are skipped (listed +//! in [`effectcraft_svg::Doc::skipped`]). + +use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Gradient, GradientKind, Join, Paint, Spread, Stroke}; +use kurbo::Point; + +use crate::build::Builder; +use crate::color::{Cs, Func, color_space, text_string}; +use crate::object::{Dict, File, Lexer, Obj, decode_stream}; + +/// A page: its dictionary with inherited attributes resolved. +pub struct Page { + pub dict: Dict, + pub resources: Dict, + /// CropBox (else MediaBox) `[x0, y0, x1, y1]`. + pub bbox: [f64; 4], + pub rotate: i32, +} + +/// The pages in order. +pub fn pages(file: &File) -> Vec { + let mut out = vec![]; + let Some(cat) = file.catalog() else { return out }; + let Some(root) = file.get_dict(cat, "Pages") else { return out }; + fn walk(file: &File, node: &Dict, inherited: (Option, Option>, Option>, i32), out: &mut Vec, depth: usize) { + if depth > 32 || out.len() > 10_000 { + return; + } + let res = file.get_dict(node, "Resources").cloned().or(inherited.0); + let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.1); + let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.2); + let rot = file.get_num(node, "Rotate").map(|r| r as i32).unwrap_or(inherited.3); + match file.get(node, "Kids").and_then(Obj::array) { + Some(kids) if file.get(node, "Type").and_then(Obj::name) != Some("Page") => { + for k in kids { + if let Some(d) = file.resolve(k).dict() { + walk(file, d, (res.clone(), media.clone(), crop.clone(), rot), out, depth + 1); + } + } + } + _ => { + let b = crop.or(media).unwrap_or_else(|| vec![0.0, 0.0, 612.0, 792.0]); + let bbox = [b[0].min(b[2]), b[1].min(b[3]), b[0].max(b[2]), b[1].max(b[3])]; + out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, rotate: rot.rem_euclid(360) }); + } + } + } + walk(file, root, (None, None, None, 0), &mut out, 0); + out +} + +/// The page's content streams, decoded and joined. +pub fn page_content(file: &File, page: &Page) -> Vec { + let mut out = vec![]; + let parts: Vec<&Obj> = match page.dict.get("Contents").map(|c| file.resolve(c)) { + Some(Obj::Array(a)) => a.iter().map(|x| file.resolve(x)).collect(), + Some(o) => vec![o], + None => vec![], + }; + for p in parts { + if let Obj::Stream(d, raw) = p + && let Some(data) = decode_stream(file, d, raw) + { + out.extend_from_slice(&data); + out.push(b'\n'); + } + } + out +} + +#[derive(Clone)] +enum Fill { + Color([f64; 3]), + Paint(Paint), + None, +} + +#[derive(Clone)] +struct GState { + ctm: Affine, + fill_cs: Cs, + stroke_cs: Cs, + fill: Fill, + stroke: Fill, + lw: f64, + cap: Cap, + join: Join, + miter: f64, + dash: Option<(Vec, f64)>, + fill_alpha: f64, + stroke_alpha: f64, +} + +impl Default for GState { + fn default() -> Self { + GState { + ctm: Affine::IDENTITY, + fill_cs: Cs::Gray, + stroke_cs: Cs::Gray, + fill: Fill::Color([0.0; 3]), + stroke: Fill::Color([0.0; 3]), + lw: 1.0, + cap: Cap::Butt, + join: Join::Miter, + miter: 10.0, + dash: None, + fill_alpha: 1.0, + stroke_alpha: 1.0, + } + } +} + +pub(crate) struct Interp<'a> { + file: &'a File, + pub b: Builder, + gs: GState, + saved: Vec<(GState, usize)>, + path: BezPath, + cur: Option, + start: Option, + pending_clip: Option, + marked: Vec>, + /// The page box in default user space (for `sh`). + page_box: [f64; 4], + budget: usize, +} + +fn affine(v: &[f64]) -> Affine { + if v.len() < 6 { + return Affine::IDENTITY; + } + Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]) +} + +impl<'a> Interp<'a> { + pub fn new(file: &'a File, page_box: [f64; 4]) -> Self { + Interp { + file, + b: Builder::new(), + gs: GState::default(), + saved: vec![], + path: BezPath::new(), + cur: None, + start: None, + pending_clip: None, + marked: vec![], + page_box, + budget: 20_000_000, + } + } + + /// Run a content stream with resources `res`; `pattern_base` maps pattern space to the page + /// (the CTM where the stream's default coordinate system starts). + pub fn run(&mut self, content: &[u8], res: &Dict, pattern_base: Affine, depth: usize) { + let mut lx = Lexer::new(content, 0); + let mut args: Vec = Vec::with_capacity(8); + let mut in_text = false; + while let Some(o) = lx.next() { + let Obj::Op(op) = o else { + if args.len() < 64 { + args.push(o); + } + continue; + }; + if self.budget == 0 { + self.b.skip("content (operation limit)"); + return; + } + self.budget -= 1; + if in_text { + if op == "ET" { + in_text = false; + } + args.clear(); + continue; + } + let n = |i: usize| args.get(i).and_then(Obj::num).unwrap_or(0.0); + let nums = || args.iter().filter_map(Obj::num).collect::>(); + match op.as_str() { + "q" => self.saved.push((self.gs.clone(), self.b.depth())), + "Q" => { + if let Some((g, d)) = self.saved.pop() { + self.gs = g; + self.b.close_to(d); + } + } + "cm" => self.gs.ctm *= affine(&nums()), + "w" => self.gs.lw = n(0), + "J" => self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(n(0) as usize).min(2)], + "j" => self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(n(0) as usize).min(2)], + "M" => self.gs.miter = n(0), + "d" => { + let arr = args.first().map(|a| self.file.nums(a)).unwrap_or_default(); + self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then(|| (arr, n(1))); + } + "gs" => { + if let Some(name) = args.first().and_then(Obj::name) + && let Some(g) = self.file.get_dict(res, "ExtGState").and_then(|e| self.file.get_dict(e, name)) + { + self.ext_gstate(g); + } + } + // Path construction. + "m" => { + let p = Point::new(n(0), n(1)); + self.path.move_to(p); + self.cur = Some(p); + self.start = Some(p); + } + "l" => { + let p = Point::new(n(0), n(1)); + self.ensure_start(); + self.path.line_to(p); + self.cur = Some(p); + } + "c" => { + self.ensure_start(); + let p = Point::new(n(4), n(5)); + self.path.curve_to(Point::new(n(0), n(1)), Point::new(n(2), n(3)), p); + self.cur = Some(p); + } + "v" => { + self.ensure_start(); + let c0 = self.cur.unwrap_or_default(); + let p = Point::new(n(2), n(3)); + self.path.curve_to(c0, Point::new(n(0), n(1)), p); + self.cur = Some(p); + } + "y" => { + self.ensure_start(); + let p = Point::new(n(2), n(3)); + self.path.curve_to(Point::new(n(0), n(1)), p, p); + self.cur = Some(p); + } + "h" => { + if self.cur.is_some() { + self.path.close_path(); + self.cur = self.start; + } + } + "re" => { + let (x, y, w, h) = (n(0), n(1), n(2), n(3)); + self.path.move_to((x, y)); + self.path.line_to((x + w, y)); + self.path.line_to((x + w, y + h)); + self.path.line_to((x, y + h)); + self.path.close_path(); + self.cur = Some(Point::new(x, y)); + self.start = self.cur; + } + // Painting. + "S" => self.paint(false, None, true), + "s" => { + self.path.close_path(); + self.paint(false, None, true) + } + "f" | "F" => self.paint(true, Some(FillRule::NonZero), false), + "f*" => self.paint(true, Some(FillRule::EvenOdd), false), + "B" => self.paint(true, Some(FillRule::NonZero), true), + "B*" => self.paint(true, Some(FillRule::EvenOdd), true), + "b" => { + self.path.close_path(); + self.paint(true, Some(FillRule::NonZero), true) + } + "b*" => { + self.path.close_path(); + self.paint(true, Some(FillRule::EvenOdd), true) + } + "n" => self.paint(false, None, false), + "W" => self.pending_clip = Some(FillRule::NonZero), + "W*" => self.pending_clip = Some(FillRule::EvenOdd), + // Colour. + "g" => self.gs.fill_cs = Cs::Gray, + "G" => self.gs.stroke_cs = Cs::Gray, + "rg" => self.gs.fill_cs = Cs::Rgb, + "RG" => self.gs.stroke_cs = Cs::Rgb, + "k" => self.gs.fill_cs = Cs::Cmyk, + "K" => self.gs.stroke_cs = Cs::Cmyk, + "cs" | "CS" => { + let cs = args.first().map(|a| color_space(self.file, a, Some(res))).unwrap_or(Cs::Gray); + let init = Fill::Color(cs.to_rgb(&cs.initial())); + if op == "cs" { + self.gs.fill_cs = cs; + self.gs.fill = init; + } else { + self.gs.stroke_cs = cs; + self.gs.stroke = init; + } + } + "sc" | "scn" | "SC" | "SCN" => { + let fill = op.starts_with('s'); + let cs = if fill { self.gs.fill_cs.clone() } else { self.gs.stroke_cs.clone() }; + let v = if let (Cs::Pattern, Some(name)) = (&cs, args.last().and_then(Obj::name)) { + self.pattern(res, name, pattern_base) + } else { + Fill::Color(cs.to_rgb(&nums())) + }; + if fill { + self.gs.fill = v; + } else { + self.gs.stroke = v; + } + } + "sh" => { + if let Some(name) = args.first().and_then(Obj::name) { + self.shade(res, name); + } + } + "Do" => { + if let Some(name) = args.first().and_then(Obj::name) { + self.xobject(res, name, depth); + } + } + "BT" => { + in_text = true; + self.b.skip("text"); + } + "BI" => { + // Inline image: skip to EI. + let d = lx.data; + let mut p = lx.pos; + while p + 2 < d.len() && !(d[p] == b'I' && d[p - 1] == b'D' && crate::object::is_white(d[p + 1])) { + p += 1; + } + while p + 2 < d.len() + && !(crate::object::is_white(d[p - 1]) && d[p] == b'E' && d[p + 1] == b'I' && (p + 2 == d.len() || crate::object::is_white(d[p + 2]))) + { + p += 1; + } + lx.pos = (p + 2).min(d.len()); + self.b.skip("image"); + } + "BMC" => self.marked.push(None), + "BDC" => { + let layer = match (args.first().and_then(Obj::name), args.get(1)) { + (Some("OC"), Some(Obj::Name(prop))) => self + .file + .get_dict(res, "Properties") + .and_then(|p| self.file.get_dict(p, prop)) + .map(|ocg| self.ocg_name(ocg).unwrap_or_else(|| prop.clone())), + (Some("OC"), Some(Obj::Dict(ocg))) => self.ocg_name(ocg), + _ => None, + }; + match layer { + Some(name) => { + self.marked.push(Some(self.b.depth())); + self.b.open(&name, true); + } + None => self.marked.push(None), + } + } + "EMC" => { + if let Some(Some(d)) = self.marked.pop() { + self.b.close_to(d); + } + } + _ => {} + } + // Colour operands set by the device-space shorthands. + match op.as_str() { + "g" | "rg" | "k" => self.gs.fill = Fill::Color(self.gs.fill_cs.to_rgb(&nums())), + "G" | "RG" | "K" => self.gs.stroke = Fill::Color(self.gs.stroke_cs.to_rgb(&nums())), + _ => {} + } + args.clear(); + } + } + + fn ocg_name(&self, ocg: &Dict) -> Option { + // An optional-content membership dictionary names its groups in /OCGs. + let d = match self.file.get(ocg, "OCGs") { + Some(Obj::Dict(g)) => g, + Some(Obj::Array(a)) => a.first().map(|x| self.file.resolve(x)).and_then(Obj::dict).unwrap_or(ocg), + _ => ocg, + }; + match self.file.get(d, "Name") { + Some(Obj::Str(s)) => Some(text_string(s)), + _ => None, + } + } + + fn ensure_start(&mut self) { + if self.cur.is_none() { + self.path.move_to((0.0, 0.0)); + self.cur = Some(Point::ZERO); + self.start = self.cur; + } + } + + fn ext_gstate(&mut self, g: &Dict) { + let f = self.file; + if let Some(v) = f.get_num(g, "LW") { + self.gs.lw = v; + } + if let Some(v) = f.get_num(g, "LC") { + self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(v as usize).min(2)]; + } + if let Some(v) = f.get_num(g, "LJ") { + self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(v as usize).min(2)]; + } + if let Some(v) = f.get_num(g, "ML") { + self.gs.miter = v; + } + if let Some(v) = f.get_num(g, "CA") { + self.gs.stroke_alpha = v.clamp(0.0, 1.0); + } + if let Some(v) = f.get_num(g, "ca") { + self.gs.fill_alpha = v.clamp(0.0, 1.0); + } + if let Some(Obj::Array(d)) = f.get(g, "D") { + let arr = d.first().map(|a| f.nums(a)).unwrap_or_default(); + let ph = d.get(1).and_then(|p| f.resolve(p).num()).unwrap_or(0.0); + self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then_some((arr, ph)); + } + if f.get(g, "SMask").is_some_and(|s| !matches!(s, Obj::Name(n) if n == "None")) { + self.b.skip("soft mask"); + } + } + + fn paint_of(f: &Fill) -> Option { + match f { + Fill::Color(c) => Some(Paint::Color(*c)), + Fill::Paint(p) => Some(p.clone()), + Fill::None => None, + } + } + + fn paint(&mut self, fill: bool, rule: Option, stroke: bool) { + let path = std::mem::take(&mut self.path); + self.cur = None; + self.start = None; + let ctm = self.gs.ctm; + let fill = if fill { Self::paint_of(&self.gs.fill).map(|p| (p, self.gs.fill_alpha, rule.unwrap_or(FillRule::NonZero))) } else { None }; + let stroke = if stroke { + Self::paint_of(&self.gs.stroke).map(|p| { + // Width 0: the thinnest line the device can draw (one pixel). + let s = { (ctm.determinant().abs()).sqrt() }; + let width = if self.gs.lw <= 0.0 { if s > 0.0 { 1.0 / s } else { 1.0 } } else { self.gs.lw }; + Stroke { + paint: p, + opacity: self.gs.stroke_alpha, + width, + cap: self.gs.cap, + join: self.gs.join, + miter: self.gs.miter.max(1.0), + dash: self.gs.dash.clone(), + } + }) + } else { + None + }; + if fill.is_some() || stroke.is_some() { + self.b.fill_stroke(path.clone(), ctm, fill, stroke); + } + if let Some(r) = self.pending_clip.take() { + self.b.clip(ctm * path, r); + } + } + + /// A shading dictionary → gradient paint in shading space. + fn shading_gradient(&mut self, sh: &Dict) -> Option { + let f = self.file; + let ty = f.get_num(sh, "ShadingType").unwrap_or(0.0) as i32; + let cs = f.get(sh, "ColorSpace").map(|c| color_space(f, c, None)).unwrap_or(Cs::Rgb); + let coords = f.get(sh, "Coords").map(|c| f.nums(c)).unwrap_or_default(); + let func = f.get(sh, "Function").map(|x| Func::read(f, x)).unwrap_or(Func::Unsupported); + let dom = f.get(sh, "Domain").map(|d| f.nums(d)).filter(|d| d.len() == 2).unwrap_or_else(|| vec![0.0, 1.0]); + let kind = match (ty, coords.len()) { + (2, 4) => GradientKind::Linear { x1: coords[0], y1: coords[1], x2: coords[2], y2: coords[3] }, + (3, 6) => GradientKind::Radial { cx: coords[3], cy: coords[4], r: coords[5], fx: coords[0], fy: coords[1] }, + _ => { + self.b.skip(&format!("shading type {ty}")); + return None; + } + }; + if func == Func::Unsupported { + self.b.skip("shading function"); + } + // Sample the function: evenly, plus exactly at stitching bounds. + let mut ts: Vec = (0..=24).map(|i| i as f64 / 24.0).collect(); + for b in func.breakpoints() { + if dom[1] > dom[0] { + ts.push(((b - dom[0]) / (dom[1] - dom[0])).clamp(0.0, 1.0)); + } + } + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + let stops = ts + .into_iter() + .map(|t| { + let c = cs.to_rgb(&func.eval1(dom[0] + t * (dom[1] - dom[0]))); + (t, [c[0], c[1], c[2], 1.0]) + }) + .collect(); + Some(Gradient { kind, stops, bbox_units: false, transform: Affine::IDENTITY, spread: Spread::Pad }) + } + + fn pattern(&mut self, res: &Dict, name: &str, base: Affine) -> Fill { + let f = self.file; + let Some(p) = f.get_dict(res, "Pattern").and_then(|pp| f.get_dict(pp, name)) else { return Fill::None }; + match f.get_num(p, "PatternType").unwrap_or(0.0) as i32 { + 2 => { + let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); + let Some(sh) = f.get(p, "Shading").and_then(Obj::dict) else { return Fill::None }; + let sh = sh.clone(); + match self.shading_gradient(&sh) { + // Gradient space → shape user space (shapes carry the CTM). + Some(mut g) => { + g.transform = self.gs.ctm.inverse() * base * m; + Fill::Paint(Paint::Gradient(g)) + } + None => Fill::None, + } + } + _ => { + self.b.skip("tiling pattern"); + Fill::Color([0.5; 3]) + } + } + } + + 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(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(); + let b = self.page_box; + let mut path = BezPath::new(); + for (i, (x, y)) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].into_iter().enumerate() { + let p = inv * Point::new(x, y); + if i == 0 { + path.move_to(p); + } else { + path.line_to(p); + } + } + path.close_path(); + self.b.fill_stroke(path, self.gs.ctm, Some((Paint::Gradient(g), self.gs.fill_alpha, FillRule::NonZero)), None); + } + + fn xobject(&mut self, res: &Dict, name: &str, depth: usize) { + let f = self.file; + let Some(x) = f.get_dict(res, "XObject").and_then(|xs| xs.get(name)).map(|x| f.resolve(x)) else { return }; + let Obj::Stream(d, raw) = x else { return }; + match f.get(d, "Subtype").and_then(Obj::name) { + Some("Form") if depth < 12 => { + let Some(content) = decode_stream(f, d, raw) else { return }; + 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; + 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 * kurbo::Shape::to_path(&r, 0.1), FillRule::NonZero); + } + let base = self.gs.ctm; + let path = std::mem::take(&mut self.path); + self.run(&content, &form_res, base, depth + 1); + self.path = path; + if let Some((g, dd)) = self.saved.pop() { + self.gs = g; + self.b.close_to(dd); + } + } + Some("Image") => self.b.skip("image"), + _ => {} + } + } +} diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs new file mode 100644 index 00000000..b9b0bff4 --- /dev/null +++ b/crates/pdf/src/ps.rs @@ -0,0 +1,1462 @@ +//! Encapsulated PostScript: a small PostScript interpreter (PostScript Language Reference, 3rd +//! 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 +//! Level 3 smooth shading (`shfill`). + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke}; +use kurbo::{PathEl, Point}; + +use crate::build::Builder; +use crate::object::{ascii85, is_white, parse_num}; + +type DictRef = Rc>>; + +#[derive(Clone)] +enum V { + Num(f64), + Bool(bool), + /// An executable name. + Name(Rc), + /// A literal name (`/name`). + Lit(Rc), + Str(Rc>>), + Proc(Rc>), + Array(Rc>>), + Dict(DictRef), + Mark, + Null, + /// `currentfile`. + File, +} + +impl V { + fn num(&self) -> Option { + match self { + V::Num(n) => Some(*n), + _ => None, + } + } + fn key(&self) -> String { + match self { + V::Name(n) | V::Lit(n) => n.to_string(), + V::Str(s) => String::from_utf8_lossy(&s.borrow()).into_owned(), + V::Num(n) => format!("{n}"), + V::Bool(b) => format!("{b}"), + _ => String::new(), + } + } + fn items(&self) -> Option> { + match self { + V::Array(a) => Some(a.borrow().clone()), + V::Proc(p) => Some(p.as_ref().clone()), + _ => None, + } + } +} + +enum Flow { + Exit, + Stop, + Quit, +} + +#[derive(Clone)] +struct GState { + ctm: Affine, + color: [f64; 3], + lw: f64, + cap: Cap, + join: Join, + miter: f64, + dash: Option<(Vec, f64)>, + path: BezPath, + cur: Option, + start: Option, + depth: usize, +} + +/// Source tokenizer (the data source for `currentfile`). +struct Src<'a> { + d: &'a [u8], + pos: usize, +} + +impl Src<'_> { + fn skip_ws(&mut self) { + while self.pos < self.d.len() { + let c = self.d[self.pos]; + if is_white(c) { + self.pos += 1; + } else if c == b'%' { + let s = self.pos; + while self.pos < self.d.len() && !matches!(self.d[self.pos], b'\n' | b'\r') { + self.pos += 1; + } + // DSC: skip embedded binary / data sections. + let line = &self.d[s..self.pos]; + if line.starts_with(b"%%BeginData:") || line.starts_with(b"%%BeginBinary:") || line.starts_with(b"%%BeginPreview") { + let end: &[u8] = if line.starts_with(b"%%BeginData:") { + b"%%EndData" + } else if line.starts_with(b"%%BeginBinary:") { + b"%%EndBinary" + } else { + b"%%EndPreview" + }; + match self.d[self.pos..].windows(end.len()).position(|w| w == end) { + Some(p) => self.pos += p, + None => self.pos = self.d.len(), + } + } + } else { + break; + } + } + } + + fn word(&mut self) -> &[u8] { + let s = self.pos; + while self.pos < self.d.len() { + let c = self.d[self.pos]; + if is_white(c) || matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') { + break; + } + self.pos += 1; + } + &self.d[s..self.pos] + } + + /// The next token; `{ … }` comes back as a whole procedure. + fn token(&mut self, depth: usize) -> Option { + self.skip_ws(); + let c = *self.d.get(self.pos)?; + Some(match c { + b'{' => { + self.pos += 1; + let mut body = vec![]; + loop { + self.skip_ws(); + match self.d.get(self.pos) { + None => break, + Some(b'}') => { + self.pos += 1; + break; + } + _ => { + if depth > 100 { + return None; + } + match self.token(depth + 1) { + Some(t) => body.push(t), + None => break, + } + } + } + } + V::Proc(Rc::new(body)) + } + b'}' => { + self.pos += 1; + V::Null + } + b'/' => { + self.pos += 1; + let imm = self.d.get(self.pos) == Some(&b'/'); + if imm { + self.pos += 1; + } + let w = String::from_utf8_lossy(self.word()).into_owned(); + // `//name` (immediately evaluated) is treated like a plain name. + if imm { V::Name(w.into()) } else { V::Lit(w.into()) } + } + b'(' => { + self.pos += 1; + let mut out = vec![]; + let mut depth = 1; + while self.pos < self.d.len() { + let c = self.d[self.pos]; + self.pos += 1; + match c { + b'\\' => { + let e = self.d.get(self.pos).copied().unwrap_or(b'\\'); + self.pos += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'0'..=b'7' => { + let mut v = (e - b'0') as u32; + for _ in 0..2 { + match self.d.get(self.pos) { + Some(d @ b'0'..=b'7') => { + v = v * 8 + (d - b'0') as u32; + self.pos += 1; + } + _ => break, + } + } + out.push(v as u8); + } + b'\n' | b'\r' => {} + o => out.push(o), + } + } + b'(' => { + depth += 1; + out.push(c); + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + out.push(c); + } + _ => out.push(c), + } + } + V::Str(Rc::new(RefCell::new(out))) + } + b'<' if self.d.get(self.pos + 1) == Some(&b'<') => { + self.pos += 2; + V::Name("<<".into()) + } + b'>' if self.d.get(self.pos + 1) == Some(&b'>') => { + self.pos += 2; + V::Name(">>".into()) + } + b'<' if self.d.get(self.pos + 1) == Some(&b'~') => { + let s = self.pos; + while self.pos + 1 < self.d.len() && !(self.d[self.pos] == b'~' && self.d[self.pos + 1] == b'>') { + self.pos += 1; + } + self.pos = (self.pos + 2).min(self.d.len()); + V::Str(Rc::new(RefCell::new(ascii85(&self.d[s..self.pos])))) + } + b'<' => { + self.pos += 1; + let mut hex = vec![]; + while self.pos < self.d.len() && self.d[self.pos] != b'>' { + if self.d[self.pos].is_ascii_hexdigit() { + hex.push(self.d[self.pos]); + } + self.pos += 1; + } + self.pos += 1; + if hex.len() % 2 == 1 { + hex.push(b'0'); + } + let h = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8; + V::Str(Rc::new(RefCell::new(hex.chunks(2).map(|p| (h(p[0]) << 4) | h(p[1])).collect()))) + } + b'[' | b']' => { + self.pos += 1; + V::Name(if c == b'[' { "[" } else { "]" }.into()) + } + b')' | b'>' => { + self.pos += 1; + V::Null + } + _ => { + let w = self.word(); + if w.is_empty() { + self.pos += 1; + return Some(V::Null); + } + match parse_num(w) { + Some(n) => V::Num(n), + None => { + // Radix numbers (16#FF). + let s = String::from_utf8_lossy(w).into_owned(); + match s.split_once('#').and_then(|(r, v)| i64::from_str_radix(v, r.parse().ok()?).ok()) { + Some(n) => V::Num(n as f64), + None => V::Name(s.into()), + } + } + } + } + }) + } + + /// Step over inline image data that follows an `image` operator reading `currentfile`. + fn skip_image_data(&mut self) { + self.skip_ws(); + if self.d[self.pos..].starts_with(b"<~") || self.d.get(self.pos).is_some_and(|c| (b'!'..=b'u').contains(c) && !c.is_ascii_hexdigit()) { + match self.d[self.pos..].windows(2).position(|w| w == b"~>") { + Some(p) => self.pos += p + 2, + None => self.pos = self.d.len(), + } + return; + } + while self.pos < self.d.len() && (self.d[self.pos].is_ascii_hexdigit() || is_white(self.d[self.pos])) { + self.pos += 1; + } + if self.d.get(self.pos) == Some(&b'>') { + self.pos += 1; + } + } +} + +pub(crate) struct Interp { + pub b: Builder, + st: Vec, + dicts: Vec, + gs: GState, + saved: Vec, + budget: usize, + page: [f64; 4], + pub unknown: Vec, +} + +fn new_dict() -> DictRef { + Rc::new(RefCell::new(HashMap::new())) +} + +fn array(v: Vec) -> V { + V::Array(Rc::new(RefCell::new(v))) +} + +fn matrix_of(v: &V) -> Option { + let a: Vec = v.items()?.iter().filter_map(V::num).collect(); + (a.len() == 6).then(|| Affine::new([a[0], a[1], a[2], a[3], a[4], a[5]])) +} + +fn matrix_val(m: Affine) -> V { + array(m.as_coeffs().iter().map(|c| V::Num(*c)).collect()) +} + +impl Interp { + pub fn new(page: [f64; 4]) -> Interp { + Interp { + b: Builder::new(), + st: vec![], + dicts: vec![new_dict()], + gs: GState { + ctm: Affine::IDENTITY, + color: [0.0; 3], + lw: 1.0, + cap: Cap::Butt, + join: Join::Miter, + miter: 10.0, + dash: None, + path: BezPath::new(), + cur: None, + start: None, + depth: 1, + }, + saved: vec![], + budget: 8_000_000, + page, + unknown: vec![], + } + } + + pub fn run(&mut self, data: &[u8]) { + 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) { + break; + } + } + } + + fn pop(&mut self) -> V { + self.st.pop().unwrap_or(V::Null) + } + fn popn(&mut self) -> f64 { + self.pop().num().unwrap_or(0.0) + } + fn push(&mut self, v: V) { + if self.st.len() < 100_000 { + self.st.push(v); + } + } + + fn lookup(&self, k: &str) -> Option { + self.dicts.iter().rev().find_map(|d| d.borrow().get(k).cloned()) + } + + /// A token read from the file: procedures are pushed, names executed. + fn exec_token(&mut self, t: V, src: &mut Src) -> Result<(), Flow> { + match t { + V::Name(n) => self.exec_name(&n, src), + other => { + self.push(other); + Ok(()) + } + } + } + + /// Execute a value as an object (procedures run). + fn exec_obj(&mut self, v: V, src: &mut Src) -> Result<(), Flow> { + match v { + V::Proc(p) => { + for x in p.iter() { + match x { + V::Name(n) => self.exec_name(n, src)?, + other => self.push(other.clone()), + } + } + Ok(()) + } + V::Name(n) => self.exec_name(&n, src), + other => { + self.push(other); + Ok(()) + } + } + } + + fn exec_name(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { + if self.budget == 0 { + self.b.skip("PostScript (operation limit)"); + return Err(Flow::Quit); + } + self.budget -= 1; + if let Some(v) = self.lookup(n) { + return match v { + V::Proc(_) => self.exec_obj(v, src), + other => { + self.push(other); + Ok(()) + } + }; + } + self.builtin(n, src) + } + + fn device_scale(&self) -> f64 { + self.gs.ctm.determinant().abs().sqrt() + } + + fn user_point(&mut self) -> Point { + let y = self.popn(); + let x = self.popn(); + Point::new(x, y) + } + + fn move_to(&mut self, p: Point) { + let q = self.gs.ctm * p; + self.gs.path.move_to(q); + self.gs.cur = Some(q); + self.gs.start = Some(q); + } + fn line_to(&mut self, p: Point) { + let q = self.gs.ctm * p; + if self.gs.cur.is_none() { + self.gs.path.move_to(q); + self.gs.start = Some(q); + } + self.gs.path.line_to(q); + self.gs.cur = Some(q); + } + fn curve_to(&mut self, a: Point, b: Point, c: Point) { + let m = self.gs.ctm; + if self.gs.cur.is_none() { + self.gs.path.move_to(m * a); + } + self.gs.path.curve_to(m * a, m * b, m * c); + self.gs.cur = Some(m * c); + } + fn current_user(&self) -> Point { + self.gs.ctm.inverse() * self.gs.cur.unwrap_or_default() + } + + fn arc(&mut self, neg: bool) { + let a2 = self.popn().to_radians(); + let a1 = self.popn().to_radians(); + let r = self.popn(); + let c = self.user_point(); + let mut sweep = a2 - a1; + if neg { + while sweep > 0.0 { + sweep -= std::f64::consts::TAU; + } + } else { + while sweep < 0.0 { + sweep += std::f64::consts::TAU; + } + } + let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin()); + if self.gs.cur.is_some() { + self.line_to(start); + } else { + self.move_to(start); + } + let arc = kurbo::Arc::new(c, (r, r), a1, sweep, 0.0); + let m = self.gs.ctm; + arc.to_cubic_beziers(0.1, |p1, p2, p3| { + self.gs.path.curve_to(m * p1, m * p2, m * p3); + self.gs.cur = Some(m * p3); + }); + } + + fn paint(&mut self, fill: Option, stroke: bool) { + let path = std::mem::take(&mut self.gs.path); + self.gs.cur = None; + self.gs.start = None; + let s = self.device_scale(); + let st = stroke.then(|| Stroke { + paint: Paint::Color(self.gs.color), + opacity: 1.0, + width: if self.gs.lw <= 0.0 { 1.0 } else { self.gs.lw * s }, + cap: self.gs.cap, + join: self.gs.join, + miter: self.gs.miter.max(1.0), + dash: self.gs.dash.as_ref().map(|(a, o)| (a.iter().map(|v| v * s).collect(), o * s)), + }); + self.b.fill_stroke(path, Affine::IDENTITY, fill.map(|r| (Paint::Color(self.gs.color), 1.0, r)), st); + } + + fn rect_path(&mut self) -> Vec { + // x y w h, or an array / string of numbers. + let top = self.pop(); + let nums: Vec = match &top { + V::Array(_) | V::Proc(_) => top.items().unwrap_or_default().iter().filter_map(V::num).collect(), + V::Num(h) => { + let w = self.popn(); + let y = self.popn(); + let x = self.popn(); + vec![x, y, w, *h] + } + _ => vec![], + }; + let m = self.gs.ctm; + nums.chunks_exact(4) + .map(|r| { + let mut p = BezPath::new(); + p.move_to(m * Point::new(r[0], r[1])); + p.line_to(m * Point::new(r[0] + r[2], r[1])); + p.line_to(m * Point::new(r[0] + r[2], r[1] + r[3])); + p.line_to(m * Point::new(r[0], r[1] + r[3])); + p.close_path(); + p + }) + .collect() + } + + fn grestore(&mut self) { + if let Some(g) = self.saved.pop() { + let d = g.depth; + self.gs = g; + self.b.close_to(d); + } + } + + fn builtin(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { + match n { + // Stack. + "pop" => { + self.pop(); + } + "exch" => { + let b = self.pop(); + let a = self.pop(); + self.push(b); + self.push(a); + } + "dup" => { + let a = self.st.last().cloned().unwrap_or(V::Null); + self.push(a); + } + "copy" => match self.pop() { + V::Num(k) => { + let k = (k.max(0.0) as usize).min(self.st.len()); + let tail: Vec = self.st[self.st.len() - k..].to_vec(); + self.st.extend(tail); + } + V::Array(dst) => { + let s = self.pop().items().unwrap_or_default(); + let mut d = dst.borrow_mut(); + for (i, v) in s.into_iter().enumerate() { + if i < d.len() { + d[i] = v; + } + } + drop(d); + self.push(V::Array(dst)); + } + other => self.push(other), + }, + "index" => { + let k = self.popn().max(0.0) as usize; + let v = if k < self.st.len() { self.st[self.st.len() - 1 - k].clone() } else { V::Null }; + self.push(v); + } + "roll" => { + let j = self.popn() as i64; + let k = (self.popn().max(0.0) as usize).min(self.st.len()); + if k > 0 { + let s = self.st.len() - k; + let r = j.rem_euclid(k as i64) as usize; + self.st[s..].rotate_right(r); + } + } + "clear" => self.st.clear(), + "count" => self.push(V::Num(self.st.len() as f64)), + "mark" | "[" | "<<" => self.push(V::Mark), + "cleartomark" => { + while let Some(v) = self.st.pop() { + if matches!(v, V::Mark) { + break; + } + } + } + "counttomark" => { + let k = self.st.iter().rev().position(|v| matches!(v, V::Mark)).unwrap_or(self.st.len()); + self.push(V::Num(k as f64)); + } + "]" => { + let k = self.st.iter().rev().position(|v| matches!(v, V::Mark)).unwrap_or(self.st.len()); + let items = self.st.split_off(self.st.len() - k); + self.st.pop(); + self.push(array(items)); + } + ">>" => { + let k = self.st.iter().rev().position(|v| matches!(v, V::Mark)).unwrap_or(self.st.len()); + let items = self.st.split_off(self.st.len() - k); + self.st.pop(); + let d = new_dict(); + for kv in items.chunks_exact(2) { + d.borrow_mut().insert(kv[0].key(), kv[1].clone()); + } + self.push(V::Dict(d)); + } + // Arithmetic. + "add" | "sub" | "mul" | "div" | "idiv" | "mod" | "atan" | "exp" | "max" | "min" => { + let b = self.popn(); + let a = self.popn(); + let v = match n { + "add" => a + b, + "sub" => a - b, + "mul" => a * b, + "div" => { + if b == 0.0 { + 0.0 + } else { + a / b + } + } + "idiv" => { + if b as i64 == 0 { + 0.0 + } else { + ((a as i64) / (b as i64)) as f64 + } + } + "mod" => { + if b as i64 == 0 { + 0.0 + } else { + ((a as i64) % (b as i64)) as f64 + } + } + "atan" => a.atan2(b).to_degrees().rem_euclid(360.0), + "exp" => a.powf(b), + "max" => a.max(b), + _ => a.min(b), + }; + self.push(V::Num(if v.is_finite() { v } else { 0.0 })); + } + "neg" | "abs" | "sqrt" | "sin" | "cos" | "ln" | "log" | "cvi" | "cvr" | "round" | "floor" | "ceiling" | "truncate" => { + let a = match self.pop() { + V::Str(s) => parse_num(&s.borrow()).unwrap_or(0.0), + v => v.num().unwrap_or(0.0), + }; + let v = match n { + "neg" => -a, + "abs" => a.abs(), + "sqrt" => a.max(0.0).sqrt(), + "sin" => a.to_radians().sin(), + "cos" => a.to_radians().cos(), + "ln" => a.ln(), + "log" => a.log10(), + "cvi" | "truncate" => a.trunc(), + "round" => (a + 0.5).floor(), + "floor" => a.floor(), + "ceiling" => a.ceil(), + _ => a, + }; + self.push(V::Num(if v.is_finite() { v } else { 0.0 })); + } + // Comparison and logic. + "eq" | "ne" => { + let b = self.pop(); + let a = self.pop(); + let eq = match (&a, &b) { + (V::Num(x), V::Num(y)) => x == y, + (V::Bool(x), V::Bool(y)) => x == y, + (V::Null, V::Null) => true, + (V::Array(x), V::Array(y)) => Rc::ptr_eq(x, y), + (V::Dict(x), V::Dict(y)) => Rc::ptr_eq(x, y), + (x, y) => !x.key().is_empty() && x.key() == y.key(), + }; + self.push(V::Bool(eq == (n == "eq"))); + } + "gt" | "ge" | "lt" | "le" => { + let b = self.popn(); + let a = self.popn(); + self.push(V::Bool(match n { + "gt" => a > b, + "ge" => a >= b, + "lt" => a < b, + _ => a <= b, + })); + } + "not" => match self.pop() { + V::Bool(b) => self.push(V::Bool(!b)), + v => self.push(V::Num(!(v.num().unwrap_or(0.0) as i64) as f64)), + }, + "and" | "or" | "xor" => { + let b = self.pop(); + let a = self.pop(); + match (a, b) { + (V::Bool(x), V::Bool(y)) => self.push(V::Bool(match n { + "and" => x && y, + "or" => x || y, + _ => x ^ y, + })), + (x, y) => { + let (x, y) = (x.num().unwrap_or(0.0) as i64, y.num().unwrap_or(0.0) as i64); + self.push(V::Num(match n { + "and" => x & y, + "or" => x | y, + _ => x ^ y, + } as f64)); + } + } + } + "true" => self.push(V::Bool(true)), + "false" => self.push(V::Bool(false)), + "null" => self.push(V::Null), + // Control. + "exec" => { + let v = self.pop(); + self.exec_obj(v, src)?; + } + "if" => { + let p = self.pop(); + if let V::Bool(true) = self.pop() { + self.exec_obj(p, src)?; + } + } + "ifelse" => { + let f = self.pop(); + let t = self.pop(); + let c = matches!(self.pop(), V::Bool(true)); + self.exec_obj(if c { t } else { f }, src)?; + } + "repeat" => { + let p = self.pop(); + let k = self.popn().clamp(0.0, 1e6) as usize; + for _ in 0..k { + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + "for" => { + let p = self.pop(); + let lim = self.popn(); + let inc = self.popn(); + let mut i = self.popn(); + let mut guard = 0; + while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { + self.push(V::Num(i)); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + i += inc; + guard += 1; + } + } + "loop" => { + let p = self.pop(); + for _ in 0..1_000_000 { + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + "forall" => { + let p = self.pop(); + match self.pop() { + V::Dict(d) => { + let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + for (k, v) in items { + self.push(V::Lit(k.into())); + self.push(v); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + V::Str(s) => { + let bytes = s.borrow().clone(); + for c in bytes { + self.push(V::Num(c as f64)); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + a => { + for v in a.items().unwrap_or_default() { + self.push(v); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + } + } + "exit" => return Err(Flow::Exit), + "stop" => return Err(Flow::Stop), + "stopped" => { + let p = self.pop(); + let stopped = match self.exec_obj(p, src) { + Err(Flow::Quit) => return Err(Flow::Quit), + Err(_) => true, + Ok(()) => false, + }; + self.push(V::Bool(stopped)); + } + "quit" => return Err(Flow::Quit), + "showpage" | "copypage" => {} + // Dictionaries. + "def" => { + let v = self.pop(); + let k = self.pop().key(); + if let Some(d) = self.dicts.last() { + d.borrow_mut().insert(k, v); + } + } + "store" => { + let v = self.pop(); + let k = self.pop().key(); + let d = self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).or(self.dicts.last()).cloned(); + if let Some(d) = d { + d.borrow_mut().insert(k, v); + } + } + "load" => { + let k = self.pop().key(); + let v = self.lookup(&k).unwrap_or(V::Name(k.into())); + self.push(v); + } + "undef" => { + let k = self.pop().key(); + if let V::Dict(d) = self.pop() { + d.borrow_mut().remove(&k); + } + } + "dict" => { + self.pop(); + self.push(V::Dict(new_dict())); + } + "begin" => { + if let V::Dict(d) = self.pop() + && self.dicts.len() < 64 + { + self.dicts.push(d); + } + } + "end" => { + if self.dicts.len() > 1 { + self.dicts.pop(); + } + } + "currentdict" => { + let d = self.dicts.last().cloned().unwrap_or_else(new_dict); + self.push(V::Dict(d)); + } + "userdict" | "globaldict" | "systemdict" | "statusdict" | "errordict" | "$error" => { + let d = self.dicts[0].clone(); + self.push(V::Dict(d)); + } + "where" => { + let k = self.pop().key(); + match self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).cloned() { + Some(d) => { + self.push(V::Dict(d)); + self.push(V::Bool(true)); + } + None => self.push(V::Bool(false)), + } + } + "known" => { + let k = self.pop().key(); + let v = match self.pop() { + V::Dict(d) => d.borrow().contains_key(&k), + _ => false, + }; + self.push(V::Bool(v)); + } + "countdictstack" => self.push(V::Num(self.dicts.len() as f64)), + "cleardictstack" => self.dicts.truncate(1), + "findresource" | "findfont" | "findencoding" => { + if n == "findresource" { + self.pop(); + } + self.pop(); + self.push(V::Dict(new_dict())); + } + "defineresource" => { + self.pop(); + let v = self.pop(); + self.pop(); + self.push(v); + } + "resourcestatus" => { + self.pop(); + self.pop(); + self.push(V::Bool(false)); + } + // Arrays and strings. + "array" => { + let k = self.popn().clamp(0.0, 1e6) as usize; + self.push(array(vec![V::Null; k])); + } + "string" => { + let k = self.popn().clamp(0.0, 1e7) as usize; + self.push(V::Str(Rc::new(RefCell::new(vec![0; k])))); + } + "length" => { + let l = match self.pop() { + V::Str(s) => s.borrow().len(), + V::Dict(d) => d.borrow().len(), + V::Name(s) | V::Lit(s) => s.len(), + v => v.items().map_or(0, |i| i.len()), + }; + self.push(V::Num(l as f64)); + } + "get" => { + let k = self.pop(); + let v = match self.pop() { + V::Dict(d) => d.borrow().get(&k.key()).cloned().unwrap_or(V::Null), + V::Str(s) => V::Num(s.borrow().get(k.num().unwrap_or(0.0) as usize).copied().unwrap_or(0) as f64), + a => a.items().and_then(|i| i.get(k.num().unwrap_or(0.0) as usize).cloned()).unwrap_or(V::Null), + }; + self.push(v); + } + "put" => { + let v = self.pop(); + let k = self.pop(); + match self.pop() { + V::Dict(d) => { + d.borrow_mut().insert(k.key(), v); + } + V::Array(a) => { + let i = k.num().unwrap_or(0.0) as usize; + if let Some(slot) = a.borrow_mut().get_mut(i) { + *slot = v; + } + } + V::Str(s) => { + let i = k.num().unwrap_or(0.0) as usize; + if let Some(slot) = s.borrow_mut().get_mut(i) { + *slot = v.num().unwrap_or(0.0) as u8; + } + } + _ => {} + } + } + "getinterval" => { + let c = self.popn().max(0.0) as usize; + let i = self.popn().max(0.0) as usize; + match self.pop() { + V::Str(s) => { + let b = s.borrow(); + let part = b.get(i.min(b.len())..(i + c).min(b.len())).unwrap_or(&[]).to_vec(); + drop(b); + self.push(V::Str(Rc::new(RefCell::new(part)))); + } + a => { + let items = a.items().unwrap_or_default(); + let part = items.get(i.min(items.len())..(i + c).min(items.len())).unwrap_or(&[]).to_vec(); + self.push(array(part)); + } + } + } + "putinterval" => { + self.pop(); + self.pop(); + self.pop(); + } + "aload" => { + let a = self.pop(); + for v in a.items().unwrap_or_default() { + self.push(v); + } + self.push(a); + } + "astore" => { + let a = self.pop(); + if let V::Array(arr) = &a { + let k = arr.borrow().len().min(self.st.len()); + let items = self.st.split_off(self.st.len() - k); + *arr.borrow_mut() = items; + } + self.push(a); + } + "cvx" => match self.pop() { + V::Array(a) => self.push(V::Proc(Rc::new(a.borrow().clone()))), + V::Lit(s) => self.push(V::Name(s)), + V::Str(s) => { + // Executable string: tokenize it as a procedure. + let bytes = s.borrow().clone(); + let mut sub = Src { d: &bytes, pos: 0 }; + let mut body = vec![]; + while let Some(t) = sub.token(0) { + body.push(t); + } + self.push(V::Proc(Rc::new(body))); + } + v => self.push(v), + }, + "cvlit" => match self.pop() { + V::Proc(p) => self.push(array(p.as_ref().clone())), + V::Name(s) => self.push(V::Lit(s)), + v => self.push(v), + }, + "cvn" => { + let k = self.pop().key(); + self.push(V::Lit(k.into())); + } + "cvs" => { + self.pop(); + let k = self.pop().key(); + self.push(V::Str(Rc::new(RefCell::new(k.into_bytes())))); + } + "xcheck" => { + let v = self.pop(); + self.push(V::Bool(matches!(v, V::Proc(_) | V::Name(_)))); + } + "type" => { + let t = match self.pop() { + V::Num(_) => "realtype", + V::Bool(_) => "booleantype", + V::Name(_) | V::Lit(_) => "nametype", + V::Str(_) => "stringtype", + V::Proc(_) | V::Array(_) => "arraytype", + V::Dict(_) => "dicttype", + V::Mark => "marktype", + V::Null => "nulltype", + V::File => "filetype", + }; + self.push(V::Name(t.into())); + } + "bind" | "readonly" | "executeonly" | "noaccess" => {} + "languagelevel" => self.push(V::Num(3.0)), + "version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))), + "save" => { + self.saved.push(self.gs.clone()); + self.gs.depth = self.b.depth(); + self.push(V::Null); + } + "restore" => { + self.pop(); + self.grestore(); + } + "currentfile" => self.push(V::File), + "readstring" | "readhexstring" | "readline" => { + self.pop(); + self.pop(); + self.push(V::Str(Rc::new(RefCell::new(vec![])))); + self.push(V::Bool(false)); + } + "flushfile" | "closefile" | "print" | "==" | "=" | "pstack" | "setpagedevice" | "setglobal" | "setpacking" | "setshared" | "setobjectformat" + | "setsmoothness" => { + if !matches!(n, "pstack") { + self.pop(); + } + } + "currentglobal" | "currentpacking" | "currentshared" => self.push(V::Bool(false)), + "vmstatus" => { + for _ in 0..3 { + self.push(V::Num(0.0)); + } + } + // Graphics state. + "gsave" => { + let mut g = self.gs.clone(); + g.depth = self.b.depth(); + self.saved.push(g); + } + "grestore" => self.grestore(), + "grestoreall" => { + while !self.saved.is_empty() { + self.grestore(); + } + } + "initgraphics" => { + self.gs.ctm = Affine::IDENTITY; + self.gs.lw = 1.0; + self.gs.color = [0.0; 3]; + } + "setlinewidth" => self.gs.lw = self.popn(), + "currentlinewidth" => self.push(V::Num(self.gs.lw)), + "setlinecap" => self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(self.popn().max(0.0) as usize).min(2)], + "setlinejoin" => self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(self.popn().max(0.0) as usize).min(2)], + "setmiterlimit" => self.gs.miter = self.popn(), + "setdash" => { + let off = self.popn(); + let a: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); + self.gs.dash = (!a.is_empty() && a.iter().any(|v| *v > 0.0)).then_some((a, off)); + } + "setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "setscreen" | "settransfer" => { + self.pop(); + } + "setgray" => { + let g = self.popn().clamp(0.0, 1.0); + self.gs.color = [g; 3]; + } + "setrgbcolor" => { + let b = self.popn(); + let g = self.popn(); + let r = self.popn(); + self.gs.color = [r, g, b].map(|v| v.clamp(0.0, 1.0)); + } + "sethsbcolor" => { + let v = self.popn().clamp(0.0, 1.0); + let s = self.popn().clamp(0.0, 1.0); + let h = self.popn().rem_euclid(1.0) * 6.0; + let (i, f) = (h.floor() as i32, h.fract()); + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + self.gs.color = match i { + 0 => [v, t, p], + 1 => [q, v, p], + 2 => [p, v, t], + 3 => [p, q, v], + 4 => [t, p, v], + _ => [v, p, q], + }; + } + "setcmykcolor" => { + let k = self.popn().clamp(0.0, 1.0); + let y = self.popn().clamp(0.0, 1.0); + let m = self.popn().clamp(0.0, 1.0); + let c = self.popn().clamp(0.0, 1.0); + self.gs.color = [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]; + } + "setcolorspace" => { + self.pop(); + } + "setcolor" => { + // Device colour by operand count (patterns are not drawn). + let k = self.st.iter().rev().take_while(|v| matches!(v, V::Num(_))).count().min(4); + let c: Vec = self.st.split_off(self.st.len() - k).iter().filter_map(V::num).collect(); + self.gs.color = match c.len() { + 1 => [c[0]; 3], + 3 => [c[0], c[1], c[2]], + 4 => [(1.0 - c[0]) * (1.0 - c[3]), (1.0 - c[1]) * (1.0 - c[3]), (1.0 - c[2]) * (1.0 - c[3])], + _ => self.gs.color, + }; + } + "currentgray" => self.push(V::Num((self.gs.color[0] + self.gs.color[1] + self.gs.color[2]) / 3.0)), + "currentrgbcolor" => { + for c in self.gs.color { + self.push(V::Num(c)); + } + } + // Coordinates. + "matrix" => self.push(matrix_val(Affine::IDENTITY)), + "identmatrix" | "defaultmatrix" => { + self.pop(); + self.push(matrix_val(Affine::IDENTITY)); + } + "currentmatrix" => { + self.pop(); + self.push(matrix_val(self.gs.ctm)); + } + "setmatrix" => { + let m = self.pop(); + self.gs.ctm = matrix_of(&m).unwrap_or(Affine::IDENTITY); + } + "concat" => { + let m = self.pop(); + if let Some(m) = matrix_of(&m) { + self.gs.ctm *= m; + } + } + "concatmatrix" => { + self.pop(); + let b = self.pop(); + let a = self.pop(); + let (a, b) = (matrix_of(&a).unwrap_or(Affine::IDENTITY), matrix_of(&b).unwrap_or(Affine::IDENTITY)); + self.push(matrix_val(b * a)); + } + "invertmatrix" => { + self.pop(); + let a = self.pop(); + self.push(matrix_val(matrix_of(&a).unwrap_or(Affine::IDENTITY).inverse())); + } + "translate" | "scale" => { + let top = self.pop(); + let (m_out, y) = match top { + V::Array(_) => (Some(top), self.popn()), + v => (None, v.num().unwrap_or(0.0)), + }; + let x = self.popn(); + let t = if n == "translate" { Affine::translate((x, y)) } else { Affine::scale_non_uniform(x, y) }; + match m_out { + Some(_) => self.push(matrix_val(t)), + None => self.gs.ctm *= t, + } + } + "rotate" => { + let top = self.pop(); + match top { + V::Array(_) => { + let a = self.popn(); + self.push(matrix_val(Affine::rotate(a.to_radians()))); + } + v => self.gs.ctm *= Affine::rotate(v.num().unwrap_or(0.0).to_radians()), + } + } + "transform" | "itransform" | "dtransform" | "idtransform" => { + let top = self.pop(); + let (m, y) = match top { + V::Array(_) | V::Proc(_) => (matrix_of(&top).unwrap_or(Affine::IDENTITY), self.popn()), + v => (self.gs.ctm, v.num().unwrap_or(0.0)), + }; + let x = self.popn(); + let m = if n.starts_with('i') { m.inverse() } else { m }; + let p = if n.contains('d') { + let c = m.as_coeffs(); + Point::new(c[0] * x + c[2] * y, c[1] * x + c[3] * y) + } else { + m * Point::new(x, y) + }; + self.push(V::Num(p.x)); + self.push(V::Num(p.y)); + } + // Paths. + "newpath" => { + self.gs.path = BezPath::new(); + self.gs.cur = None; + self.gs.start = None; + } + "moveto" => { + let p = self.user_point(); + self.move_to(p); + } + "rmoveto" => { + let d = self.user_point(); + let c = self.current_user(); + self.move_to(Point::new(c.x + d.x, c.y + d.y)); + } + "lineto" => { + let p = self.user_point(); + self.line_to(p); + } + "rlineto" => { + let d = self.user_point(); + let c = self.current_user(); + self.line_to(Point::new(c.x + d.x, c.y + d.y)); + } + "curveto" | "rcurveto" => { + let p3 = self.user_point(); + let p2 = self.user_point(); + let p1 = self.user_point(); + if n == "rcurveto" { + let c = self.current_user(); + let o = |p: Point| Point::new(c.x + p.x, c.y + p.y); + self.curve_to(o(p1), o(p2), o(p3)); + } else { + self.curve_to(p1, p2, p3); + } + } + "closepath" => { + if self.gs.cur.is_some() { + self.gs.path.close_path(); + self.gs.cur = self.gs.start; + } + } + "arc" => self.arc(false), + "arcn" => self.arc(true), + "currentpoint" => { + let c = self.current_user(); + self.push(V::Num(c.x)); + self.push(V::Num(c.y)); + } + "pathbbox" => { + let r = kurbo::Shape::bounding_box(&self.gs.path); + let inv = self.gs.ctm.inverse(); + let (a, b) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1)); + for v in [a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)] { + self.push(V::Num(v)); + } + } + "flattenpath" | "reversepath" | "strokepath" | "initclip" => {} + "clippath" => { + let p = self.page; + self.gs.path = kurbo::Shape::to_path(&kurbo::Rect::new(p[0], p[1], p[2], p[3]), 0.1); + } + // Painting. + "fill" => self.paint(Some(FillRule::NonZero), false), + "eofill" => self.paint(Some(FillRule::EvenOdd), false), + "stroke" => self.paint(None, true), + "clip" | "eoclip" => { + let rule = if n == "clip" { FillRule::NonZero } else { FillRule::EvenOdd }; + let p = self.gs.path.clone(); + if !p.elements().iter().all(|e| matches!(e, PathEl::MoveTo(_))) { + self.b.clip(p, rule); + } + } + "rectfill" | "rectstroke" | "rectclip" => { + let paths = self.rect_path(); + if n == "rectstroke" && matches!(self.st.last(), Some(V::Array(_))) { + self.pop(); + } + let mut all = BezPath::new(); + for p in paths { + all.extend(p); + } + match n { + "rectclip" => self.b.clip(all, FillRule::NonZero), + _ => { + let saved = std::mem::replace(&mut self.gs.path, all); + self.paint(if n == "rectfill" { Some(FillRule::NonZero) } else { None }, n == "rectstroke"); + self.gs.path = saved; + } + } + } + "shfill" => { + 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)); + } + // Images: operands popped, inline data skipped. + "image" | "imagemask" | "colorimage" => { + let mut reads_file = false; + let check = |v: &V, rf: &mut bool| { + if matches!(v, V::File) || matches!(v, V::Proc(p) if p.iter().any(|x| matches!(x, V::Name(n) if &**n == "currentfile"))) { + *rf = true; + } + }; + if n == "colorimage" { + let ncomp = self.popn().max(1.0) as usize; + let multi = matches!(self.pop(), V::Bool(true)); + for _ in 0..if multi { ncomp.min(4) } else { 1 } { + let v = self.pop(); + check(&v, &mut reads_file); + } + for _ in 0..4 { + self.pop(); + } + } else { + match self.pop() { + V::Dict(d) => { + if let Some(ds) = d.borrow().get("DataSource") { + check(ds, &mut reads_file); + } + } + top => { + check(&top, &mut reads_file); + for _ in 0..4 { + self.pop(); + } + } + } + } + if reads_file { + src.skip_image_data(); + } + self.b.skip("image"); + } + _ => { + if self.unknown.len() < 32 && !self.unknown.iter().any(|u| u == n) { + self.unknown.push(n.to_string()); + } + } + } + Ok(()) + } +} + +/// The PostScript section of an EPS file (DOS EPS binary header handled) and its bounding box +/// (`%%HiResBoundingBox` preferred). +pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> { + let ps = if bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && bytes.len() >= 12 { + let off = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; + let len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize; + bytes.get(off..off.checked_add(len)?.min(bytes.len()))? + } else { + bytes + }; + let find_box = |key: &[u8]| -> Option<[f64; 4]> { + let mut from = 0; + while let Some(p) = ps[from..].windows(key.len()).position(|w| w == key) { + let s = from + p + key.len(); + let e = ps[s..].iter().position(|c| matches!(c, b'\n' | b'\r')).map_or(ps.len(), |q| s + q); + let v: Vec = String::from_utf8_lossy(&ps[s..e]).split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if v.len() == 4 && v[2] > v[0] && v[3] > v[1] { + return Some([v[0], v[1], v[2], v[3]]); + } + from = s; + } + None + }; + let bbox = find_box(b"%%HiResBoundingBox:").or_else(|| find_box(b"%%BoundingBox:"))?; + Some((ps, bbox)) +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs new file mode 100644 index 00000000..b4e91f2b --- /dev/null +++ b/crates/pdf/src/tests.rs @@ -0,0 +1,150 @@ +//! Test files are generated here (no third-party samples). + +use super::*; + +use crate::write::pdf; + +const CONTENT: &str = "/OC /MC0 BDC\n0 1 0 rg 0 0 200 100 re f\nEMC\n\ +/OC /MC1 BDC\nq 10 10 80 80 re W n\n1 0 0 rg 0 0 50 100 re f\nQ\n\ +/Pattern cs /P0 scn 100 0 100 50 re f\n\ +0 0 1 RG 4 w 120 80 m 180 80 l S\n\ +BT /F1 12 Tf (ignored) Tj ET\nEMC\n"; + +pub(crate) fn sample_pdf(object_stream: bool) -> Vec { + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Properties << /MC0 5 0 R /MC1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>"; + let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [100 0 200 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> >>"; + let mut objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), None), + (3, page.to_string(), None), + (4, "<< >>".to_string(), Some(CONTENT.as_bytes().to_vec())), + (7, pattern.to_string(), None), + ]; + let ocg5 = "<< /Type /OCG /Name (Background) >>"; + let ocg6 = "<< /Type /OCG /Name >>"; // "Art" in UTF-16BE + if object_stream { + let body = format!("{ocg5} {ocg6}"); + let head = format!("5 0 6 {} ", ocg5.len() + 1); + let data = format!("{head}{body}"); + objs.push((8, format!("<< /Type /ObjStm /N 2 /First {} >>", head.len()), Some(data.into_bytes()))); + } else { + objs.push((5, ocg5.to_string(), None)); + objs.push((6, ocg6.to_string(), None)); + } + pdf(&objs, 1) +} + +fn px(doc: &Doc, x: i64, y: i64) -> [f32; 4] { + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(doc, w, h, 1.0); + img.get(x, y) +} + +#[test] +fn pdf_paths_clip_gradient_and_layers() { + for objstm in [false, true] { + let bytes = sample_pdf(objstm); + assert_eq!(sniff(&bytes), Some(Format::Pdf)); + assert_eq!(page_count(&bytes), 1); + let doc = parse(&bytes).unwrap(); + assert_eq!((doc.width, doc.height), (200.0, 100.0)); + assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}"); + assert!(doc.skipped.contains(&"text".to_string())); + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + let at = |x: i64, y: i64| img.get(x, y); + // Red rectangle clipped to x ≥ 10, over the green background. + assert!(at(30, 50)[0] > 0.99 && at(30, 50)[1] < 0.01, "{:?}", at(30, 50)); + assert!(at(5, 50)[1] > 0.99 && at(5, 50)[0] < 0.01, "clipped out: {:?}", at(5, 50)); + assert!(at(70, 50)[1] > 0.99, "{:?}", at(70, 50)); + // Axial shading, red → blue left to right (in the lower half: PDF y is up). + let (l, r) = (at(110, 75), at(190, 75)); + assert!(l[0] > 0.8 && l[2] < 0.2 && r[2] > 0.8 && r[0] < 0.2, "{l:?} {r:?}"); + assert!(at(150, 25)[1] > 0.99, "the gradient stays in its rectangle: {:?}", at(150, 25)); + // Stroked line (PDF y = 80 → 20 px from the top). + assert!(at(150, 20)[2] > 0.99 && at(150, 20)[1] < 0.01, "{:?}", at(150, 20)); + // One layer at a time. + let bg = layer_doc(&doc, 0); + assert!(px(&bg, 30, 50)[1] > 0.99); + let art = layer_doc(&doc, 1); + assert_eq!(px(&art, 5, 50)[3], 0.0); + // Continuous rasterisation: twice the size stays sharp at the clip edge. + let big = effectcraft_svg::rasterize(&doc, 400, 200, 2.0); + assert!(big.get(21, 100)[0] > 0.99 && big.get(18, 100)[1] > 0.99); + } +} + +#[test] +fn pdf_forms_rotation_cmyk_and_alpha() { + let content = "q 2 0 0 2 0 0 cm /Fm0 Do Q\n/GS0 gs 0 0 0 1 k 0 0 10 10 re f\n"; + let form = "0 1 1 0 k 0 0 20 20 re f"; + let objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 /Rotate 90 >>".to_string(), None), + ( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /XObject << /Fm0 5 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >> /Contents 4 0 R >>" + .to_string(), + None, + ), + (4, "<< >>".to_string(), Some(content.as_bytes().to_vec())), + (5, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>".to_string(), Some(form.as_bytes().to_vec())), + ]; + let doc = parse(&pdf(&objs, 1)).unwrap(); + // Rotated a quarter turn: 50 × 100. + assert_eq!((doc.width, doc.height), (50.0, 100.0)); + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + // The form (CMYK red, clipped to its 10×10 box, scaled ×2) covers PDF (0..20, 0..20); the + // half-transparent black square covers PDF (0..10, 0..10). /Rotate 90: PDF (x, y) → (y, x). + let red = img.get(15, 15); + assert!(red[0] > 0.99 && red[1] < 0.01 && red[3] > 0.99, "{red:?}"); + let dark = img.get(5, 5); + assert!((dark[0] - 0.5).abs() < 0.02 && dark[3] > 0.99, "{dark:?}"); + assert_eq!(img.get(30, 30)[3], 0.0, "form clipped to its bounding box"); +} + +const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\ +/m {moveto} bind def /l {lineto} bind def\n\ +/cm { 6 array astore concat } bind def\n\ +/box { 4 dict begin /h exch def /w exch def /y exch def /x exch def x y m w 0 rlineto 0 h rlineto w neg 0 rlineto closepath end } bind def\n\ +1 0 0 setrgbcolor\n10 10 30 30 box fill\n\ +gsave 0 0 1 setrgbcolor 70 50 20 0 360 arc fill grestore\n\ +gsave 1 0 0 1 50 0 cm 0 1 0 setrgbcolor 0 0 10 10 box fill grestore\n\ +0 setgray 2 setlinewidth 0 90 m 100 90 l stroke\n\ +/Helvetica findfont 12 scalefont setfont 5 5 moveto (text) show\n\ +showpage\n%%EOF\n"; + +#[test] +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())); + 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)); + assert!(at(55, 95)[1] > 0.99, "translated green box {:?}", at(55, 95)); + let line = at(50, 10); + assert!(line[3] > 0.99 && line[0] < 0.01, "black line {line:?}"); + assert_eq!(at(95, 70)[3], 0.0); + // DOS EPS binary header (with a fake TIFF preview after the PostScript). + let ps = EPS.as_bytes(); + let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6]; + dos.extend_from_slice(&30u32.to_le_bytes()); + dos.extend_from_slice(&(ps.len() as u32).to_le_bytes()); + dos.extend_from_slice(&[0; 18]); + dos.extend_from_slice(ps); + dos.extend_from_slice(b"II*\0 not a real preview"); + let d2 = parse(&dos).unwrap(); + assert_eq!(d2.root.children.len(), doc.root.children.len()); + assert_eq!(codec("art.eps", &dos), Some("EPS")); + assert_eq!(codec("art.ai", &sample_pdf(false)), Some("AI")); + assert_eq!(parse(b"hello"), Err(Error::NotVector)); +} + +#[test] +fn filters_decode() { + assert_eq!(object::ascii85(b"<~87cURD]i,\"Ebo80~>"), b"Hello World!".to_vec()); + let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6); + assert_eq!(object::inflate(&z).unwrap(), b"abc"); +} diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs new file mode 100644 index 00000000..9e54bdc3 --- /dev/null +++ b/crates/pdf/src/write.rs @@ -0,0 +1,32 @@ +//! A minimal PDF writer for generating test files (EffectCraft does not export PDF). + +/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed), a +/// cross-reference table and a trailer naming object `root` as the catalog. +pub fn pdf(objects: &[(u32, String, Option>)], root: u32) -> Vec { + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); + let mut offsets = vec![]; + for (n, dict, stream) in objects { + offsets.push((*n, out.len())); + match stream { + Some(data) => { + let z = miniz_oxide::deflate::compress_to_vec_zlib(data, 6); + let d = format!("{} /Filter /FlateDecode /Length {} >>", dict.trim_end().trim_end_matches(">>"), z.len()); + out.extend_from_slice(format!("{n} 0 obj\n{d}\nstream\n").as_bytes()); + out.extend_from_slice(&z); + out.extend_from_slice(b"\nendstream\nendobj\n"); + } + None => out.extend_from_slice(format!("{n} 0 obj\n{dict}\nendobj\n").as_bytes()), + } + } + let xref = out.len(); + let max = objects.iter().map(|o| o.0).max().unwrap_or(0); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", max + 1).as_bytes()); + for i in 1..=max { + match offsets.iter().find(|o| o.0 == i) { + Some((_, off)) => out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()), + None => out.extend_from_slice(b"0000000000 65535 f \n"), + } + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root {root} 0 R >>\nstartxref\n{xref}\n%%EOF\n", max + 1).as_bytes()); + out +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 470c4edc..5f90c291 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -826,9 +826,9 @@ pub struct Proxy { pub enabled: bool, } -/// A layer of a layered still (a Photoshop document) used as footage -/// (File ▸ Import ▸ Composition / Composition – Retain Layer Sizes, or Choose Layer). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// A layer of a layered still (a Photoshop document, or a PDF / Illustrator / EPS file) used as +/// footage (File ▸ Import ▸ Composition / Composition – Retain Layer Sizes, or Choose Layer). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SourceLayer { /// Layer record index in the file (bottom of the stack = 0). pub index: u32, @@ -836,6 +836,10 @@ pub struct SourceLayer { /// The footage is the layer's own bounds (Retain Layer Sizes) rather than the document size. #[serde(default)] pub layer_size: bool, + /// A Photoshop smart object: the unique id of its embedded file (linked layer data), which + /// is the footage instead of the layer's pixels. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub embedded: Option, } fn one() -> u32 { @@ -852,6 +856,7 @@ pub struct Solid { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type")] +#[allow(clippy::large_enum_variant)] pub enum ItemKind { Folder, Comp(Arc), diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fd9be466..e2c91e60 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -66,7 +66,7 @@ pub trait FootageSource: Send + Sync { /// Footage that can be rasterised at any scale (SVG). pub fn is_vector_footage(f: &Footage) -> bool { - f.codec == "SVG" + matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") } /// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 22b5fead..0536480c 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -170,6 +170,9 @@ pub struct Group { pub transform: Affine, pub opacity: f64, pub children: Vec, + /// Clipping paths in the group's space (after `transform`); the children show only where + /// every one of them covers (PDF / EPS clipping; SVG `clipPath` is not read yet). + pub clip: Vec<(BezPath, FillRule)>, } #[derive(Clone, Debug, PartialEq)] @@ -666,7 +669,7 @@ 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 })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); } "svg" => { // Nested viewport. @@ -685,7 +688,7 @@ impl<'a, 'i> Parser<'a, 'i> { self.node(c, &st, &mut children, depth + 1); } (self.vw, self.vh) = saved; - out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children, clip: vec![] })); } "use" => { let Some(target) = self.href(n) else { return }; @@ -706,11 +709,11 @@ impl<'a, 'i> Parser<'a, 'i> { for c in target.children() { self.node(c, &sst, &mut inner, depth + 1); } - children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner })); + children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner, clip: vec![] })); } else { self.node(target, &st, &mut children, depth + 1); } - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); } "path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => { if !st.visible { @@ -750,7 +753,7 @@ impl<'a, 'i> Parser<'a, 'i> { self.node(c, &st, &mut children, depth + 1); } if !children.is_empty() { - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); } } } @@ -942,7 +945,7 @@ pub fn parse(bytes: &[u8]) -> Result { p.node(c, &rst, &mut children, 1); } let transform = viewbox_transform(root, dw, dh); - Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children }, skipped: std::mem::take(&mut p.skipped) }) + Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children, clip: vec![] }, skipped: std::mem::take(&mut p.skipped) }) } #[cfg(test)] diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index 2cfb3dd3..de82695b 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -31,6 +31,27 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { if o <= 0.0 { return; } + if !g.clip.is_empty() { + // Draw the children on their own, keep what every clip path covers, composite. + let mut tmp = Image::new(dst.width, dst.height); + for c in &g.children { + draw_node(c, m, 1.0, &mut tmp); + } + 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)| { + for v in p.iter_mut() { + *v *= c; + } + }); + } + over(dst, &tmp, o as f32); + return; + } if g.opacity < 1.0 && g.children.len() > 1 { let mut tmp = Image::new(dst.width, dst.height); for c in &g.children { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index eed7e639..acf5a544 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -813,7 +813,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option None, diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 21928b90..ad904eba 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -35,6 +35,7 @@ const LAYERS: &[(&str, u8)] = &[ ("effects", 2), ("track", 2), ("svg", 2), + ("pdf", 2), ("model", 2), ("render", 3), ("media", 3), @@ -58,6 +59,7 @@ const SAME_LAYER: &[(&str, &str)] = &[ ("path", "keyframe"), ("path", "raster"), ("text", "path"), + ("pdf", "svg"), ("effects", "project"), ("effects", "text"), ("effects", "path"),