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 <noreply@anthropic.com>
This commit is contained in:
parent
30a92ba82f
commit
da0ec526f3
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -28,12 +28,14 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
});
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -101,6 +101,17 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Value> {
|
|||
&& 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<u8>, 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<u64>)> {
|
||||
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<CommandSpec> {
|
|||
"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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<PropGroup> {
|
|||
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<Result<Doc, String>> {
|
||||
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<ItemId>), 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 };
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<std::result::Result<effectcraft_svg::Doc, String>> {
|
||||
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<Option<Footage>> {
|
||||
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<Option<Footage>> {
|
|||
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<Image> {
|
||||
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<Image> {
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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<filmcraft_media::MediaError> 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.
|
||||
|
|
|
|||
|
|
@ -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<Arc<effectcraft_raster::AuxChannels>> {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<bool>,
|
||||
pub skipped: Vec<String>,
|
||||
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<Stroke>) {
|
||||
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<Node>, Vec<bool>, Vec<String>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Cs>,
|
||||
hival: usize,
|
||||
lookup: Vec<u8>,
|
||||
},
|
||||
/// Separation / DeviceN: components → alternate space through a tint transform.
|
||||
Tint {
|
||||
n: usize,
|
||||
alt: Box<Cs>,
|
||||
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<f64> {
|
||||
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<f64> = (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<f64>,
|
||||
c1: Vec<f64>,
|
||||
n: f64,
|
||||
},
|
||||
/// Type 3.
|
||||
Stitch {
|
||||
domain: [f64; 2],
|
||||
funcs: Vec<Func>,
|
||||
bounds: Vec<f64>,
|
||||
encode: Vec<f64>,
|
||||
},
|
||||
/// Type 0 with one input.
|
||||
Sampled {
|
||||
domain: [f64; 2],
|
||||
size: usize,
|
||||
outputs: usize,
|
||||
encode: [f64; 2],
|
||||
decode: Vec<f64>,
|
||||
samples: Vec<f64>,
|
||||
},
|
||||
/// An array of one-output functions.
|
||||
Array(Vec<Func>),
|
||||
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<f64> {
|
||||
let t = input.first().copied().unwrap_or(0.0);
|
||||
self.eval1(t)
|
||||
}
|
||||
|
||||
pub fn eval1(&self, t: f64) -> Vec<f64> {
|
||||
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<f64> {
|
||||
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<u16> = 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()
|
||||
}
|
||||
|
|
@ -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<Format> {
|
||||
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<Doc, Error> {
|
||||
parse_page(bytes, 0)
|
||||
}
|
||||
|
||||
/// Parse page `index` of a PDF (EPS files have one page).
|
||||
pub fn parse_page(bytes: &[u8], index: usize) -> Result<Doc, Error> {
|
||||
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<Node>, flags: Vec<bool>, skipped: Vec<String>, width: f64, height: f64, to_doc: Affine) -> Doc {
|
||||
let mut layers: Vec<Node> = vec![];
|
||||
let mut loose: Vec<Node> = vec![];
|
||||
let flush = |loose: &mut Vec<Node>, layers: &mut Vec<Node>| {
|
||||
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<Doc, Error> {
|
||||
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<Doc, Error> {
|
||||
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<String> {
|
||||
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;
|
||||
|
|
@ -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<u8>),
|
||||
Array(Vec<Obj>),
|
||||
Dict(Dict),
|
||||
Ref(u32, u16),
|
||||
/// A stream: its dictionary and raw (still encoded) data.
|
||||
Stream(Dict, Vec<u8>),
|
||||
/// A content-stream operator (or any bare keyword).
|
||||
Op(String),
|
||||
}
|
||||
|
||||
pub type Dict = HashMap<String, Obj>;
|
||||
|
||||
impl Obj {
|
||||
pub fn num(&self) -> Option<f64> {
|
||||
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<Obj> {
|
||||
self.next_depth(0)
|
||||
}
|
||||
|
||||
fn next_depth(&mut self, depth: usize) -> Option<Obj> {
|
||||
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<u8> {
|
||||
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<f64> {
|
||||
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::<f64>().ok().or_else(|| s.trim_start_matches(['+', '-']).parse::<f64>().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<Vec<u8>> {
|
||||
let filters: Vec<String> = 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<Option<Dict>> = 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<Vec<u8>> {
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| {
|
||||
// Truncated streams: keep what inflates.
|
||||
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<u8> {
|
||||
let mut hex: Vec<u8> = 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<u8> {
|
||||
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<u8> {
|
||||
let mut out = vec![];
|
||||
let mut table: Vec<Vec<u8>> = (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<Vec<u8>> = 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<u8> {
|
||||
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<u8> {
|
||||
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<u32, Obj>,
|
||||
pub trailers: Vec<Dict>,
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
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::<u32>().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<u8>)> = 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<f64> {
|
||||
self.get(d, key)?.num()
|
||||
}
|
||||
|
||||
/// Numbers of an array (references resolved).
|
||||
pub fn nums(&self, o: &Obj) -> Vec<f64> {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Page> {
|
||||
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<Dict>, Option<Vec<f64>>, Option<Vec<f64>>, i32), out: &mut Vec<Page>, 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<u8> {
|
||||
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>, 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<Point>,
|
||||
start: Option<Point>,
|
||||
pending_clip: Option<FillRule>,
|
||||
marked: Vec<Option<usize>>,
|
||||
/// 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<Obj> = 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::<Vec<f64>>();
|
||||
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<String> {
|
||||
// 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<Paint> {
|
||||
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<FillRule>, 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<Gradient> {
|
||||
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<f64> = (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"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<u8> {
|
||||
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 <FEFF00410072007400> >>"; // "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");
|
||||
}
|
||||
|
|
@ -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<Vec<u8>>)], root: u32) -> Vec<u8> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<Comp>),
|
||||
|
|
|
|||
|
|
@ -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`]).
|
||||
|
|
|
|||
|
|
@ -170,6 +170,9 @@ pub struct Group {
|
|||
pub transform: Affine,
|
||||
pub opacity: f64,
|
||||
pub children: Vec<Node>,
|
||||
/// 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<Doc, Error> {
|
|||
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)]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -813,7 +813,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
|||
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
|
||||
let paths = f(&[
|
||||
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac",
|
||||
"ogg", "opus", "svg", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv",
|
||||
"ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv",
|
||||
]);
|
||||
match (paths.is_empty(), key) {
|
||||
(true, _) => None,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue