mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 22:20:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
30a92ba82f
commit
da0ec526f3
@@ -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;
|
||||
Reference in New Issue
Block a user