Merge branch 'worktree-agent-aadac0ebe6eb9e405'

This commit is contained in:
Brandon Thomas 2026-10-03 04:38:40 -04:00
commit ed46bc0cce
49 changed files with 4956 additions and 90 deletions

12
Cargo.lock generated
View File

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

View File

@ -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" }

View File

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

View File

@ -433,8 +433,8 @@ fn ae(v: [f64; 3]) -> Vec3 {
}
/// Composition ▸ VR ▸ Create VR Environment: renders the active comp's 3D scene in every
/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, with a
/// copy of the scene's layers), a 3:2 cube map of them, and an equirectangular output comp
/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, looking
/// at the scene nested as a collapsed 3D precomp), a 3:2 cube map of them, and an equirectangular output comp
/// (VR Converter, cube map → equirectangular). One undo step.
fn create_vr_environment(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
@ -462,30 +462,41 @@ fn create_vr_environment(s: &mut Session, p: &Value) -> Result<Value> {
id_of(&s.execute("layer.newCamera", json!({"comp": fc, "name": format!("{fname} Camera"), "angleOfView": 90.0, "type": "oneNode"}))?, "layer")?;
s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/position", "value": center}))?;
s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/orientation", "value": o}))?;
// The scene's layers (not its cameras), in the scene's coordinates, under the face
// camera.
let layers: Vec<effectcraft_project::Layer> = src.layers.iter().filter(|l| !l.is_camera()).cloned().collect();
s.edit("Create VR Environment", None, |proj, _| {
let mut next = proj.next_id;
let mut map = std::collections::BTreeMap::new();
let mut copies = vec![];
for l in &layers {
let mut c = l.clone();
super::edit::reid(&mut c, &mut next);
map.insert(l.id, c.id);
copies.push(c);
}
for c in &mut copies {
c.parent = c.parent.and_then(|p| map.get(&p).copied());
if let Some(m) = &mut c.track_matte {
m.layer = map.get(&m.layer).copied().unwrap_or(m.layer);
// The scene, nested as a collapsed 3D precomp whose layer maps the scene's
// coordinates onto the face comp's unchanged (anchor = position): its 3D layers are
// seen through the face camera and edits to the scene show in every face. Lights
// inside a collapsed precomp do not light it (the containing comp's do), so the
// scene's lights are copied next to it.
let pre = id_of(&s.execute("layer.addItem", json!({"comp": fc, "item": cid.0, "time": 0.0}))?, "layer")?;
let mid = [src.width as f64 / 2.0, src.height as f64 / 2.0, 0.0];
s.execute("layer.setSwitch", json!({"comp": fc, "layers": [pre], "switch": "threeD", "value": true}))?;
s.execute("layer.setSwitch", json!({"comp": fc, "layers": [pre], "switch": "collapse", "value": true}))?;
s.execute("prop.set", json!({"comp": fc, "layer": pre, "path": "transform/anchor", "value": mid}))?;
s.execute("prop.set", json!({"comp": fc, "layer": pre, "path": "transform/position", "value": mid}))?;
let lights: Vec<effectcraft_project::Layer> = src.layers.iter().filter(|l| l.is_light()).cloned().collect();
if !lights.is_empty() {
s.edit("Create VR Environment", None, |proj, _| {
let mut next = proj.next_id;
let mut map = std::collections::BTreeMap::new();
let mut copies = vec![];
for l in &lights {
let mut c = l.clone();
super::edit::reid(&mut c, &mut next);
map.insert(l.id, c.id);
copies.push(c);
}
}
proj.next_id = next;
let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?;
comp.layers.extend(copies);
Ok(())
})?;
for c in &mut copies {
c.parent = c.parent.and_then(|p| map.get(&p).copied());
}
proj.next_id = next;
let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?;
let at = comp.layers.len().saturating_sub(1);
for (k, c) in copies.into_iter().enumerate() {
comp.layers.insert(at + k, c);
}
Ok(())
})?;
}
faces.push(fc);
}
let cube = id_of(

View File

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

View File

@ -111,6 +111,17 @@ pub(crate) 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;
@ -179,7 +190,9 @@ pub(crate) 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;
@ -285,6 +298,22 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retai
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"];
@ -449,7 +478,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), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
always,
import
),

View File

@ -1163,7 +1163,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Text",
[],
None,
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, OpenType: discretionaryLigatures?, contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01…ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
has_layers,
set_text
),

View File

@ -465,6 +465,38 @@ pub(crate) fn sanitize(s: &mut Session) {
}
}
/// The OpenType features of a font (`font` / `style`, else the target text layer's style at the
/// caret or its first character), and which Character panel options it sets with its own glyphs
/// (the others are synthesized or have no effect).
pub fn font_features(s: &mut Session, p: &Value) -> Result<Value> {
let (family, style) = match p.get("font").and_then(Value::as_str) {
Some(f) => (f.to_string(), p.get("style").and_then(Value::as_str).unwrap_or("Regular").to_string()),
None => {
let lid = target(s, p, "text.fontFeatures")?;
let doc = layer_doc(s, lid).ok_or_else(|| bad("text.fontFeatures", "not a text layer"))?;
let at = s.state.text_edit.as_ref().filter(|e| e.layer == lid).map_or(0, |e| e.range().start);
let st = doc.style_at(at);
(st.font, st.style)
}
};
let r = effectcraft_text::resolve(&family, &style);
let face = effectcraft_text::fonts::face(r.face);
let feats = face.features();
let has = |t: &str| feats.iter().any(|f| f == t);
let sets: Vec<u32> = (1..=20u32).filter(|n| has(&format!("ss{n:02}"))).collect();
Ok(json!({
"family": face.info.family, "style": face.info.style, "missing": r.missing,
"features": feats,
"options": {
"kerning": has("kern"), "ligatures": has("liga") || has("clig"), "discretionaryLigatures": has("dlig"),
"contextualAlternates": has("calt"), "stylisticAlternates": has("salt"), "stylisticSets": sets,
"swash": has("swsh"), "titling": has("titl"), "ordinals": has("ordn"), "fractions": has("frac"),
"smallCaps": has("smcp"), "allSmallCaps": has("c2sc"), "superscript": has("sups"), "subscript": has("subs"),
"oldStyleFigures": has("onum"), "liningFigures": has("lnum"), "tabularFigures": has("tnum"), "proportionalFigures": has("pnum"),
},
}))
}
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
@ -485,6 +517,15 @@ pub fn specs() -> Vec<CommandSpec> {
edit
),
cmd!("text.endEdit", "Exit Text Editing", [], None, "{}", always_ok, end_edit),
cmd!(
"text.fontFeatures",
"OpenType Features",
[],
None,
"{layer?, font?, style?} → {family, style, features: [tags], options: {smallCaps, superscript, stylisticSets: [n], fractions, …}} (what the font sets with its own glyphs)",
always_ok,
font_features
),
cmd!(
"text.setSelection",
"Set Text Selection",

View File

@ -824,6 +824,11 @@ pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to.
pub fn font_features(family: &str, style: &str) -> Vec<String> {
effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features()
}
/// One row of the Character panel's font menu.
#[derive(Clone, Debug, PartialEq)]
pub struct FontRow {

View File

@ -6,7 +6,9 @@
//! so their blend modes act on the layers below, like Photoshop); type layers become editable
//! text layers; layer masks are baked into the layer's alpha and vector masks become masks;
//! layer effects map to Layer Styles; adjustment layers become adjustment layers with the
//! matching effect (best effort); clipped layers preserve the transparency underneath.
//! matching effect (best effort); clipped layers preserve the transparency underneath. Smart
//! objects whose file is embedded become footage of that file (an embedded Photoshop document's
//! merged image, or the embedded image) placed with the smart object's transform.
use std::sync::Arc;
@ -156,7 +158,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));
@ -164,6 +166,60 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (
(id, (w, h))
}
/// Size of an embedded file's image: the smart object's recorded size, else the embedded
/// document's header (Photoshop, PNG).
fn embedded_size(so: &effectcraft_psd::SmartObject, data: &[u8]) -> Option<(u32, u32)> {
if let Some([w, h]) = so.size.filter(|s| s[0] >= 1.0 && s[1] >= 1.0) {
return Some((w.round() as u32, h.round() as u32));
}
if effectcraft_psd::is_psd(data) && data.len() >= 22 {
let h = u32::from_be_bytes([data[14], data[15], data[16], data[17]]);
let w = u32::from_be_bytes([data[18], data[19], data[20], data[21]]);
return Some((w, h));
}
if data.starts_with(b"\x89PNG") && data.len() >= 24 {
let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
return Some((w, h));
}
None
}
/// A smart object layer as footage of its embedded file, placed by the smart object's
/// transform. `None` when the file is not embedded (linked externally) or unreadable.
fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usize, name: &str) -> Option<Layer> {
let pl = &cx.psd.layers[index];
let so = pl.smart_object.clone()?;
let data = cx.psd.linked_data(&so.uuid)?;
let (w, h) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default();
let f = Footage {
path: cx.path.to_string(),
kind: FootageKind::Still,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: cx.rate,
has_video: true,
alpha: AlphaMode::Straight,
loop_count: 1,
codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() },
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()) }),
..Default::default()
};
let label = if file.is_empty() { name.to_string() } else { file };
let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
cx.items.push(id);
let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None);
let (c, sx, sy, rot) = so.placement(w as f64, h as f64);
set_vec2(&mut l, "transform/position", c);
set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]);
if rot.abs() > 1e-9 {
set_scalar(&mut l, "transform/rotation", rot);
}
Some(l)
}
fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Option<Layer> {
let (index, children) = match n {
Node::Layer(i) => (*i, None),
@ -183,6 +239,8 @@ fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Optio
let mut l = adjustment_layer(proj, comp, &name);
add_adjustment_effect(proj, &mut l, adj, comp, &mut cx.warnings, &name);
l
} else if let Some(l) = pl.smart_object.as_ref().and_then(|_| smart_object_layer(proj, cx, comp, index, &name)) {
l
} else if pl.has_pixels() || pl.fill.is_some() {
let (item, size) = footage_item(proj, cx, index, &name);
let mut l = build::layer(proj, comp, &name, LayerSource::Footage { item }, size, None);

View File

@ -476,8 +476,9 @@ fn create_vr_environment_and_extract_cubemap() {
let c = s.project.comp(ItemId(*f)).unwrap();
assert_eq!((c.width, c.height), (64, 64));
assert!(c.layers.iter().any(|l| l.is_camera()));
assert!(c.layers.iter().any(|l| l.name == aname && l.is_3d()), "the scene's layers");
let _ = main;
let nested = c.layers.iter().find(|l| matches!(l.source, effectcraft_project::LayerSource::Comp { item } if item == main)).expect("the scene, nested");
assert!(nested.switches.collapse && nested.is_3d(), "collapsed 3D precomp");
let _ = &aname;
}
let cube = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap();
assert_eq!((cube.width, cube.height, cube.layers.len()), (192, 128, 6));

View File

@ -202,3 +202,28 @@ fn edit_state_round_trips_through_serde() {
let back: crate::EditorState = serde_json::from_value(j).unwrap();
assert_eq!(back.text_edit, s.state.text_edit);
}
#[test]
fn opentype_features_per_range_render_and_query() {
let (mut s, t) = session("Office 1/2");
s.execute("layer.setText", json!({"layer": t, "range": [7, 10], "fractions": true, "ss01": true})).unwrap();
let d = doc(&s, t);
assert!(!d.style_at(0).opentype.fractions);
assert!(d.style_at(8).opentype.fractions && d.style_at(8).opentype.stylistic_set(1));
// The laid-out glyphs change (frac turns 1/2 into a fraction).
let plain = effectcraft_text::layout_doc(&TextDoc { text: "1/2".into(), ..Default::default() });
let frac = effectcraft_text::layout_doc(&d.slice(7..10));
let ids = |l: &effectcraft_text::TextLayout| l.glyphs.iter().map(|g| g.gid).collect::<Vec<_>>();
assert_ne!(ids(&plain), ids(&frac));
// Undo restores the uniform style.
s.execute("edit.undo", json!({})).unwrap();
assert!(doc(&s, t).is_uniform());
// Font feature query (font by name, or the layer's).
let f = s.execute("text.fontFeatures", json!({"font": "Noto Serif"})).unwrap();
assert_eq!(f["options"]["smallCaps"], true);
assert!(f["features"].as_array().unwrap().iter().any(|t| t == "onum"));
let f = s.execute("text.fontFeatures", json!({"layer": t})).unwrap();
assert_eq!(f["family"], "Inter");
assert!(f["options"]["stylisticSets"].as_array().unwrap().contains(&json!(1)));
assert_eq!(f["options"]["smallCaps"], false);
}

View File

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

View File

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

View File

@ -184,3 +184,153 @@ 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 psd_smart_object_imports_its_embedded_document() {
// The embedded document: red left half, green right half (20×10).
let mut inner = WDoc::new(20, 10);
inner.layers =
vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])];
let inner_bytes = write(&inner);
let mut d = WDoc::new(64, 48);
// Placed at 2× and turned a quarter clockwise: the content's top-left corner at (40, 4).
let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]];
d.layers = vec![
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
// Placeholder pixels (grey) that the embedded file replaces.
WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("so-1", quad, [20.0, 10.0])),
];
d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes)];
let path = tmp("smart.psd");
std::fs::write(&path, write(&d)).unwrap();
let mut s = effectcraft_host::session();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = ItemId(r["comps"][0].as_u64().unwrap());
let comp = s.project.comp(cid).unwrap();
let placed = comp.layers.iter().find(|l| l.name == "Placed").unwrap();
let effectcraft_project::LayerSource::Footage { item } = placed.source else { panic!("footage layer") };
let effectcraft_project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() };
assert_eq!((f.width, f.height), (20, 10));
assert_eq!(f.layer.as_ref().unwrap().embedded.as_deref(), Some("so-1"));
assert!(s.project.item(item).unwrap().name.contains("inner.psd"));
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
// Rotated a quarter clockwise: the red half is on top (rows 4–24), green below.
let (red, green) = (img.get(30, 14), img.get(30, 34));
assert!(red[0] > 0.95 && red[1] < 0.05, "{red:?}");
assert!(green[1] > 0.95 && green[0] < 0.05, "{green:?}");
assert!(img.get(10, 24)[0] < 0.01 && img.get(10, 24)[3] > 0.99, "background outside");
}
#[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));
}

View File

@ -13,7 +13,7 @@ pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun};
pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value};
/// Temporal interpolation on one side of a keyframe.

View File

@ -143,6 +143,125 @@ fn yes() -> bool {
true
}
/// Figure style (OpenType `lnum` / `onum`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FigureStyle {
/// The font's default figures.
#[default]
Default,
Lining,
OldStyle,
}
/// Figure spacing (OpenType `pnum` / `tnum`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FigureWidth {
#[default]
Default,
Proportional,
Tabular,
}
/// OpenType layout features of a run of characters (Character panel ▸ OpenType). Kerning,
/// standard ligatures, small caps and superscript / subscript have their own [`CharStyle`]
/// fields (they use the font's `smcp` / `sups` / `subs` glyphs when it has them and synthesize
/// them otherwise).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct OpenType {
/// `dlig`.
pub discretionary_ligatures: bool,
/// `calt` (on by default, as in the font).
pub contextual_alternates: bool,
/// `salt`.
pub stylistic_alternates: bool,
/// Stylistic sets: bit `n - 1` turns on `ssNN` (n = 1…20).
pub stylistic_sets: u32,
/// `swsh`.
pub swash: bool,
/// `titl`.
pub titling: bool,
/// `ordn`.
pub ordinals: bool,
/// `frac`.
pub fractions: bool,
/// All Small Caps: capitals become small capitals too (`c2sc` with `smcp`).
pub all_small_caps: bool,
pub figure_style: FigureStyle,
pub figure_width: FigureWidth,
}
impl Default for OpenType {
fn default() -> Self {
OpenType {
discretionary_ligatures: false,
contextual_alternates: true,
stylistic_alternates: false,
stylistic_sets: 0,
swash: false,
titling: false,
ordinals: false,
fractions: false,
all_small_caps: false,
figure_style: FigureStyle::Default,
figure_width: FigureWidth::Default,
}
}
}
impl OpenType {
pub fn is_default(&self) -> bool {
*self == OpenType::default()
}
/// Whether stylistic set `n` (1…20) is on.
pub fn stylistic_set(&self, n: u32) -> bool {
(1..=20).contains(&n) && self.stylistic_sets & (1 << (n - 1)) != 0
}
pub fn set_stylistic_set(&mut self, n: u32, on: bool) {
if (1..=20).contains(&n) {
if on {
self.stylistic_sets |= 1 << (n - 1);
} else {
self.stylistic_sets &= !(1 << (n - 1));
}
}
}
/// The OpenType feature settings these options ask for (tag, value), beyond the font's
/// defaults: e.g. `[("dlig", 1), ("ss02", 1), ("calt", 0)]`.
pub fn feature_settings(&self) -> Vec<([u8; 4], u32)> {
let mut v = Vec::new();
for (tag, f) in [
(b"dlig", self.discretionary_ligatures),
(b"salt", self.stylistic_alternates),
(b"swsh", self.swash),
(b"titl", self.titling),
(b"ordn", self.ordinals),
(b"frac", self.fractions),
(b"c2sc", self.all_small_caps),
(b"smcp", self.all_small_caps),
(b"lnum", self.figure_style == FigureStyle::Lining),
(b"onum", self.figure_style == FigureStyle::OldStyle),
(b"pnum", self.figure_width == FigureWidth::Proportional),
(b"tnum", self.figure_width == FigureWidth::Tabular),
] {
if f {
v.push((*tag, 1));
}
}
if !self.contextual_alternates {
v.push((*b"calt", 0));
}
for n in 1..=20u32 {
if self.stylistic_set(n) {
let d = format!("ss{n:02}");
let b = d.as_bytes();
v.push(([b[0], b[1], b[2], b[3]], 1));
}
}
v
}
}
/// The formatting of a run of characters (Character panel).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@ -177,6 +296,9 @@ pub struct CharStyle {
/// Vertical type: keep Roman (half-width) characters upright instead of turning them on
/// their side (Character panel menu ▸ Standard Vertical Roman Alignment).
pub vertical_roman_upright: bool,
/// OpenType features (stylistic sets, figures, fractions…).
#[serde(skip_serializing_if = "OpenType::is_default")]
pub opentype: OpenType,
}
impl Default for CharStyle {
@ -258,6 +380,9 @@ pub struct TextDoc {
/// Base Tate-Chu-Yoko and Standard Vertical Roman Alignment (see [`CharStyle`]).
pub tate_chu_yoko: bool,
pub vertical_roman_upright: bool,
/// Base OpenType features (see [`CharStyle::opentype`]).
#[serde(skip_serializing_if = "OpenType::is_default")]
pub opentype: OpenType,
pub indent_left: f64,
pub indent_right: f64,
pub indent_first: f64,
@ -309,6 +434,7 @@ impl Default for TextDoc {
ligatures: true,
tate_chu_yoko: false,
vertical_roman_upright: false,
opentype: OpenType::default(),
indent_left: 0.0,
indent_right: 0.0,
indent_first: 0.0,
@ -395,6 +521,7 @@ impl TextDoc {
ligatures: self.ligatures,
tate_chu_yoko: self.tate_chu_yoko,
vertical_roman_upright: self.vertical_roman_upright,
opentype: self.opentype,
}
}
@ -422,6 +549,7 @@ impl TextDoc {
self.ligatures = s.ligatures;
self.tate_chu_yoko = s.tate_chu_yoko;
self.vertical_roman_upright = s.vertical_roman_upright;
self.opentype = s.opentype;
}
/// The base (first paragraph's) settings.
@ -777,6 +905,9 @@ fn color_json(v: &J) -> Option<[f32; 4]> {
fn num(key: &str, v: &J) -> Result<f64, String> {
v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("{key}: expected a number"))
}
fn norm(v: &J) -> Option<String> {
v.as_str().map(|s| s.to_ascii_lowercase().replace(['-', ' ', '_'], ""))
}
fn flag(key: &str, v: &J) -> Result<bool, String> {
v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)).ok_or_else(|| format!("{key}: expected a boolean"))
}
@ -808,6 +939,18 @@ pub const CHAR_ATTRS: &[&str] = &[
"ligatures",
"tateChuYoko",
"verticalRomanUpright",
"discretionaryLigatures",
"contextualAlternates",
"stylisticAlternates",
"stylisticSets",
"swash",
"titling",
"ordinals",
"fractions",
"allSmallCaps",
"figureStyle",
"figureWidth",
"figures",
];
/// Paragraph attribute keys of `layer.setText`.
@ -850,6 +993,7 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result<bool, Stri
s.all_caps = flag(key, v)?;
if s.all_caps {
s.small_caps = false;
s.opentype.all_small_caps = false;
}
}
"smallCaps" => {
@ -869,6 +1013,65 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result<bool, Stri
"ligatures" => s.ligatures = flag(key, v)?,
"tateChuYoko" => s.tate_chu_yoko = flag(key, v)?,
"verticalRomanUpright" => s.vertical_roman_upright = flag(key, v)?,
"discretionaryLigatures" => s.opentype.discretionary_ligatures = flag(key, v)?,
"contextualAlternates" => s.opentype.contextual_alternates = flag(key, v)?,
"stylisticAlternates" => s.opentype.stylistic_alternates = flag(key, v)?,
"swash" => s.opentype.swash = flag(key, v)?,
"titling" => s.opentype.titling = flag(key, v)?,
"ordinals" => s.opentype.ordinals = flag(key, v)?,
"fractions" => s.opentype.fractions = flag(key, v)?,
"allSmallCaps" => {
s.opentype.all_small_caps = flag(key, v)?;
if s.opentype.all_small_caps {
s.all_caps = false;
}
}
"stylisticSets" => {
s.opentype.stylistic_sets = match v {
J::Array(a) => {
let mut m = 0u32;
for n in a {
let n = n.as_u64().filter(|n| (1..=20).contains(n)).ok_or("stylisticSets: expected set numbers 1–20")?;
m |= 1 << (n - 1);
}
m
}
J::Number(_) => (num(key, v)? as u64 & 0xF_FFFF) as u32,
_ => return Err("stylisticSets: expected [1, 2, …] or a bit mask".into()),
}
}
"figureStyle" => {
s.opentype.figure_style = match norm(v).as_deref() {
Some("default") => FigureStyle::Default,
Some("lining") => FigureStyle::Lining,
Some("oldstyle") => FigureStyle::OldStyle,
_ => return Err("figureStyle: expected default|lining|oldStyle".into()),
}
}
"figureWidth" => {
s.opentype.figure_width = match norm(v).as_deref() {
Some("default") => FigureWidth::Default,
Some("proportional") => FigureWidth::Proportional,
Some("tabular") => FigureWidth::Tabular,
_ => return Err("figureWidth: expected default|proportional|tabular".into()),
}
}
"figures" => {
let (st, w) = match norm(v).as_deref() {
Some("default") => (FigureStyle::Default, FigureWidth::Default),
Some("tabularlining") => (FigureStyle::Lining, FigureWidth::Tabular),
Some("proportionallining") => (FigureStyle::Lining, FigureWidth::Proportional),
Some("tabularoldstyle") => (FigureStyle::OldStyle, FigureWidth::Tabular),
Some("proportionaloldstyle") => (FigureStyle::OldStyle, FigureWidth::Proportional),
_ => return Err("figures: expected default|tabularLining|proportionalLining|tabularOldstyle|proportionalOldstyle".into()),
};
s.opentype.figure_style = st;
s.opentype.figure_width = w;
}
k if k.len() == 4 && k.starts_with("ss") && k[2..].parse::<u32>().is_ok_and(|n| (1..=20).contains(&n)) => {
let n = k[2..].parse::<u32>().unwrap_or(0);
s.opentype.set_stylistic_set(n, flag(key, v)?);
}
_ => return Ok(false),
}
Ok(true)
@ -915,6 +1118,13 @@ pub fn char_style_json(s: &CharStyle) -> J {
"hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic,
"allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(),
"ligatures": s.ligatures, "tateChuYoko": s.tate_chu_yoko, "verticalRomanUpright": s.vertical_roman_upright,
"discretionaryLigatures": s.opentype.discretionary_ligatures, "contextualAlternates": s.opentype.contextual_alternates,
"stylisticAlternates": s.opentype.stylistic_alternates,
"stylisticSets": (1..=20u32).filter(|n| s.opentype.stylistic_set(*n)).collect::<Vec<_>>(),
"swash": s.opentype.swash, "titling": s.opentype.titling, "ordinals": s.opentype.ordinals, "fractions": s.opentype.fractions,
"allSmallCaps": s.opentype.all_small_caps,
"figureStyle": match s.opentype.figure_style { FigureStyle::Default => "default", FigureStyle::Lining => "lining", FigureStyle::OldStyle => "oldStyle" },
"figureWidth": match s.opentype.figure_width { FigureWidth::Default => "default", FigureWidth::Proportional => "proportional", FigureWidth::Tabular => "tabular" },
})
}
@ -1127,4 +1337,37 @@ mod tests {
assert_eq!(word_at(t, 8), 7..10);
assert_eq!(word_at(t, 6), 6..7);
}
#[test]
fn opentype_attributes_per_range_and_serde() {
let mut d = TextDoc::plain("Office 1/2 1st");
// Untouched documents serialize without an OpenType block.
assert!(!serde_json::to_string(&d).unwrap().contains("opentype"));
d.set_attr("ss02", &J::Bool(true), Some(0..6)).unwrap();
d.set_attr("fractions", &J::Bool(true), Some(7..10)).unwrap();
d.set_attr("figures", &J::from("tabularOldstyle"), None).unwrap();
assert!(d.style_at(0).opentype.stylistic_set(2));
assert!(!d.style_at(8).opentype.stylistic_set(2));
assert!(d.style_at(8).opentype.fractions);
assert_eq!(d.style_at(12).opentype.figure_width, FigureWidth::Tabular);
assert!(d.set_attr("stylisticSets", &serde_json::json!([1, 20]), Some(0..1)).unwrap());
assert_eq!(d.style_at(0).opentype.stylistic_sets, 1 | (1 << 19));
assert!(d.set_attr("stylisticSets", &serde_json::json!([21]), None).is_err());
let f = d.style_at(8).opentype.feature_settings();
assert!(f.contains(&(*b"frac", 1)) && f.contains(&(*b"onum", 1)) && f.contains(&(*b"tnum", 1)));
let mut s = d.style_at(0);
s.opentype.contextual_alternates = false;
assert!(s.opentype.feature_settings().contains(&(*b"calt", 0)));
// Round trip, and the JSON form names the attributes as layer.setText does.
let back: TextDoc = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
assert_eq!(back, d);
let j = char_style_json(&d.style_at(0));
assert_eq!(j["stylisticSets"], serde_json::json!([1, 20]));
assert_eq!(j["figureStyle"], "oldStyle");
// All Small Caps and All Caps exclude each other.
let mut s = CharStyle::default();
apply_char_attr(&mut s, "allCaps", &J::Bool(true)).unwrap();
apply_char_attr(&mut s, "allSmallCaps", &J::Bool(true)).unwrap();
assert!(!s.all_caps && s.opentype.all_small_caps);
}
}

View File

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

View File

@ -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)
}
@ -35,6 +67,17 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result<Option<Footage>> {
pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -> Result<Option<Image>> {
if effectcraft_psd::is_psd(bytes) {
let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
// A smart object's embedded file: an embedded document's merged image, or an image.
if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) {
let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?;
if effectcraft_psd::is_psd(data) {
let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
let px = inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
}
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
return Ok(Some(crate::convert::dynamic_to_image(&img, op)));
}
let px = match &footage.layer {
Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size),
None => psd.composite(),
@ -42,8 +85,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 +96,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))

View File

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

View File

@ -488,9 +488,10 @@ impl Inner {
let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count);
Loc { key: key(&footage.sequence[i as usize], 0), media_t: None }
}
// A layer of a layered still is keyed by its layer index and size mode.
// A layer of a layered still is keyed by its layer index and size mode (smart objects
// by their embedded file).
FootageKind::Still if footage.layer.is_some() => {
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64);
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 });
Loc { key: key(&footage.path, l), media_t: None }
}
FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None },
@ -621,7 +622,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>> {

15
crates/pdf/Cargo.toml Normal file
View File

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

48
crates/pdf/README.md Normal file
View File

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

93
crates/pdf/src/build.rs Normal file
View File

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

288
crates/pdf/src/color.rs Normal file
View File

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

185
crates/pdf/src/lib.rs Normal file
View File

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

708
crates/pdf/src/object.rs Normal file
View File

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

573
crates/pdf/src/page.rs Normal file
View File

@ -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"),
_ => {}
}
}
}

1462
crates/pdf/src/ps.rs Normal file

File diff suppressed because it is too large Load Diff

150
crates/pdf/src/tests.rs Normal file
View File

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

32
crates/pdf/src/write.rs Normal file
View File

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

View File

@ -830,9 +830,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,
@ -840,6 +840,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 {
@ -856,6 +860,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>),

View File

@ -8,10 +8,14 @@ Implemented from Adobe's public **Adobe Photoshop File Formats Specification** (
edition): file header, colour mode data, image resources, layer and mask information (layer
records, channel image data, layer mask data, additional layer information: `luni`, `lsct`/`lsdk`,
`lyid`, `iOpa`, `TySh`, `lfx2`, `SoCo`, `nvrt`, `brit`, `hue2`, `levl`, `expA`, `vmsk`/`vsms`,
`Lr16`/`Lr32`), the descriptor (action) structure, and the merged image data. Raw, RLE (PackBits)
`Lr16`/`Lr32`, smart objects: `SoLd`/`SoLE`/`PlLd` placement and `lnk2`/`lnkD`/`lnk3`/`lnkE`
linked layer data with embedded files), the descriptor (action) structure, and the merged image data. Raw, RLE (PackBits)
and ZIP (with and without prediction, inflated with `miniz_oxide`) compression; 1/8/16/32-bit;
Bitmap, Grayscale, Indexed, RGB, CMYK (naive conversion) and Lab colour modes.
Text engine data (`EngineData`) is read best-effort for the font, size, fill colour and
justification of the first style run. No Adobe code, sample files or assets were used; test
justification of the first style run. Smart objects import as footage of their embedded file
(a Photoshop document's merged image, or an image) placed by the smart object's corner quad
(position, scale, rotation; perspective and warps are not applied). Externally linked files
fall back to the layer's rendered pixels. No Adobe code, sample files or assets were used; test
fixtures are generated in-test by the writer in this crate ([`write`](src/write.rs)).

View File

@ -204,6 +204,46 @@ pub struct SubPath {
pub knots: Vec<[[f64; 2]; 3]>,
}
/// A placed layer (smart object): `SoLd` / `SoLE` (placed layer data) or the older `PlLd`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SmartObject {
/// Unique id of the linked file ([`Psd::linked`]) the layer shows.
pub uuid: String,
/// Where the content's corners sit in document pixels: top-left, top-right, bottom-right,
/// bottom-left.
pub quad: [[f64; 2]; 4],
/// The content's size in pixels (`Sz `), when recorded.
pub size: Option<[f64; 2]>,
}
impl SmartObject {
/// The quad as an affine placement of a `w`×`h` content rectangle: (centre, scale x, scale
/// y, rotation in degrees). Perspective and skew are dropped.
pub fn placement(&self, w: f64, h: f64) -> ([f64; 2], f64, f64, f64) {
let q = self.quad;
let centre = [(q[0][0] + q[1][0] + q[2][0] + q[3][0]) / 4.0, (q[0][1] + q[1][1] + q[2][1] + q[3][1]) / 4.0];
let top = [q[1][0] - q[0][0], q[1][1] - q[0][1]];
let left = [q[3][0] - q[0][0], q[3][1] - q[0][1]];
let sx = top[0].hypot(top[1]) / w.max(1e-9);
let sy = left[0].hypot(left[1]) / h.max(1e-9);
// Mirrored content (left edge turning the other way) keeps a negative y scale.
let cross = top[0] * left[1] - top[1] * left[0];
(centre, sx, if cross < 0.0 { -sy } else { sy }, top[1].atan2(top[0]).to_degrees())
}
}
/// A file embedded in (or linked from) the document: linked layer data (`lnk2`, `lnkD`,
/// `lnk3`, `lnkE`).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LinkedFile {
pub uuid: String,
pub name: String,
/// File type code (`8BPS`, `PNGf`, `JPEG`…), may be blank.
pub file_type: String,
/// Embedded data (absent for externally linked files).
pub data: Option<std::ops::Range<usize>>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Layer {
/// Index in [`Psd::layers`] (file order: bottom of the stack first).
@ -230,6 +270,8 @@ pub struct Layer {
/// Layer effects (`lfx2` descriptor).
pub effects: Option<Descriptor>,
pub vector_mask: Option<VectorMask>,
/// Smart object placement.
pub smart_object: Option<SmartObject>,
/// Keys of every additional-information block the layer carries.
pub keys: Vec<[u8; 4]>,
}
@ -277,6 +319,8 @@ pub struct Psd {
pub merged_alpha: bool,
/// Indexed colour palette (256 R, then G, then B).
pub palette: Vec<u8>,
/// Files embedded for smart objects.
pub linked: Vec<LinkedFile>,
data: Arc<[u8]>,
merged_offset: usize,
}
@ -314,7 +358,20 @@ impl Psd {
let n = r.u32()? as usize;
r.skip(n)?;
// Layer and mask information.
let mut psd = Psd { psb, width, height, channels, depth, mode, layers: vec![], merged_alpha: false, palette, data: data.clone(), merged_offset: 0 };
let mut psd = Psd {
psb,
width,
height,
channels,
depth,
mode,
layers: vec![],
merged_alpha: false,
palette,
linked: vec![],
data: data.clone(),
merged_offset: 0,
};
let lm_len = r.length(psb)?;
let lm_end = r.pos.checked_add(lm_len).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
if lm_len > 0 {
@ -330,6 +387,9 @@ impl Psd {
}
// Global additional layer information (16/32-bit layer info lives here).
while let Some((key, start, end)) = next_block(&data, &mut r.pos, lm_end, psb) {
if matches!(&key, b"lnk2" | b"lnkD" | b"lnk3" | b"lnkE") {
psd.linked_files(&data, start, end);
}
if matches!(&key, b"Lr16" | b"Lr32" | b"Layr") && psd.layers.is_empty() && end > start {
// The block holds the layer info, normally without its length field; accept
// both layouts.
@ -348,6 +408,67 @@ impl Psd {
Ok(psd)
}
/// The embedded bytes of linked file `uuid`.
pub fn linked_data(&self, uuid: &str) -> Option<&[u8]> {
let f = self.linked.iter().find(|f| f.uuid == uuid)?;
self.data.get(f.data.clone()?)
}
/// Linked layer data records (spec: "Linked Layer"): length, kind (`liFD` embedded data,
/// `liFE` external file, `liFA` alias), version, unique id, original file name, type,
/// creator, data length, optional descriptors, then the data. A damaged record ends the list.
fn linked_files(&mut self, data: &[u8], start: usize, end: usize) {
let mut pos = start;
while pos + 8 <= end {
let mut r = Reader::at(&data[..end], pos);
let Ok(len) = r.u64() else { break };
let body = r.pos;
let Some(rec_end) = body.checked_add(len as usize).filter(|e| *e <= end) else { break };
let mut r = Reader::at(&data[..rec_end], body);
let rec = (|| -> Result<LinkedFile> {
let kind = r.tag()?;
let version = r.u32()?;
let n = r.u8()? as usize;
let uuid = String::from_utf8_lossy(r.bytes(n)?).into_owned();
let name = descriptor::read_unicode(&mut r)?;
let file_type = String::from_utf8_lossy(&r.tag()?).trim().to_string();
let _creator = r.tag()?;
let dlen = r.u64()? as usize;
if r.u8()? != 0 {
let _v = r.u32()?;
descriptor::read_descriptor(&mut r)?;
}
let mut out = LinkedFile { uuid, name, file_type, data: None };
match &kind {
b"liFD" => {
let s = r.pos;
r.skip(dlen)?;
out.data = Some(s..s + dlen);
}
b"liFE" => {
let _v = r.u32()?;
descriptor::read_descriptor(&mut r)?;
if version > 3 {
r.skip(4 + 4 + 8)?;
}
let _size = r.u64()?;
if version > 2 && r.remaining() >= dlen && dlen > 0 {
let s = r.pos;
out.data = Some(s..s + dlen);
}
}
_ => {}
}
Ok(out)
})();
match rec {
Ok(f) => self.linked.push(f),
Err(_) => break,
}
pos = rec_end.div_ceil(4) * 4;
}
}
fn layer_info(&mut self, data: &[u8], start: usize, end: usize) -> Result<()> {
let mut r = Reader::at(&data[..end], start);
let count = r.i16()?;
@ -522,6 +643,46 @@ impl Psd {
l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned()));
}
}
b"SoLd" | b"SoLE" => {
let _id = r.tag()?;
let _ver = r.u32()?;
let _dver = r.u32()?;
let d = descriptor::read_descriptor(&mut r)?;
let uuid = d.text("Idnt").unwrap_or_default().to_string();
let nums = |k: &str| -> Option<Vec<f64>> {
match d.get(k)? {
DValue::List(l) => Some(
l.iter()
.filter_map(|v| match v {
DValue::Double(x) | DValue::UnitFloat(_, x) => Some(*x),
DValue::Integer(i) => Some(*i as f64),
_ => None,
})
.collect(),
),
DValue::UnitFloats(_, v) => Some(v.clone()),
_ => None,
}
};
let t = nums("Trnf").filter(|t| t.len() == 8).unwrap_or_else(|| vec![0.0; 8]);
let size = d.obj("Sz ").and_then(|s| Some([s.num("Wdth")?, s.num("Hght")?]));
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size });
}
b"PlLd" if l.smart_object.is_none() => {
let _id = r.tag()?;
let _ver = r.u32()?;
let n = r.u8()? as usize;
let uuid = String::from_utf8_lossy(r.bytes(n)?).into_owned();
let _page = r.u32()?;
let _pages = r.u32()?;
let _aa = r.u32()?;
let _ty = r.u32()?;
let mut t = [0.0; 8];
for v in &mut t {
*v = r.f64()?;
}
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None });
}
b"vmsk" | b"vsms" => {
let _ver = r.u32()?;
let flags = r.u32()?;

View File

@ -187,3 +187,34 @@ fn truncated_and_garbage_input_errors() {
assert!(Psd::parse(b"8BPS\0\x09garbage".to_vec()).is_err());
assert!(Psd::parse(b"PNG".to_vec()).is_err());
}
#[test]
fn smart_objects_and_embedded_files() {
let mut inner = WDoc::new(20, 10);
inner.layers = vec![WLayer::solid("Red", Rect::new(0, 0, 20, 10), [1.0, 0.0, 0.0, 1.0])];
let inner_bytes = write(&inner);
let mut d = WDoc::new(64, 48);
// The content scaled 2× and rotated 90° clockwise: top-left at (40, 4).
let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]];
d.layers = vec![
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("uuid-1", quad, [20.0, 10.0])),
];
d.linked = vec![("uuid-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())];
let p = Psd::parse(write(&d)).unwrap();
assert_eq!(p.linked.len(), 1);
assert_eq!(p.linked[0].name, "inner.psd");
assert_eq!(p.linked[0].file_type, "8BPS");
assert_eq!(p.linked_data("uuid-1").unwrap(), &inner_bytes[..]);
let so = p.layers[1].smart_object.clone().unwrap();
assert_eq!(so.uuid, "uuid-1");
assert_eq!(so.quad, quad);
assert_eq!(so.size, Some([20.0, 10.0]));
let (c, sx, sy, rot) = so.placement(20.0, 10.0);
assert_eq!(c, [30.0, 24.0]);
assert!((sx - 2.0).abs() < 1e-9 && (sy - 2.0).abs() < 1e-9 && (rot - 90.0).abs() < 1e-9, "{sx} {sy} {rot}");
// The embedded document parses on its own.
let e = Psd::parse(p.linked_data("uuid-1").unwrap().to_vec()).unwrap();
assert_eq!((e.width, e.height), (20, 10));
assert!(p.layers[0].smart_object.is_none());
}

View File

@ -103,11 +103,14 @@ pub struct WDoc {
/// Merged image (straight RGBA, document-sized); `None` composites visible pixel layers
/// with Normal blending.
pub composite: Option<Vec<[f32; 4]>>,
/// Embedded files for smart objects: (unique id, file name, type code, bytes), written as
/// linked layer data (`lnk2`).
pub linked: Vec<(String, String, [u8; 4], Vec<u8>)>,
}
impl WDoc {
pub fn new(width: u32, height: u32) -> WDoc {
WDoc { width, height, depth: 8, mode: ColorMode::Rgb, rle: true, layers: vec![], composite: None }
WDoc { width, height, depth: 8, mode: ColorMode::Rgb, rle: true, layers: vec![], composite: None, linked: vec![] }
}
}
@ -339,6 +342,13 @@ pub fn write(doc: &WDoc) -> Vec<u8> {
lm.extend_from_slice(&li);
u32b(&mut lm, 0);
}
if !doc.linked.is_empty() {
let d = linked_block(&doc.linked);
lm.extend_from_slice(b"8BIM");
lm.extend_from_slice(b"lnk2");
u32b(&mut lm, d.len() as u32);
lm.extend_from_slice(&d);
}
}
u32b(&mut out, lm.len() as u32);
out.extend_from_slice(&lm);
@ -372,8 +382,53 @@ pub fn write(doc: &WDoc) -> Vec<u8> {
out
}
/// Linked layer data records (`liFD`, version 7) for embedded files.
fn linked_block(files: &[(String, String, [u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut out = vec![];
for (uuid, name, ty, data) in files {
let mut rec = vec![];
rec.extend_from_slice(b"liFD");
u32b(&mut rec, 7);
rec.push(uuid.len() as u8);
rec.extend_from_slice(uuid.as_bytes());
rec.extend_from_slice(&unicode_plain(name));
rec.extend_from_slice(ty);
rec.extend_from_slice(b"8BIM");
rec.extend_from_slice(&(data.len() as u64).to_be_bytes());
rec.push(0); // no file-open descriptor
rec.extend_from_slice(data);
u32b(&mut rec, 0); // child document id (empty unicode string)
rec.extend_from_slice(&0f64.to_be_bytes()); // asset modification time
rec.push(0); // asset locked state
out.extend_from_slice(&(rec.len() as u64).to_be_bytes());
out.extend_from_slice(&rec);
while !out.len().is_multiple_of(4) {
out.push(0);
}
}
out
}
// ---------------------------------------------------------------- block helpers
/// `SoLd`: a placed layer showing linked file `uuid` (content `size` pixels) with its corners at
/// `quad` (top-left, top-right, bottom-right, bottom-left).
pub fn smart_object_block(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2]) -> ([u8; 4], Vec<u8>) {
let list = |v: &[f64]| DValue::List(v.iter().map(|x| DValue::Double(*x)).collect());
let t: Vec<f64> = quad.iter().flat_map(|p| [p[0], p[1]]).collect();
let d = Descriptor::new("null")
.with("Idnt", DValue::Text(uuid.to_string()))
.with("placed", DValue::Text(uuid.to_string()))
.with("Trnf", list(&t))
.with("nonAffineTransform", list(&t))
.with("Sz ", DValue::Descriptor(Descriptor::new("Pnt ").with("Wdth", DValue::Double(size[0])).with("Hght", DValue::Double(size[1]))));
let mut out = b"soLD".to_vec();
u32b(&mut out, 4);
u32b(&mut out, 16);
descriptor::write_descriptor(&mut out, &d);
(*b"SoLd", out)
}
/// `TySh`: a type layer with one style run; the text origin sits at `origin`.
pub fn text_block(text: &str, origin: [f64; 2], font: &str, size: f64, color: [f64; 3], justification: u8) -> ([u8; 4], Vec<u8>) {
let mut d = vec![];

View File

@ -69,7 +69,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`]).

View File

@ -200,3 +200,37 @@ fn wireframe_quality_draws_the_layer_bounds() {
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0));
}
#[test]
fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() {
// A 1000×600 scene comp with a blue 3D solid at its centre, collapsed into a 300×200 comp
// whose camera looks at the precomp layer (at the parent's centre). The nested layers must
// be projected with the parent comp's view (its centre and size), not the scene comp's.
let run = |three_d_precomp: bool, camera: bool| {
let mut p = project();
let outer = Comp::new(300, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let inner = Comp::new(1000, 600, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (40, 40), [500.0, 300.0, 0.0]);
blue.switches.three_d = true;
let mut inner = inner;
inner.layers = vec![blue];
let iid = p.add_item("Scene", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut pre = build::layer(&mut p, &outer, "Scene", LayerSource::Comp { item: iid }, (1000, 600), None);
pre.switches.three_d = three_d_precomp;
pre.switches.collapse = true;
let mut c = outer.clone();
c.layers = vec![pre];
if camera {
let cam = build::layer(&mut p, &outer, "Camera", LayerSource::Camera, (300, 200), None);
c.layers.insert(0, cam);
}
let cid = p.add_item("Face", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
};
for (three_d, camera) in [(true, true), (true, false), (false, true)] {
let img = run(three_d, camera);
let c = img.get(150, 100);
assert!(c[2] > 0.99 && c[3] > 0.99, "3D precomp {three_d}, camera {camera}: centre {c:?}");
assert!(img.get(100, 100)[3] < 0.01, "only the 40 px solid is drawn");
}
}

View File

@ -161,6 +161,13 @@ pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState {
}
}
/// The size of the comp the camera looks through: a collapsed precomp's layers are projected
/// into the outermost comp they collapse into (its centre and size), not their own comp's.
pub(crate) fn view_size(r: &Renderer, ctx: &EvalCtx) -> (f64, f64) {
let c = r.collapse3d.map_or(ctx.comp, |c| c.parent.comp);
(c.width as f64, c.height as f64)
}
/// Number of motion-blur sub-samples for a layer.
fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) {
@ -202,7 +209,7 @@ fn prepare_with<'a>(
if buf.img.is_empty() {
return None;
}
let comp = (ctx.comp.width as f64, ctx.comp.height as f64);
let comp = view_size(r, ctx);
let s = r.opts.scale;
let cam = camera_for(r, ctx);
let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world);
@ -919,7 +926,7 @@ pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut
let cam = camera_for(r, ctx);
let (fwd, right, down) = (cam.forward(), cam.right(), cam.down());
let s = r.opts.scale.max(1e-9);
let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64);
let (cw, ch) = view_size(r, ctx);
let (ox, oy) = r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y));
let rot = environment_rotation(ctx);
let (iw, ih) = (img.width as f64, img.height as f64);

View File

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

View File

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

View File

@ -214,6 +214,21 @@ impl Face {
underline_thickness: m.underline.map_or(px * 0.06, |u| u.thickness.max(px * 0.03)),
}
}
/// The OpenType layout features the face offers (GSUB and GPOS tags, sorted, deduplicated).
pub fn features(&self) -> Vec<String> {
use skrifa::raw::TableProvider;
let Some(f) = self.font() else { return vec![] };
let mut out: Vec<String> = vec![];
if let Ok(list) = f.gsub().and_then(|g| g.feature_list()) {
out.extend(list.feature_records().iter().map(|r| String::from_utf8_lossy(&r.feature_tag().to_be_bytes()).into_owned()));
}
if let Ok(list) = f.gpos().and_then(|g| g.feature_list()) {
out.extend(list.feature_records().iter().map(|r| String::from_utf8_lossy(&r.feature_tag().to_be_bytes()).into_owned()));
}
out.sort();
out.dedup();
out
}
/// Whether the face's GSUB offers an OpenType feature (e.g. `smcp`).
pub fn has_feature(&self, tag: &[u8; 4]) -> bool {
use skrifa::raw::TableProvider;

View File

@ -18,6 +18,8 @@ use std::sync::{Arc, Mutex, OnceLock};
use harfrust::{Direction, Feature, Tag, UnicodeBuffer};
use unicode_bidi::{BidiInfo, Level};
use effectcraft_keyframe::OpenType;
use crate::fonts::{self, FaceId, Resolved};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
@ -81,6 +83,8 @@ pub struct TextStyle {
pub script: Script,
/// Baseline-to-baseline distance in pixels; None = auto (120% of the size).
pub leading: Option<f32>,
/// OpenType features (stylistic sets, figures, fractions, all small caps…).
pub opentype: OpenType,
}
impl Default for TextStyle {
@ -104,6 +108,7 @@ impl Default for TextStyle {
tsume: 0.0,
script: Script::Normal,
leading: None,
opentype: OpenType::default(),
}
}
}
@ -188,6 +193,7 @@ impl Hash for TextStyle {
self.manual_kern.map(f32::to_bits).hash(h);
self.leading.map(f32::to_bits).hash(h);
(self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h);
self.opentype.hash(h);
}
}
@ -342,6 +348,8 @@ struct ShapedGlyph {
dy: f32,
size: f32,
run: usize,
/// Upward baseline offset (baseline shift, plus a synthesized superscript / subscript's).
rise: f32,
}
struct Item {
@ -360,6 +368,50 @@ fn upper_single(c: char) -> char {
const SMALL_CAPS_SCALE: f32 = 0.78;
/// How a character is set: as itself, as a synthesized small capital (a scaled-down capital),
/// with the font's small-capital glyph (`smcp` / `c2sc`), or with the font's superior / inferior
/// glyph (`sups` / `subs`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Form {
Normal,
FauxSmall,
TrueSmall,
TrueScript,
}
/// Whether OpenType feature `tag` gives character `c` its own glyph in `face` (cached): the
/// true small caps / superscripts / subscripts test, with synthesized forms as the fallback.
pub fn feature_substitutes(face: FaceId, tag: &[u8; 4], c: char) -> bool {
static C: OnceLock<Mutex<HashMap<(FaceId, [u8; 4], char), bool>>> = OnceLock::new();
let cache = C.get_or_init(Default::default);
if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, *tag, c)) {
return *v;
}
let f = fonts::face(face);
let v = f.has_feature(tag)
&& match (f.font(), f.shaper_data()) {
(Some(font), Some(data)) => {
let shaper = data.shaper(&font).build();
let shape = |feats: &[Feature]| -> Vec<u32> {
let mut buf = UnicodeBuffer::new();
buf.add(c, 0);
buf.guess_segment_properties();
shaper.shape(buf, harfrust::ShapeOptions::new().features(feats)).glyph_infos().iter().map(|i| i.glyph_id).collect()
};
let plain = shape(&[]);
let with = shape(&[Feature::new(Tag::new(tag), 1, ..)]);
plain != with && !with.contains(&0)
}
_ => false,
};
let mut m = cache.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 100_000 {
m.clear();
}
m.insert((face, *tag, c), v);
v
}
/// Horizontal ink extent `(x0, x1)` of a glyph in font units.
fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> {
let p = crate::outline_units(face, gid)?;
@ -370,7 +422,7 @@ fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> {
Some((r.x0 as f32, r.x1 as f32))
}
fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize) -> Vec<ShapedGlyph> {
fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize, form: Form) -> Vec<ShapedGlyph> {
let f = fonts::face(face);
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
let shaper = data.shaper(&font).build();
@ -388,6 +440,30 @@ fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32,
feats.push(Feature::new(Tag::new(b"liga"), 0, ..));
feats.push(Feature::new(Tag::new(b"clig"), 0, ..));
}
// OpenType options; small caps go per character (true where the font has them).
for (tag, v) in style.opentype.feature_settings() {
if &tag != b"smcp" && &tag != b"c2sc" {
feats.push(Feature::new(Tag::new(&tag), v, ..));
}
}
if style.caps == Caps::All {
// Case-sensitive forms (punctuation raised to sit with capitals).
feats.push(Feature::new(Tag::new(b"case"), 1, ..));
}
let rise = match form {
Form::TrueScript => style.baseline_shift,
_ => style.rise(),
};
match form {
Form::TrueSmall => {
feats.push(Feature::new(Tag::new(b"smcp"), 1, ..));
if style.opentype.all_small_caps {
feats.push(Feature::new(Tag::new(b"c2sc"), 1, ..));
}
}
Form::TrueScript => feats.push(Feature::new(Tag::new(if style.script == Script::Sub { b"subs" } else { b"sups" }), 1, ..)),
_ => {}
}
let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats));
let k = size / f.units_per_em();
let hs = style.h_scale;
@ -404,6 +480,7 @@ fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32,
dy: p.y_offset as f32 * k,
size,
run,
rise,
})
.collect()
}
@ -539,38 +616,54 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
let bidi = BidiInfo::new(text, default_level);
let pinfo = &bidi.paragraphs[0];
let base_rtl = pinfo.level.is_rtl();
// per char: (byte, char, shaped char, face, rtl, small caps, run)
let chars: Vec<(usize, char, char, FaceId, bool, bool, usize)> = text
// per char: (byte, char, shaped char, face, rtl, form, run)
let chars: Vec<(usize, char, char, FaceId, bool, Form, usize)> = text
.char_indices()
.map(|(b, c)| {
let si = run_at(runs, base + b);
let style = &runs[si].1;
let (sc, small) = match style.caps {
Caps::Normal => (c, false),
Caps::All => (upper_single(c), false),
Caps::Small if c.is_lowercase() => (upper_single(c), true),
Caps::Small => (c, false),
};
let primary = primaries[si].face;
let face = if sc.is_whitespace() { primary } else { fonts::fallback_for(sc, primary) };
(b, c, sc, face, bidi.levels[b].is_rtl(), small, si)
let face_of = |ch: char| if ch.is_whitespace() { primary } else { fonts::fallback_for(ch, primary) };
let all_small = style.opentype.all_small_caps && style.caps != Caps::All;
let small = (style.caps == Caps::Small || all_small) && c.is_lowercase();
let small_cap = all_small && c.is_uppercase();
let (sc, mut form) = match style.caps {
Caps::All => (upper_single(c), Form::Normal),
_ if small && feature_substitutes(face_of(c), b"smcp", c) => (c, Form::TrueSmall),
_ if small_cap && feature_substitutes(face_of(c), b"c2sc", c) => (c, Form::TrueSmall),
_ if small => (upper_single(c), Form::FauxSmall),
_ if small_cap => (c, Form::FauxSmall),
_ => (c, Form::Normal),
};
let face = face_of(sc);
if form == Form::Normal && style.script != Script::Normal && !sc.is_whitespace() {
let tag = if style.script == Script::Sub { b"subs" } else { b"sups" };
if feature_substitutes(face, tag, sc) {
form = Form::TrueScript;
}
}
(b, c, sc, face, bidi.levels[b].is_rtl(), form, si)
})
.collect();
// items: runs of equal (face, rtl, small, style)
let mut items: Vec<Item> = Vec::new();
let mut i = 0;
while i < chars.len() {
let key = |c: &(usize, char, char, FaceId, bool, bool, usize)| (c.3, c.4, c.5, c.6);
let key = |c: &(usize, char, char, FaceId, bool, Form, usize)| (c.3, c.4, c.5, c.6);
let k0 = key(&chars[i]);
let mut j = i + 1;
while j < chars.len() && key(&chars[j]) == k0 {
j += 1;
}
let (face, rtl, small, si) = k0;
let (face, rtl, form, si) = k0;
let style = &runs[si].1;
let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect();
let size = style.glyph_size() * if small { SMALL_CAPS_SCALE } else { 1.0 };
let mut glyphs = shape_item(&sub, rtl, face, size, style, si);
let size = match form {
Form::FauxSmall => style.glyph_size() * SMALL_CAPS_SCALE,
Form::TrueScript => style.size,
_ => style.glyph_size(),
};
let mut glyphs = shape_item(&sub, rtl, face, size, style, si, form);
space_item(&mut glyphs, style, text, true);
let end = if j < chars.len() { chars[j].0 } else { text.len() };
items.push(Item { range: chars[i].0..end, rtl, glyphs });
@ -718,7 +811,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
face: g.face,
id: g.id,
x: pen + g.dx,
y: -g.dy - st.rise(),
y: -g.dy - g.rise,
size: g.size,
cluster: base + g.cluster,
synth_bold: primaries[g.run].synth_bold || st.faux_bold,
@ -960,6 +1053,82 @@ mod tests {
TextStyle { size, ..Default::default() }
}
fn ids(text: &str, s: &TextStyle) -> Vec<u32> {
layout_uncached(text, s, &ParagraphStyle::default()).glyphs.iter().map(|g| g.id).collect()
}
fn ot(f: impl FnOnce(&mut OpenType)) -> TextStyle {
let mut s = st(100.0);
f(&mut s.opentype);
s
}
fn serif() -> TextStyle {
TextStyle { family: "Noto Serif".into(), ..st(100.0) }
}
#[test]
fn bundled_faces_list_their_features() {
let inter = fonts::face(fonts::resolve("Inter", "Regular").face).features();
for t in ["ss01", "dlig", "frac", "tnum", "sups", "ordn"] {
assert!(inter.iter().any(|f| f == t), "{t} in {inter:?}");
}
let serif = fonts::face(fonts::resolve("Noto Serif", "Regular").face).features();
for t in ["smcp", "c2sc", "onum", "lnum"] {
assert!(serif.iter().any(|f| f == t), "{t} in {serif:?}");
}
}
#[test]
fn opentype_features_substitute_glyphs() {
let plain = st(100.0);
// Stylistic sets, fractions, ordinals, figure widths and discretionary ligatures.
assert_ne!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(1, true))), "ss01 alternate digits");
assert_eq!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(20, true))), "Inter has no ss20");
assert_ne!(ids("1/2", &plain), ids("1/2", &ot(|o| o.fractions = true)), "frac");
assert_ne!(ids("1a 2o No", &plain), ids("1a 2o No", &ot(|o| o.ordinals = true)), "ordn");
let tab = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Tabular));
let prop = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Proportional));
assert!((tab - prop).abs() > 1.0, "tabular {tab} vs proportional {prop}");
// Old-style figures (Noto Serif).
let old = TextStyle { opentype: OpenType { figure_style: effectcraft_keyframe::FigureStyle::OldStyle, ..Default::default() }, ..serif() };
assert_ne!(ids("123", &serif()), ids("123", &old), "onum");
// Contextual alternates are on by default; turning them off can only keep or change glyphs.
let no_calt = ot(|o| o.contextual_alternates = false);
assert_eq!(ids("Hello", &plain).len(), ids("Hello", &no_calt).len());
}
#[test]
fn true_small_caps_and_superscripts_with_faux_fallback() {
// Noto Serif has smcp: lowercase letters take their own small-capital glyphs at full size.
let sc = TextStyle { caps: Caps::Small, ..serif() };
let l = layout_uncached("Ab", &sc, &ParagraphStyle::default());
let upper_b = ids("B", &serif())[0];
assert_ne!(l.glyphs[1].id, upper_b, "true small cap glyph, not a scaled capital");
assert_eq!(l.glyphs[1].size, 100.0);
// Inter has no smcp: a capital scaled down.
let faux = layout_uncached("Ab", &TextStyle { caps: Caps::Small, ..st(100.0) }, &ParagraphStyle::default());
assert_eq!(faux.glyphs[1].id, ids("B", &st(100.0))[0]);
assert!((faux.glyphs[1].size - 78.0).abs() < 0.01);
// All Small Caps: capitals become small capitals too (c2sc).
let all = TextStyle { opentype: OpenType { all_small_caps: true, ..Default::default() }, ..serif() };
let a = layout_uncached("AB", &all, &ParagraphStyle::default());
assert_ne!(a.glyphs[0].id, ids("A", &serif())[0]);
// Superscript: Inter's sups figures at full size on the baseline; letters it lacks are
// synthesized (smaller and raised).
let sup = TextStyle { script: Script::Super, ..st(100.0) };
let s2 = layout_uncached("2", &sup, &ParagraphStyle::default());
assert_ne!(s2.glyphs[0].id, ids("2", &st(100.0))[0]);
assert_eq!(s2.glyphs[0].size, 100.0);
assert_eq!(s2.glyphs[0].y, 0.0);
let sq = layout_uncached("q", &sup, &ParagraphStyle::default());
if !feature_substitutes(sq.glyphs[0].face, b"sups", 'q') {
assert!(sq.glyphs[0].size < 60.0 && sq.glyphs[0].y < -20.0, "{:?}", sq.glyphs[0]);
}
let sub = layout_uncached("2", &TextStyle { script: Script::Sub, ..st(100.0) }, &ParagraphStyle::default());
assert_ne!(sub.glyphs[0].id, s2.glyphs[0].id);
}
#[test]
fn kerning_and_ligatures_change_advances() {
let kern = measure("AVAVAV", &st(100.0));
@ -1110,7 +1279,11 @@ mod tests {
let base = st(100.0);
let sup = TextStyle { script: Script::Super, ..base.clone() };
let sub = TextStyle { script: Script::Sub, ..base.clone() };
let l = rich("x2y2", &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default());
// A letter the font has no superior / inferior glyph for: synthesized.
let inter = fonts::resolve("Inter", "Regular").face;
let c = "qwkzvjxyQWK".chars().find(|&c| !feature_substitutes(inter, b"sups", c) && !feature_substitutes(inter, b"subs", c)).unwrap();
let text: String = ['x', c, 'y', c].iter().collect();
let l = rich(&text, &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default());
let g = &l.glyphs;
assert!((g[1].size - 58.3).abs() < 0.1);
assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size");

View File

@ -161,6 +161,7 @@ pub fn text_style(s: &CharStyle) -> TextStyle {
BaselineOption::Subscript => Script::Sub,
},
leading: s.leading.map(|l| l as f32),
opentype: s.opentype,
}
}

View File

@ -846,7 +846,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,

View File

@ -2,7 +2,7 @@
//! Character and Paragraph panels show and change the selected text (creating style runs);
//! otherwise they apply to the whole selected text layer.
use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, Justify, Kerning, TextDoc};
use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, TextDoc};
use effectcraft_engine::render::EvalCtx;
use egui::{Align2, Rect, Sense, pos2, vec2};
use serde_json::json;
@ -102,7 +102,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.push(json!({"font": f}));
}
y += 28.0;
let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
let sr = Rect::from_min_size(pos2(x0, y), vec2(w - 84.0, 22.0));
let spop = egui::Id::new("char-style-pop");
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("char-style")).clicked() && enabled {
widgets::open_popup(ui, spop);
@ -231,6 +231,16 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add("character.ligatures", lr, "Ligatures");
p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Ligatures", Tokens::ui(11.5), t.text_dim);
// OpenType: a popup with the font's layout features (like the Character panel's OpenType menu).
let or = Rect::from_min_size(pos2(sr.max.x + 6.0, sr.min.y), vec2(78.0, 22.0));
let opop = egui::Id::new("char-opentype-pop");
let n_on = doc.opentype.feature_settings().len();
let olabel = if n_on > 0 { format!("OT ({n_on})") } else { "OpenType".to_string() };
if widgets::dropdown(ui, or, &olabel, &t, egui::Id::new("char-opentype")).clicked() && enabled {
widgets::open_popup(ui, opop);
}
app.auto.add("character.opentype", or, "OpenType");
opentype_popup(app, ui, opop, pos2((or.max.x - 300.0).max(rect.min.x), or.max.y), &doc, &mut actions);
// Vertical type: Tate-Chu-Yoko and Standard Vertical Roman Alignment (Character panel menu).
if doc.vertical {
y += 22.0;
@ -266,6 +276,100 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// The Character panel's OpenType popup: feature toggles (dimmed when the font lacks them),
/// figure styles and the twenty stylistic sets. Every row is an automation target
/// (`character.opentype.<key>`), and every change is a `layer.setText` attribute.
fn opentype_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, doc: &TextDoc, actions: &mut Vec<serde_json::Value>) {
let open_id = id.with("open");
if !ui.data(|d| d.get_temp::<bool>(open_id).unwrap_or(false)) {
return;
}
let t = app.tokens;
let feats = effectcraft_engine::font_features(&doc.font, &doc.style);
let has = |tag: &str| feats.iter().any(|f| f == tag);
let o = doc.opentype;
let mut rects: Vec<(String, Rect, String)> = vec![];
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(230.0);
ui.label(egui::RichText::new(format!("{} {}", doc.font, doc.style)).small().color(t.text_dim));
let toggles: [(&str, &str, bool, &[&str]); 12] = [
("ligatures", "Standard Ligatures", doc.ligatures, &["liga", "clig"]),
("discretionaryLigatures", "Discretionary Ligatures", o.discretionary_ligatures, &["dlig"]),
("contextualAlternates", "Contextual Alternates", o.contextual_alternates, &["calt"]),
("stylisticAlternates", "Stylistic Alternates", o.stylistic_alternates, &["salt"]),
("swash", "Swash", o.swash, &["swsh"]),
("titling", "Titling Alternates", o.titling, &["titl"]),
("ordinals", "Ordinals", o.ordinals, &["ordn"]),
("fractions", "Fractions", o.fractions, &["frac"]),
("smallCaps", "Small Caps", doc.small_caps, &["smcp"]),
("allSmallCaps", "All Small Caps", o.all_small_caps, &["c2sc"]),
("superscript", "Superscript / Superior", doc.baseline == BaselineOption::Superscript, &["sups"]),
("subscript", "Subscript / Inferior", doc.baseline == BaselineOption::Subscript, &["subs"]),
];
for (key, label, on, tags) in toggles {
let avail = tags.iter().any(|g| has(g));
// Unavailable features stay clickable: small caps and scripts are synthesized.
let synth = matches!(key, "smallCaps" | "allSmallCaps" | "superscript" | "subscript");
let text = match (avail, synth) {
(true, _) => label.to_string(),
(false, true) => format!("{label} (synthesized)"),
(false, false) => format!("{label} (not in font)"),
};
let r = ui.selectable_label(on, egui::RichText::new(text).color(if avail { t.text } else { t.text_faint }));
rects.push((format!("character.opentype.{key}"), r.rect, label.to_string()));
if r.clicked() {
actions.push(json!({key: !on}));
}
}
ui.separator();
ui.label(egui::RichText::new("Figures").small().color(t.text_dim));
let figs = [
("default", "Default Figure Style", FigureStyle::Default, FigureWidth::Default),
("tabularLining", "Tabular Lining", FigureStyle::Lining, FigureWidth::Tabular),
("proportionalOldstyle", "Proportional Oldstyle", FigureStyle::OldStyle, FigureWidth::Proportional),
("proportionalLining", "Proportional Lining", FigureStyle::Lining, FigureWidth::Proportional),
("tabularOldstyle", "Tabular Oldstyle", FigureStyle::OldStyle, FigureWidth::Tabular),
];
for (key, label, st, fw) in figs {
let on = o.figure_style == st && o.figure_width == fw;
let r = ui.selectable_label(on, label);
rects.push((format!("character.opentype.figures.{key}"), r.rect, label.to_string()));
if r.clicked() {
actions.push(json!({"figures": key}));
}
}
ui.separator();
ui.label(egui::RichText::new("Stylistic Sets").small().color(t.text_dim));
egui::Grid::new(id.with("sets")).spacing(vec2(3.0, 3.0)).show(ui, |ui| {
for n in 1..=20u32 {
let key = format!("ss{n:02}");
let on = o.stylistic_set(n);
let avail = has(&key);
let r = ui.add_sized(
vec2(38.0, 20.0),
egui::Button::selectable(on, egui::RichText::new(format!("{n}")).color(if avail || on { t.text } else { t.text_faint })),
);
rects.push((format!("character.opentype.{key}"), r.rect, format!("Stylistic Set {n}")));
if r.clicked() {
actions.push(json!({key: !on}));
}
if n % 5 == 0 {
ui.end_row();
}
}
});
});
});
for (aid, r, label) in rects {
app.auto.add(&aid, r, &label);
}
let outside = ui.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer() && !area.response.hovered();
if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(open_id, false));
}
}
/// The seven Paragraph panel alignment buttons: (justify, `layer.setText` key).
const ALIGNS: [(Justify, &str); 7] = [
(Justify::Left, "left"),

View File

@ -26,6 +26,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L2 | `text` | Fonts, shaping, layout, per-glyph geometry, text animators and selectors |
| L2 | `effects` | The effect registry (241 effects) and their CPU implementations |
| L2 | `svg` | SVG import (W3C SVG 1.1/2 static subset: shapes, paths, transforms, `use`, CSS, gradients) and rasterisation at any scale |
| L2 | `pdf` | PDF, PDF-compatible Illustrator (`.ai`) and EPS (PostScript subset) vector footage into the `svg` render tree: paths, fills, strokes, shadings, clipping, optional-content layers |
| L2 | `model` | 3D models for Advanced 3D: glTF 2.0 (`.gltf`/`.glb`) and OBJ/MTL import (meshes, PBR metallic-roughness materials and textures, node hierarchy, skins, animations), parametric primitives, extruded/bevelled outline meshes and polygon triangulation |
| L2 | `track` | Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation, Lucas–Kanade sub-pixel refinement), confidence, homography/affine/similarity solves |
| L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown |

View File

@ -21,8 +21,8 @@ Tracking 88%, Effects 85%, Web 65%.
What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; Render Queue field render, crop/resize and templates;
approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; OpenType features and variable
font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; face tracking; and the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7).
approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; text in imported
PDF/AI files and Illustrator procset EPS; more codecs; 59 preferences not yet wired; ScriptUI; face tracking; and the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7).
The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for
history.
@ -109,14 +109,14 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit |
| Output | 90% | 3.0 | multiple output modules, pre-render (WebM with VP9 alpha + Opus and WAV/AIFF audio-only landed: VP9 is intra-only, Opus CELT-only) |
| Audio | 85% | 0.5 | audio to keyframes |
| Import | 88% | 2.0 | AI/EPS/PDF vector footage, PSD smart objects and 3D layers (PSD as footage/composition/retain layer sizes, SVG footage and Create Shapes from Vector Layer landed) |
| Import | 94% | 1.0 | text and images inside PDF/AI files, Illustrator EPS relying on Adobe procsets, smart-object perspective/warps, PSD 3D layers (PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) |
| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets |
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
| Animation | 67% | 10.0 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Text | ≈ 94% | 0.5 | no extruded strokes, variable-axis animation changes outlines but not advances (OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
| Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
| Interface | 74% | 5.0 | a richer Learn area (Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |

View File

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