Merge branch 'worktree-agent-aadac0ebe6eb9e405'
This commit is contained in:
commit
ed46bc0cce
|
|
@ -1213,6 +1213,7 @@ dependencies = [
|
|||
"effectcraft-lottie",
|
||||
"effectcraft-model",
|
||||
"effectcraft-path",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
|
|
@ -1309,6 +1310,7 @@ dependencies = [
|
|||
"effectcraft-export",
|
||||
"effectcraft-expr",
|
||||
"effectcraft-media",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
|
|
@ -1352,6 +1354,7 @@ name = "effectcraft-media"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
|
|
@ -1398,6 +1401,15 @@ dependencies = [
|
|||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"effectcraft-svg",
|
||||
"kurbo",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ effectcraft-media = { path = "crates/media" }
|
|||
effectcraft-export = { path = "crates/export" }
|
||||
effectcraft-expr = { path = "crates/expr" }
|
||||
effectcraft-lottie = { path = "crates/lottie" }
|
||||
effectcraft-pdf = { path = "crates/pdf" }
|
||||
effectcraft-psd = { path = "crates/psd" }
|
||||
effectcraft-vp9enc = { path = "crates/vp9enc" }
|
||||
effectcraft-svg = { path = "crates/svg" }
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ effectcraft-lottie = { workspace = true }
|
|||
effectcraft-psd = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
effectcraft-svg = { workspace = true }
|
||||
effectcraft-pdf = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG footage →
|
||||
//! shape layer), Create Shapes from Text and Create Masks from Text (glyph outlines).
|
||||
//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG, PDF,
|
||||
//! Illustrator and EPS footage → shape layer), Create Shapes from Text and Create Masks from
|
||||
//! Text (glyph outlines), and vector files imported as compositions (one layer per file layer).
|
||||
|
||||
use effectcraft_keyframe::{Gradient, ShapePath, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
|
|
@ -149,6 +150,74 @@ pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec<PropGroup> {
|
|||
if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items }
|
||||
}
|
||||
|
||||
/// Whether footage is a vector file (SVG / PDF / AI / EPS).
|
||||
pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
|
||||
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
|
||||
}
|
||||
|
||||
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted
|
||||
/// to one layer when `layer` names it.
|
||||
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option<Result<Doc, String>> {
|
||||
if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) {
|
||||
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
|
||||
}
|
||||
effectcraft_pdf::sniff(bytes)?;
|
||||
Some(
|
||||
effectcraft_pdf::parse(bytes)
|
||||
.map(|d| match layer {
|
||||
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
|
||||
None => d,
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Import a PDF / Illustrator / EPS document as a composition the size of its page: one footage
|
||||
/// item per file layer (each showing only that layer, continuously rasterisable), top layer
|
||||
/// first. Returns (comp, folder, items).
|
||||
pub fn import_vector_comp(
|
||||
proj: &mut Project,
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
rate: effectcraft_time::FrameRate,
|
||||
duration: effectcraft_time::Tick,
|
||||
) -> Result<(ItemId, ItemId, Vec<ItemId>), String> {
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer};
|
||||
let doc = effectcraft_pdf::parse(bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF");
|
||||
let (w, h) = doc.pixel_size();
|
||||
let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder);
|
||||
let mut comp = Comp::new(w, h, rate, duration);
|
||||
let mut items = vec![];
|
||||
let names = effectcraft_pdf::layer_names(&doc);
|
||||
for (i, lname) in names.iter().enumerate() {
|
||||
let f = Footage {
|
||||
path: path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
width: w,
|
||||
height: h,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: rate,
|
||||
has_video: true,
|
||||
alpha: AlphaMode::Straight,
|
||||
loop_count: 1,
|
||||
codec: codec.into(),
|
||||
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f));
|
||||
items.push(id);
|
||||
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None);
|
||||
l.name = comp.unique_layer_name(lname);
|
||||
comp.layers.insert(0, l);
|
||||
}
|
||||
let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(std::sync::Arc::new(comp)));
|
||||
Ok((cid, folder, items))
|
||||
}
|
||||
|
||||
/// A copy of `src`'s transform group with fresh uids.
|
||||
fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) {
|
||||
let Some(tr) = src.props.sub("transform") else { return };
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@ effectcraft-export = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
effectcraft-psd = { workspace = true }
|
||||
effectcraft-svg = { workspace = true }
|
||||
effectcraft-pdf = { workspace = true }
|
||||
effectcraft-raster = { workspace = true }
|
||||
effectcraft-time = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -184,3 +184,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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ effectcraft-raster = { workspace = true }
|
|||
effectcraft-render = { workspace = true }
|
||||
effectcraft-psd = { workspace = true }
|
||||
effectcraft-svg = { workspace = true }
|
||||
effectcraft-pdf = { workspace = true }
|
||||
effectcraft-model = { workspace = true }
|
||||
filmcraft-codecs = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! Layered and vector stills: Photoshop documents (`effectcraft-psd`: the merged image, or one
|
||||
//! layer for footage imported as a composition) and SVG (`effectcraft-svg`, rasterised).
|
||||
//! layer for footage imported as a composition) and vector files rasterised at any scale: SVG
|
||||
//! (`effectcraft-svg`), PDF / Illustrator / EPS (`effectcraft-pdf`; one layer of the file for
|
||||
//! footage imported as a composition).
|
||||
|
||||
use effectcraft_project::{AlphaMode, Footage};
|
||||
use effectcraft_raster::Image;
|
||||
|
|
@ -11,7 +13,29 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool {
|
|||
path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes)
|
||||
}
|
||||
|
||||
/// Footage for a Photoshop or SVG file (`None` for other formats).
|
||||
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted
|
||||
/// to the footage's layer when it names one.
|
||||
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option<std::result::Result<effectcraft_svg::Doc, String>> {
|
||||
if is_svg(path, bytes) {
|
||||
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
|
||||
}
|
||||
effectcraft_pdf::sniff(bytes)?;
|
||||
Some(
|
||||
effectcraft_pdf::parse(bytes)
|
||||
.map(|d| match layer {
|
||||
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
|
||||
None => d,
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_pdf_like(path: &str, bytes: &[u8]) -> bool {
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF"))
|
||||
}
|
||||
|
||||
/// Footage for a Photoshop or vector file (`None` for other formats).
|
||||
pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result<Option<Footage>> {
|
||||
if effectcraft_psd::is_psd(bytes) {
|
||||
let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
|
||||
|
|
@ -28,6 +52,14 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result<Option<Footage>> {
|
|||
f.alpha = AlphaMode::Straight;
|
||||
return Ok(Some(f));
|
||||
}
|
||||
if is_pdf_like(path, bytes) {
|
||||
let doc = effectcraft_pdf::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
|
||||
let (w, h) = doc.pixel_size();
|
||||
let mut f = crate::probe::still_footage(path, w, h, image::ImageFormat::Png, true);
|
||||
f.codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF").into();
|
||||
f.alpha = AlphaMode::Straight;
|
||||
return Ok(Some(f));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "effectcraft-pdf"
|
||||
description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages and EPS (a PostScript subset) parsed into the SVG render tree (paths, fills, strokes, axial / radial shadings, clipping, optional-content layers)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
effectcraft-svg = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# effectcraft-pdf
|
||||
|
||||
Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, which are PDF
|
||||
documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG
|
||||
(`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape
|
||||
layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one
|
||||
layer per file layer (File ▸ Import ▸ Composition).
|
||||
|
||||
Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file
|
||||
structure, filters, content streams, graphics state, colour spaces, functions, shadings and
|
||||
optional content; the **PostScript Language Reference, third edition** and the **Encapsulated
|
||||
PostScript File Format Specification 3.0** for EPS. No third-party PDF/PostScript code was read;
|
||||
test files are generated in code ([`write`](src/write.rs) and the tests).
|
||||
|
||||
## What is read
|
||||
|
||||
- **Structure:** indirect objects found by scanning (damaged or incremental cross-reference
|
||||
sections do not matter), object streams, the page tree with inherited resources, MediaBox /
|
||||
CropBox and `/Rotate`. Filters: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
|
||||
RunLength. Encrypted files are refused.
|
||||
- **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`,
|
||||
`LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd),
|
||||
clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by
|
||||
component count), CalGray / CalRGB, Lab, Indexed, Separation and DeviceN (tint transforms of
|
||||
function types 0, 2 and 3), axial and radial shadings as pattern fills and through `sh`, form
|
||||
XObjects (matrix, bounding-box clip, nested resources).
|
||||
- **Layers:** top-level optional-content marked sequences (`/OC … BDC`, Illustrator's layers)
|
||||
become the document's layers, named after their OCG; content outside them is gathered into
|
||||
layers "Layer N".
|
||||
- **EPS:** a PostScript interpreter for the subset exported vector EPS uses: operand and
|
||||
dictionary stacks, procedures and `def`/`bind`/`load`/`where`, arithmetic, comparisons,
|
||||
`if`/`ifelse`/`for`/`repeat`/`loop`/`forall`, arrays and strings, `gsave`/`grestore`,
|
||||
`save`/`restore`, matrices (`concat`, `translate`, `scale`, `rotate`, `transform` …), path
|
||||
construction including `arc`/`arcn` and relative operators, `fill`/`eofill`/`stroke`,
|
||||
`clip`/`eoclip`, `rectfill`/`rectstroke`/`rectclip`, gray / RGB / CMYK / HSB colour. DOS EPS
|
||||
binary headers and `%%HiResBoundingBox` / `%%BoundingBox` are honoured; `%%BeginData` /
|
||||
`%%BeginBinary` / preview sections are skipped.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Text is not drawn (embedded fonts are not read); images, tiling patterns, mesh shadings
|
||||
(types 4–7), PostScript calculator functions, soft masks and blend modes are skipped. What a
|
||||
document skipped is listed in `Doc::skipped`.
|
||||
- Illustrator EPS files whose drawing depends on Adobe's procedure-set resources (defined in
|
||||
the file through constructs beyond this subset) and Level 3 `shfill` gradients in EPS draw
|
||||
only what the subset understands; save such artwork as PDF-compatible `.ai` or PDF instead.
|
||||
- Shape-layer conversion has no clipping: clipped artwork converts unclipped.
|
||||
- Only the first page of a multi-page PDF is imported.
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
//! The output side shared by the PDF and PostScript interpreters: an open-group stack (clipping
|
||||
//! groups, optional-content layers) that collects shapes into the SVG render tree.
|
||||
|
||||
use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Plain,
|
||||
Clip,
|
||||
Layer,
|
||||
}
|
||||
|
||||
pub(crate) struct Builder {
|
||||
stack: Vec<(Group, Kind)>,
|
||||
/// Which of the finished top-level nodes are optional-content layers.
|
||||
pub top_layers: Vec<bool>,
|
||||
pub skipped: Vec<String>,
|
||||
pub shapes: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn group(name: &str) -> Group {
|
||||
Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![] }
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
pub fn new() -> Builder {
|
||||
Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0 }
|
||||
}
|
||||
|
||||
pub fn depth(&self) -> usize {
|
||||
self.stack.len()
|
||||
}
|
||||
|
||||
pub fn skip(&mut self, what: &str) {
|
||||
if !self.skipped.iter().any(|s| s == what) {
|
||||
self.skipped.push(what.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, n: Node, layer: bool) {
|
||||
if self.stack.len() == 1 {
|
||||
self.top_layers.push(layer);
|
||||
}
|
||||
if let Some((g, _)) = self.stack.last_mut() {
|
||||
g.children.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close every group opened above `depth`.
|
||||
pub fn close_to(&mut self, depth: usize) {
|
||||
while self.stack.len() > depth.max(1) {
|
||||
let Some((g, k)) = self.stack.pop() else { break };
|
||||
// Empty clipping groups draw nothing.
|
||||
if g.children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.add(Node::Group(g), k == Kind::Layer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Children drawn from now on are clipped by `path` (page space) until the group closes.
|
||||
pub fn clip(&mut self, path: BezPath, rule: FillRule) {
|
||||
let mut g = group("Clip Group");
|
||||
g.clip.push((path, rule));
|
||||
self.stack.push((g, Kind::Clip));
|
||||
}
|
||||
|
||||
/// Open an optional-content layer (or a named group).
|
||||
pub fn open(&mut self, name: &str, layer: bool) {
|
||||
self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain }));
|
||||
}
|
||||
|
||||
pub fn fill_stroke(&mut self, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option<Stroke>) {
|
||||
if path.elements().is_empty() || (fill.is_none() && stroke.is_none()) {
|
||||
return;
|
||||
}
|
||||
self.shapes += 1;
|
||||
let (f, fo, rule) = match fill {
|
||||
Some((p, o, r)) => (Some(p), o, r),
|
||||
None => (None, 1.0, FillRule::NonZero),
|
||||
};
|
||||
let s = Shape { name: "Path".into(), geom: Geom::Path(path), transform, fill: f, fill_opacity: fo, fill_rule: rule, stroke, opacity: 1.0 };
|
||||
self.add(Node::Shape(s), false);
|
||||
}
|
||||
|
||||
/// The finished top-level nodes and their layer flags.
|
||||
pub fn finish(mut self) -> (Vec<Node>, Vec<bool>, Vec<String>) {
|
||||
self.close_to(1);
|
||||
let root = self.stack.pop().map(|(g, _)| g.children).unwrap_or_default();
|
||||
let flags = self.top_layers;
|
||||
(root, flags, self.skipped)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
//! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled,
|
||||
//! exponential and stitching; PostScript calculator functions are not evaluated).
|
||||
|
||||
use crate::object::{Dict, File, Obj, decode_stream};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Cs {
|
||||
Gray,
|
||||
Rgb,
|
||||
Cmyk,
|
||||
/// CIE L*a*b* (approximated).
|
||||
Lab,
|
||||
Indexed {
|
||||
base: Box<Cs>,
|
||||
hival: usize,
|
||||
lookup: Vec<u8>,
|
||||
},
|
||||
/// Separation / DeviceN: components → alternate space through a tint transform.
|
||||
Tint {
|
||||
n: usize,
|
||||
alt: Box<Cs>,
|
||||
func: Func,
|
||||
},
|
||||
Pattern,
|
||||
}
|
||||
|
||||
impl Cs {
|
||||
pub fn components(&self) -> usize {
|
||||
match self {
|
||||
Cs::Gray | Cs::Indexed { .. } => 1,
|
||||
Cs::Rgb | Cs::Lab => 3,
|
||||
Cs::Cmyk => 4,
|
||||
Cs::Tint { n, .. } => *n,
|
||||
Cs::Pattern => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The initial colour of the space (black, or tint 1).
|
||||
pub fn initial(&self) -> Vec<f64> {
|
||||
match self {
|
||||
Cs::Cmyk => vec![0.0, 0.0, 0.0, 1.0],
|
||||
Cs::Tint { n, .. } => vec![1.0; *n],
|
||||
Cs::Lab => vec![0.0, 0.0, 0.0],
|
||||
c => vec![0.0; c.components()],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_rgb(&self, c: &[f64]) -> [f64; 3] {
|
||||
let g = |i: usize| c.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
match self {
|
||||
Cs::Gray => [g(0); 3],
|
||||
Cs::Rgb | Cs::Pattern => [g(0), g(1), g(2)],
|
||||
Cs::Cmyk => {
|
||||
let k = g(3);
|
||||
[(1.0 - g(0)) * (1.0 - k), (1.0 - g(1)) * (1.0 - k), (1.0 - g(2)) * (1.0 - k)]
|
||||
}
|
||||
Cs::Lab => lab_to_rgb(c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0), c.get(2).copied().unwrap_or(0.0)),
|
||||
Cs::Indexed { base, hival, lookup } => {
|
||||
let i = (c.first().copied().unwrap_or(0.0).round().max(0.0) as usize).min(*hival);
|
||||
let n = base.components();
|
||||
let v: Vec<f64> = (0..n).map(|k| lookup.get(i * n + k).copied().unwrap_or(0) as f64 / 255.0).collect();
|
||||
base.to_rgb(&v)
|
||||
}
|
||||
Cs::Tint { alt, func, .. } => alt.to_rgb(&func.eval(c)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lab_to_rgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||
let fy = (l + 16.0) / 116.0;
|
||||
let fx = fy + a / 500.0;
|
||||
let fz = fy - b / 200.0;
|
||||
let f = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) };
|
||||
let (x, y, z) = (0.9505 * f(fx), f(fy), 1.089 * f(fz));
|
||||
let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.204 * y + 1.057 * z];
|
||||
lin.map(|v| {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a colour space (a name, or an array such as `[/ICCBased 5 0 R]`); named resources are
|
||||
/// looked up in `res` (`/ColorSpace`).
|
||||
pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
|
||||
let o = file.resolve(o);
|
||||
match o {
|
||||
Obj::Name(n) => match n.as_str() {
|
||||
"DeviceGray" | "G" | "CalGray" => Cs::Gray,
|
||||
"DeviceRGB" | "RGB" | "CalRGB" => Cs::Rgb,
|
||||
"DeviceCMYK" | "CMYK" => Cs::Cmyk,
|
||||
"Pattern" => Cs::Pattern,
|
||||
other => match res.and_then(|r| file.get_dict(r, "ColorSpace")).and_then(|cs| file.get(cs, other)) {
|
||||
Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space(file, x, None),
|
||||
_ => Cs::Gray,
|
||||
},
|
||||
},
|
||||
Obj::Array(a) => {
|
||||
let head = a.first().map(|h| file.resolve(h)).and_then(Obj::name).unwrap_or("");
|
||||
match head {
|
||||
"ICCBased" => {
|
||||
let n = a.get(1).map(|s| file.resolve(s)).and_then(Obj::dict).and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize;
|
||||
match n {
|
||||
1 => Cs::Gray,
|
||||
4 => Cs::Cmyk,
|
||||
_ => Cs::Rgb,
|
||||
}
|
||||
}
|
||||
"CalRGB" => Cs::Rgb,
|
||||
"CalGray" => Cs::Gray,
|
||||
"Lab" => Cs::Lab,
|
||||
"Pattern" => Cs::Pattern,
|
||||
"Indexed" | "I" => {
|
||||
let base = a.get(1).map(|b| color_space(file, b, res)).unwrap_or(Cs::Rgb);
|
||||
let hival = a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize;
|
||||
let lookup = match a.get(3).map(|l| file.resolve(l)) {
|
||||
Some(Obj::Str(s)) => s.clone(),
|
||||
Some(Obj::Stream(d, raw)) => decode_stream(file, d, raw).unwrap_or_default(),
|
||||
_ => vec![],
|
||||
};
|
||||
Cs::Indexed { base: Box::new(base), hival, lookup }
|
||||
}
|
||||
"Separation" | "DeviceN" => {
|
||||
let n = if head == "Separation" { 1 } else { a.get(1).map(|x| file.resolve(x)).and_then(Obj::array).map_or(1, <[Obj]>::len) };
|
||||
let alt = a.get(2).map(|b| color_space(file, b, res)).unwrap_or(Cs::Gray);
|
||||
let func = a.get(3).map(|f| Func::read(file, f)).unwrap_or(Func::Unsupported);
|
||||
// An unreadable tint transform: tint 1 = black, 0 = white.
|
||||
if func == Func::Unsupported {
|
||||
return Cs::Tint { n, alt: Box::new(Cs::Gray), func: Func::Exp { domain: [0.0, 1.0], c0: vec![1.0], c1: vec![0.0], n: 1.0 } };
|
||||
}
|
||||
Cs::Tint { n, alt: Box::new(alt), func }
|
||||
}
|
||||
_ => Cs::Rgb,
|
||||
}
|
||||
}
|
||||
_ => Cs::Gray,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Func {
|
||||
/// Type 2.
|
||||
Exp {
|
||||
domain: [f64; 2],
|
||||
c0: Vec<f64>,
|
||||
c1: Vec<f64>,
|
||||
n: f64,
|
||||
},
|
||||
/// Type 3.
|
||||
Stitch {
|
||||
domain: [f64; 2],
|
||||
funcs: Vec<Func>,
|
||||
bounds: Vec<f64>,
|
||||
encode: Vec<f64>,
|
||||
},
|
||||
/// Type 0 with one input.
|
||||
Sampled {
|
||||
domain: [f64; 2],
|
||||
size: usize,
|
||||
outputs: usize,
|
||||
encode: [f64; 2],
|
||||
decode: Vec<f64>,
|
||||
samples: Vec<f64>,
|
||||
},
|
||||
/// An array of one-output functions.
|
||||
Array(Vec<Func>),
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl Func {
|
||||
pub fn read(file: &File, o: &Obj) -> Func {
|
||||
let o = file.resolve(o);
|
||||
if let Obj::Array(a) = o {
|
||||
return Func::Array(a.iter().map(|f| Func::read(file, f)).collect());
|
||||
}
|
||||
let Some(d) = o.dict() else { return Func::Unsupported };
|
||||
let dom = file.get(d, "Domain").map(|x| file.nums(x)).unwrap_or_default();
|
||||
let domain = [dom.first().copied().unwrap_or(0.0), dom.get(1).copied().unwrap_or(1.0)];
|
||||
match file.get_num(d, "FunctionType").unwrap_or(-1.0) as i32 {
|
||||
2 => Func::Exp {
|
||||
domain,
|
||||
c0: file.get(d, "C0").map(|x| file.nums(x)).unwrap_or_else(|| vec![0.0]),
|
||||
c1: file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]),
|
||||
n: file.get_num(d, "N").unwrap_or(1.0),
|
||||
},
|
||||
3 => Func::Stitch {
|
||||
domain,
|
||||
funcs: file.get(d, "Functions").and_then(Obj::array).map(|a| a.iter().map(|f| Func::read(file, f)).collect()).unwrap_or_default(),
|
||||
bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(),
|
||||
encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(),
|
||||
},
|
||||
0 => {
|
||||
let Obj::Stream(sd, raw) = o else { return Func::Unsupported };
|
||||
let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported };
|
||||
let size = file.get(d, "Size").map(|x| file.nums(x)).and_then(|v| v.first().copied()).unwrap_or(0.0) as usize;
|
||||
let bps = file.get_num(d, "BitsPerSample").unwrap_or(8.0) as usize;
|
||||
let range = file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default();
|
||||
let outputs = range.len() / 2;
|
||||
if size == 0 || outputs == 0 || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
|
||||
return Func::Unsupported;
|
||||
}
|
||||
let enc = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default();
|
||||
let encode = [enc.first().copied().unwrap_or(0.0), enc.get(1).copied().unwrap_or(size as f64 - 1.0)];
|
||||
let decode = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= outputs * 2).unwrap_or_else(|| range.clone());
|
||||
let max = ((1u64 << bps) - 1) as f64;
|
||||
let mut samples = Vec::with_capacity(size * outputs);
|
||||
let mut bit = 0usize;
|
||||
for _ in 0..size * outputs {
|
||||
let mut v = 0u64;
|
||||
for _ in 0..bps {
|
||||
let byte = data.get(bit / 8).copied().unwrap_or(0);
|
||||
v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64;
|
||||
bit += 1;
|
||||
}
|
||||
samples.push(v as f64 / max);
|
||||
}
|
||||
Func::Sampled { domain, size, outputs, encode, decode, samples }
|
||||
}
|
||||
_ => Func::Unsupported,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate at the first input (the functions used by shadings and tints take one input
|
||||
/// in practice; extra inputs of DeviceN tints are averaged in).
|
||||
pub fn eval(&self, input: &[f64]) -> Vec<f64> {
|
||||
let t = input.first().copied().unwrap_or(0.0);
|
||||
self.eval1(t)
|
||||
}
|
||||
|
||||
pub fn eval1(&self, t: f64) -> Vec<f64> {
|
||||
match self {
|
||||
Func::Exp { domain, c0, c1, n } => {
|
||||
let t = t.clamp(domain[0].min(domain[1]), domain[0].max(domain[1]));
|
||||
let x = if *n == 1.0 { t } else { t.max(0.0).powf(*n) };
|
||||
(0..c0.len().max(c1.len()))
|
||||
.map(|i| c0.get(i).copied().unwrap_or(0.0) + x * (c1.get(i).copied().unwrap_or(1.0) - c0.get(i).copied().unwrap_or(0.0)))
|
||||
.collect()
|
||||
}
|
||||
Func::Stitch { domain, funcs, bounds, encode } => {
|
||||
if funcs.is_empty() {
|
||||
return vec![0.0];
|
||||
}
|
||||
let t = t.clamp(domain[0], domain[1]);
|
||||
let k = bounds.iter().take_while(|b| t >= **b).count().min(funcs.len() - 1);
|
||||
let lo = if k == 0 { domain[0] } else { bounds[k - 1] };
|
||||
let hi = bounds.get(k).copied().unwrap_or(domain[1]);
|
||||
let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0));
|
||||
let u = if hi > lo { e0 + (t - lo) / (hi - lo) * (e1 - e0) } else { e0 };
|
||||
funcs[k].eval1(u)
|
||||
}
|
||||
Func::Sampled { domain, size, outputs, encode, decode, samples } => {
|
||||
let t = t.clamp(domain[0], domain[1]);
|
||||
let e = if domain[1] > domain[0] { encode[0] + (t - domain[0]) / (domain[1] - domain[0]) * (encode[1] - encode[0]) } else { encode[0] };
|
||||
let e = e.clamp(0.0, *size as f64 - 1.0);
|
||||
let (i0, f) = (e.floor() as usize, e.fract());
|
||||
let i1 = (i0 + 1).min(size - 1);
|
||||
(0..*outputs)
|
||||
.map(|o| {
|
||||
let s = samples[i0 * outputs + o] * (1.0 - f) + samples[i1 * outputs + o] * f;
|
||||
let (d0, d1) = (decode.get(2 * o).copied().unwrap_or(0.0), decode.get(2 * o + 1).copied().unwrap_or(1.0));
|
||||
d0 + s * (d1 - d0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Func::Array(fs) => fs.iter().map(|f| f.eval1(t).first().copied().unwrap_or(0.0)).collect(),
|
||||
Func::Unsupported => vec![0.5],
|
||||
}
|
||||
}
|
||||
|
||||
/// Input values worth sampling exactly (stitching bounds).
|
||||
pub fn breakpoints(&self) -> Vec<f64> {
|
||||
match self {
|
||||
Func::Stitch { bounds, .. } => bounds.clone(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PDF text string → Rust string (UTF-16BE with a byte-order mark, else PDFDocEncoding ≈ Latin-1).
|
||||
pub fn text_string(b: &[u8]) -> String {
|
||||
if b.starts_with(&[0xFE, 0xFF]) {
|
||||
let u: Vec<u16> = b[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
return String::from_utf16_lossy(&u);
|
||||
}
|
||||
if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
return String::from_utf8_lossy(rest).into_owned();
|
||||
}
|
||||
b.iter().map(|&c| c as char).collect()
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
//! Vector footage: PDF, PDF-compatible Illustrator files (`.ai`) and Encapsulated PostScript
|
||||
//! (see README.md for what is read and the specifications used).
|
||||
//!
|
||||
//! Files parse into the same render tree as SVG ([`effectcraft_svg::Doc`]): document pixels are
|
||||
//! PostScript points (1/72 in), y down, so footage rasterises at any scale (Continuously
|
||||
//! Rasterize) and converts to shape layers (Create Shapes from Vector Layer). The root group's
|
||||
//! children are the document's **layers**: one group per top-level optional-content group
|
||||
//! (Illustrator layers), with content outside them gathered into groups of its own.
|
||||
|
||||
mod build;
|
||||
mod color;
|
||||
mod object;
|
||||
mod page;
|
||||
mod ps;
|
||||
pub mod write;
|
||||
|
||||
pub use effectcraft_svg::Doc;
|
||||
use effectcraft_svg::{Affine, Group, Node};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Error {
|
||||
NotVector,
|
||||
Encrypted,
|
||||
NoPages,
|
||||
PageOutOfRange(usize),
|
||||
NoBoundingBox,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::NotVector => write!(f, "not a PDF, Illustrator or EPS file"),
|
||||
Error::Encrypted => write!(f, "encrypted PDF files are not supported"),
|
||||
Error::NoPages => write!(f, "the PDF has no pages"),
|
||||
Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n + 1),
|
||||
Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
/// File extensions read as vector footage by this crate.
|
||||
pub const EXTENSIONS: &[&str] = &["pdf", "ai", "eps", "epsf", "epsi"];
|
||||
|
||||
/// The format of a file, by its first bytes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Pdf,
|
||||
Eps,
|
||||
}
|
||||
|
||||
/// Sniff a PDF (`%PDF-` near the start, as PDF-compatible `.ai` files are) or an EPS file.
|
||||
pub fn sniff(bytes: &[u8]) -> Option<Format> {
|
||||
let head = &bytes[..bytes.len().min(1024)];
|
||||
if head.windows(5).any(|w| w == b"%PDF-") {
|
||||
return Some(Format::Pdf);
|
||||
}
|
||||
if bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) || bytes.starts_with(b"%!PS") {
|
||||
return Some(Format::Eps);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A footage codec name for a sniffed file ("PDF", "AI", "EPS").
|
||||
pub fn codec(path: &str, bytes: &[u8]) -> Option<&'static str> {
|
||||
let f = sniff(bytes)?;
|
||||
let ai = path.to_ascii_lowercase().ends_with(".ai");
|
||||
Some(match (f, ai) {
|
||||
(_, true) => "AI",
|
||||
(Format::Pdf, false) => "PDF",
|
||||
(Format::Eps, false) => "EPS",
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the first page of a PDF / `.ai`, or an EPS file.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
|
||||
parse_page(bytes, 0)
|
||||
}
|
||||
|
||||
/// Parse page `index` of a PDF (EPS files have one page).
|
||||
pub fn parse_page(bytes: &[u8], index: usize) -> Result<Doc, Error> {
|
||||
match sniff(bytes) {
|
||||
Some(Format::Pdf) => parse_pdf(bytes, index),
|
||||
Some(Format::Eps) => parse_eps(bytes),
|
||||
None => Err(Error::NotVector),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of pages (1 for EPS).
|
||||
pub fn page_count(bytes: &[u8]) -> usize {
|
||||
match sniff(bytes) {
|
||||
Some(Format::Pdf) => page::pages(&object::File::parse(bytes)).len(),
|
||||
Some(Format::Eps) => 1,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the document: `root` children become layer groups (see the crate docs).
|
||||
fn finish(nodes: Vec<Node>, flags: Vec<bool>, skipped: Vec<String>, width: f64, height: f64, to_doc: Affine) -> Doc {
|
||||
let mut layers: Vec<Node> = vec![];
|
||||
let mut loose: Vec<Node> = vec![];
|
||||
let flush = |loose: &mut Vec<Node>, layers: &mut Vec<Node>| {
|
||||
if !loose.is_empty() {
|
||||
let mut g = build::group("");
|
||||
g.children = std::mem::take(loose);
|
||||
layers.push(Node::Group(g));
|
||||
}
|
||||
};
|
||||
for (n, is_layer) in nodes.into_iter().zip(flags.into_iter().chain(std::iter::repeat(false))) {
|
||||
if is_layer {
|
||||
flush(&mut loose, &mut layers);
|
||||
layers.push(n);
|
||||
} else {
|
||||
loose.push(n);
|
||||
}
|
||||
}
|
||||
flush(&mut loose, &mut layers);
|
||||
// Name unnamed layers "Layer N" (bottom first, as Illustrator numbers them).
|
||||
for (i, l) in layers.iter_mut().enumerate() {
|
||||
if let Node::Group(g) = l
|
||||
&& g.name.is_empty()
|
||||
{
|
||||
g.name = format!("Layer {}", i + 1);
|
||||
}
|
||||
}
|
||||
Doc { width, height, root: Group { name: "page".into(), transform: to_doc, opacity: 1.0, children: layers, clip: vec![] }, skipped }
|
||||
}
|
||||
|
||||
fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
|
||||
let file = object::File::parse(bytes);
|
||||
if file.encrypted() {
|
||||
return Err(Error::Encrypted);
|
||||
}
|
||||
let pages = page::pages(&file);
|
||||
if pages.is_empty() {
|
||||
return Err(Error::NoPages);
|
||||
}
|
||||
let pg = pages.get(index).ok_or(Error::PageOutOfRange(index))?;
|
||||
let [x0, y0, x1, y1] = pg.bbox;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
// Default user space (y up) → document pixels (y down), with the page's /Rotate.
|
||||
let (to_doc, dw, dh) = match pg.rotate {
|
||||
90 => (Affine::new([0.0, 1.0, 1.0, 0.0, -y0, -x0]), h, w),
|
||||
180 => (Affine::new([-1.0, 0.0, 0.0, 1.0, x1, -y0]), w, h),
|
||||
270 => (Affine::new([0.0, -1.0, -1.0, 0.0, y1, x1]), h, w),
|
||||
_ => (Affine::new([1.0, 0.0, 0.0, -1.0, -x0, y1]), w, h),
|
||||
};
|
||||
let content = page::page_content(&file, pg);
|
||||
let mut it = page::Interp::new(&file, pg.bbox);
|
||||
it.run(&content, &pg.resources, Affine::IDENTITY, 0);
|
||||
let (nodes, flags, skipped) = it.b.finish();
|
||||
Ok(finish(nodes, flags, skipped, dw.max(1.0), dh.max(1.0), to_doc))
|
||||
}
|
||||
|
||||
fn parse_eps(bytes: &[u8]) -> Result<Doc, Error> {
|
||||
let (ps, bb) = ps::eps_parts(bytes).ok_or(Error::NoBoundingBox)?;
|
||||
let mut it = ps::Interp::new(bb);
|
||||
it.run(ps);
|
||||
let (nodes, flags, skipped) = it.b.finish();
|
||||
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -bb[0], bb[3]]);
|
||||
Ok(finish(nodes, flags, skipped, (bb[2] - bb[0]).max(1.0), (bb[3] - bb[1]).max(1.0), to_doc))
|
||||
}
|
||||
|
||||
/// The document's layer names (the root's children), bottom first.
|
||||
pub fn layer_names(doc: &Doc) -> Vec<String> {
|
||||
doc.root
|
||||
.children
|
||||
.iter()
|
||||
.map(|n| match n {
|
||||
Node::Group(g) => g.name.clone(),
|
||||
Node::Shape(s) => s.name.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The document with only layer `index` (for one layer of a file imported as a composition).
|
||||
pub fn layer_doc(doc: &Doc, index: usize) -> Doc {
|
||||
let mut d = doc.clone();
|
||||
d.root.children = doc.root.children.get(index).cloned().into_iter().collect();
|
||||
d
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,708 @@
|
|||
//! PDF objects (ISO 32000-1 §7.2–7.5): the lexer, the object model, stream filters and the
|
||||
//! indirect-object table.
|
||||
//!
|
||||
//! The table is built by scanning the file for `n g obj` headers (later definitions win, as
|
||||
//! incremental updates do) and by unpacking object streams, so damaged or rewritten
|
||||
//! cross-reference sections do not matter.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Obj {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Num(f64),
|
||||
Name(String),
|
||||
Str(Vec<u8>),
|
||||
Array(Vec<Obj>),
|
||||
Dict(Dict),
|
||||
Ref(u32, u16),
|
||||
/// A stream: its dictionary and raw (still encoded) data.
|
||||
Stream(Dict, Vec<u8>),
|
||||
/// A content-stream operator (or any bare keyword).
|
||||
Op(String),
|
||||
}
|
||||
|
||||
pub type Dict = HashMap<String, Obj>;
|
||||
|
||||
impl Obj {
|
||||
pub fn num(&self) -> Option<f64> {
|
||||
match self {
|
||||
Obj::Num(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
match self {
|
||||
Obj::Name(n) => Some(n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn array(&self) -> Option<&[Obj]> {
|
||||
match self {
|
||||
Obj::Array(a) => Some(a),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn dict(&self) -> Option<&Dict> {
|
||||
match self {
|
||||
Obj::Dict(d) | Obj::Stream(d, _) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_white(c: u8) -> bool {
|
||||
matches!(c, 0 | 9 | 10 | 12 | 13 | 32)
|
||||
}
|
||||
pub fn is_delim(c: u8) -> bool {
|
||||
matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
|
||||
}
|
||||
|
||||
/// A byte lexer producing PDF objects (arrays and dictionaries nested).
|
||||
pub struct Lexer<'a> {
|
||||
pub data: &'a [u8],
|
||||
pub pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
pub fn new(data: &'a [u8], pos: usize) -> Self {
|
||||
Lexer { data, pos }
|
||||
}
|
||||
|
||||
pub fn skip_ws(&mut self) {
|
||||
while self.pos < self.data.len() {
|
||||
let c = self.data[self.pos];
|
||||
if is_white(c) {
|
||||
self.pos += 1;
|
||||
} else if c == b'%' {
|
||||
while self.pos < self.data.len() && !matches!(self.data[self.pos], b'\n' | b'\r') {
|
||||
self.pos += 1;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn word(&mut self) -> &'a [u8] {
|
||||
let s = self.pos;
|
||||
while self.pos < self.data.len() && !is_white(self.data[self.pos]) && !is_delim(self.data[self.pos]) {
|
||||
self.pos += 1;
|
||||
}
|
||||
&self.data[s..self.pos]
|
||||
}
|
||||
|
||||
/// The next object, `None` at the end. `]` and `>>` come back as `Op`s.
|
||||
pub fn next(&mut self) -> Option<Obj> {
|
||||
self.next_depth(0)
|
||||
}
|
||||
|
||||
fn next_depth(&mut self, depth: usize) -> Option<Obj> {
|
||||
self.skip_ws();
|
||||
let c = *self.data.get(self.pos)?;
|
||||
match c {
|
||||
b'/' => {
|
||||
self.pos += 1;
|
||||
let w = self.word();
|
||||
Some(Obj::Name(decode_name(w)))
|
||||
}
|
||||
b'(' => Some(Obj::Str(self.literal_string())),
|
||||
b'<' if self.data.get(self.pos + 1) == Some(&b'<') => {
|
||||
self.pos += 2;
|
||||
let mut d = Dict::new();
|
||||
loop {
|
||||
if depth > 64 {
|
||||
return Some(Obj::Null);
|
||||
}
|
||||
let k = self.next_depth(depth + 1);
|
||||
match k {
|
||||
None => break,
|
||||
Some(Obj::Op(o)) if o == ">>" => break,
|
||||
Some(Obj::Name(k)) => {
|
||||
let v = self.next_depth(depth + 1).unwrap_or(Obj::Null);
|
||||
let v = self.maybe_ref(v);
|
||||
d.insert(k, v);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
Some(Obj::Dict(d))
|
||||
}
|
||||
b'<' => {
|
||||
self.pos += 1;
|
||||
let mut hex = vec![];
|
||||
while self.pos < self.data.len() && self.data[self.pos] != b'>' {
|
||||
let h = self.data[self.pos];
|
||||
if h.is_ascii_hexdigit() {
|
||||
hex.push(h);
|
||||
}
|
||||
self.pos += 1;
|
||||
}
|
||||
self.pos += 1;
|
||||
if hex.len() % 2 == 1 {
|
||||
hex.push(b'0');
|
||||
}
|
||||
Some(Obj::Str(hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect()))
|
||||
}
|
||||
b'>' if self.data.get(self.pos + 1) == Some(&b'>') => {
|
||||
self.pos += 2;
|
||||
Some(Obj::Op(">>".into()))
|
||||
}
|
||||
b'[' => {
|
||||
self.pos += 1;
|
||||
let mut a = vec![];
|
||||
loop {
|
||||
if depth > 64 {
|
||||
return Some(Obj::Null);
|
||||
}
|
||||
match self.next_depth(depth + 1) {
|
||||
None => break,
|
||||
Some(Obj::Op(o)) if o == "]" => break,
|
||||
Some(v) => {
|
||||
let v = self.maybe_ref(v);
|
||||
a.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Obj::Array(a))
|
||||
}
|
||||
b']' | b'{' | b'}' | b')' | b'>' => {
|
||||
self.pos += 1;
|
||||
Some(Obj::Op((c as char).to_string()))
|
||||
}
|
||||
_ => {
|
||||
let w = self.word();
|
||||
if w.is_empty() {
|
||||
self.pos += 1;
|
||||
return Some(Obj::Op(String::new()));
|
||||
}
|
||||
Some(match w {
|
||||
b"true" => Obj::Bool(true),
|
||||
b"false" => Obj::Bool(false),
|
||||
b"null" => Obj::Null,
|
||||
_ => match parse_num(w) {
|
||||
Some(n) => Obj::Num(n),
|
||||
None => Obj::Op(String::from_utf8_lossy(w).into_owned()),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `n g R` after a number inside arrays and dictionaries.
|
||||
fn maybe_ref(&mut self, v: Obj) -> Obj {
|
||||
let Obj::Num(n) = v else { return v };
|
||||
if n.fract() != 0.0 || n < 0.0 {
|
||||
return v;
|
||||
}
|
||||
let save = self.pos;
|
||||
self.skip_ws();
|
||||
let gp = self.pos;
|
||||
let g = self.word();
|
||||
if let Some(gn) = parse_num(g).filter(|g| g.fract() == 0.0 && *g >= 0.0) {
|
||||
self.skip_ws();
|
||||
if self.data.get(self.pos) == Some(&b'R') && self.data.get(self.pos + 1).is_none_or(|c| is_white(*c) || is_delim(*c)) {
|
||||
self.pos += 1;
|
||||
return Obj::Ref(n as u32, gn as u16);
|
||||
}
|
||||
}
|
||||
let _ = gp;
|
||||
self.pos = save;
|
||||
v
|
||||
}
|
||||
|
||||
fn literal_string(&mut self) -> Vec<u8> {
|
||||
self.pos += 1;
|
||||
let mut out = vec![];
|
||||
let mut depth = 1;
|
||||
while self.pos < self.data.len() {
|
||||
let c = self.data[self.pos];
|
||||
self.pos += 1;
|
||||
match c {
|
||||
b'\\' => {
|
||||
let Some(&e) = self.data.get(self.pos) else { break };
|
||||
self.pos += 1;
|
||||
match e {
|
||||
b'n' => out.push(b'\n'),
|
||||
b'r' => out.push(b'\r'),
|
||||
b't' => out.push(b'\t'),
|
||||
b'b' => out.push(8),
|
||||
b'f' => out.push(12),
|
||||
b'\r' => {
|
||||
if self.data.get(self.pos) == Some(&b'\n') {
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
b'\n' => {}
|
||||
b'0'..=b'7' => {
|
||||
let mut v = (e - b'0') as u32;
|
||||
for _ in 0..2 {
|
||||
match self.data.get(self.pos) {
|
||||
Some(d @ b'0'..=b'7') => {
|
||||
v = v * 8 + (d - b'0') as u32;
|
||||
self.pos += 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
out.push(v as u8);
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
out.push(c);
|
||||
}
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn hexv(c: u8) -> u8 {
|
||||
match c {
|
||||
b'0'..=b'9' => c - b'0',
|
||||
b'a'..=b'f' => c - b'a' + 10,
|
||||
b'A'..=b'F' => c - b'A' + 10,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_name(w: &[u8]) -> String {
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
while i < w.len() {
|
||||
if w[i] == b'#' && w.get(i + 1).is_some_and(u8::is_ascii_hexdigit) && w.get(i + 2).is_some_and(u8::is_ascii_hexdigit) {
|
||||
out.push((hexv(w[i + 1]) << 4) | hexv(w[i + 2]));
|
||||
i += 3;
|
||||
} else {
|
||||
out.push(w[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
pub fn parse_num(w: &[u8]) -> Option<f64> {
|
||||
if w.is_empty() || !w.iter().all(|c| c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.')) || !w.iter().any(u8::is_ascii_digit) {
|
||||
return None;
|
||||
}
|
||||
let s = std::str::from_utf8(w).ok()?;
|
||||
// "--5" and similar malformed numbers: take what parses.
|
||||
s.parse::<f64>().ok().or_else(|| s.trim_start_matches(['+', '-']).parse::<f64>().ok().map(|v| if s.starts_with('-') { -v } else { v }))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ filters
|
||||
|
||||
/// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT,
|
||||
/// JBIG2) give `None`.
|
||||
pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option<Vec<u8>> {
|
||||
let filters: Vec<String> = match d.get("Filter").map(|f| file.resolve(f)) {
|
||||
None => vec![],
|
||||
Some(Obj::Name(n)) => vec![n.clone()],
|
||||
Some(Obj::Array(a)) => a.iter().filter_map(|f| file.resolve(f).name().map(str::to_string)).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
let parms: Vec<Option<Dict>> = match d.get("DecodeParms").or_else(|| d.get("DP")).map(|p| file.resolve(p)) {
|
||||
Some(Obj::Dict(p)) => vec![Some(p.clone())],
|
||||
Some(Obj::Array(a)) => a.iter().map(|p| file.resolve(p).dict().cloned()).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
let mut data = raw.to_vec();
|
||||
for (i, f) in filters.iter().enumerate() {
|
||||
data = match f.as_str() {
|
||||
"FlateDecode" | "Fl" => inflate(&data)?,
|
||||
"ASCIIHexDecode" | "AHx" => ascii_hex(&data),
|
||||
"ASCII85Decode" | "A85" => ascii85(&data),
|
||||
"LZWDecode" | "LZW" => {
|
||||
lzw(&data, parms.get(i).and_then(|p| p.as_ref()).and_then(|p| p.get("EarlyChange")).and_then(Obj::num).unwrap_or(1.0) != 0.0)
|
||||
}
|
||||
"RunLengthDecode" | "RL" => run_length(&data),
|
||||
_ => return None,
|
||||
};
|
||||
if let Some(Some(p)) = parms.get(i)
|
||||
&& matches!(f.as_str(), "FlateDecode" | "Fl" | "LZWDecode" | "LZW")
|
||||
{
|
||||
data = predict(&data, p);
|
||||
}
|
||||
}
|
||||
Some(data)
|
||||
}
|
||||
|
||||
pub fn inflate(data: &[u8]) -> Option<Vec<u8>> {
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| {
|
||||
// Truncated streams: keep what inflates.
|
||||
let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib);
|
||||
let mut out = vec![0u8; data.len() * 8 + 1024];
|
||||
let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish);
|
||||
(r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec())
|
||||
})
|
||||
}
|
||||
|
||||
fn ascii_hex(data: &[u8]) -> Vec<u8> {
|
||||
let mut hex: Vec<u8> = data.iter().copied().take_while(|c| *c != b'>').filter(u8::is_ascii_hexdigit).collect();
|
||||
if hex.len() % 2 == 1 {
|
||||
hex.push(b'0');
|
||||
}
|
||||
hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect()
|
||||
}
|
||||
|
||||
pub fn ascii85(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
let mut group = [0u32; 5];
|
||||
let mut n = 0;
|
||||
let mut i = 0;
|
||||
if data.starts_with(b"<~") {
|
||||
i = 2;
|
||||
}
|
||||
while i < data.len() {
|
||||
let c = data[i];
|
||||
i += 1;
|
||||
match c {
|
||||
b'~' => break,
|
||||
b'z' if n == 0 => out.extend_from_slice(&[0; 4]),
|
||||
b'!'..=b'u' => {
|
||||
group[n] = (c - b'!') as u32;
|
||||
n += 1;
|
||||
if n == 5 {
|
||||
let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d));
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
n = 0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if n > 1 {
|
||||
for g in group.iter_mut().skip(n) {
|
||||
*g = 84;
|
||||
}
|
||||
let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d));
|
||||
out.extend_from_slice(&v.to_be_bytes()[..n - 1]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn lzw(data: &[u8], early: bool) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
let mut table: Vec<Vec<u8>> = (0..=255u16).map(|b| vec![b as u8]).collect();
|
||||
table.push(vec![]);
|
||||
table.push(vec![]);
|
||||
let (mut bits, mut acc, mut nbits) = (9u32, 0u32, 0u32);
|
||||
let mut prev: Option<Vec<u8>> = None;
|
||||
for &b in data {
|
||||
acc = (acc << 8) | b as u32;
|
||||
nbits += 8;
|
||||
while nbits >= bits {
|
||||
let code = ((acc >> (nbits - bits)) & ((1 << bits) - 1)) as usize;
|
||||
nbits -= bits;
|
||||
match code {
|
||||
256 => {
|
||||
table.truncate(258);
|
||||
bits = 9;
|
||||
prev = None;
|
||||
continue;
|
||||
}
|
||||
257 => return out,
|
||||
_ => {}
|
||||
}
|
||||
let entry = if code < table.len() {
|
||||
table[code].clone()
|
||||
} else if let Some(p) = &prev {
|
||||
let mut e = p.clone();
|
||||
e.push(p[0]);
|
||||
e
|
||||
} else {
|
||||
return out;
|
||||
};
|
||||
out.extend_from_slice(&entry);
|
||||
if let Some(p) = prev {
|
||||
let mut e = p;
|
||||
e.push(entry[0]);
|
||||
table.push(e);
|
||||
}
|
||||
prev = Some(entry);
|
||||
let limit = table.len() + usize::from(early);
|
||||
if limit >= (1 << bits) && bits < 12 {
|
||||
bits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn run_length(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
let l = data[i] as usize;
|
||||
i += 1;
|
||||
if l < 128 {
|
||||
out.extend_from_slice(&data[i.min(data.len())..(i + l + 1).min(data.len())]);
|
||||
i += l + 1;
|
||||
} else if l > 128 {
|
||||
if let Some(&b) = data.get(i) {
|
||||
out.extend(std::iter::repeat_n(b, 257 - l));
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// PNG / TIFF predictors (`/Predictor`, `/Colors`, `/BitsPerComponent`, `/Columns`).
|
||||
fn predict(data: &[u8], p: &Dict) -> Vec<u8> {
|
||||
let g = |k: &str, d: f64| p.get(k).and_then(Obj::num).unwrap_or(d) as usize;
|
||||
let pred = g("Predictor", 1.0);
|
||||
if pred < 10 {
|
||||
return data.to_vec();
|
||||
}
|
||||
let bpp = (g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8).max(1);
|
||||
let row = (g("Columns", 1.0) * g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8);
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut prev = vec![0u8; row];
|
||||
for chunk in data.chunks(row + 1) {
|
||||
if chunk.len() < row + 1 {
|
||||
break;
|
||||
}
|
||||
let ft = chunk[0];
|
||||
let mut cur = chunk[1..].to_vec();
|
||||
for i in 0..row {
|
||||
let a = if i >= bpp { cur[i - bpp] as i32 } else { 0 };
|
||||
let b = prev[i] as i32;
|
||||
let c = if i >= bpp { prev[i - bpp] as i32 } else { 0 };
|
||||
let add = match ft {
|
||||
1 => a,
|
||||
2 => b,
|
||||
3 => (a + b) / 2,
|
||||
4 => {
|
||||
let pp = a + b - c;
|
||||
let (pa, pb, pc) = ((pp - a).abs(), (pp - b).abs(), (pp - c).abs());
|
||||
if pa <= pb && pa <= pc {
|
||||
a
|
||||
} else if pb <= pc {
|
||||
b
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
cur[i] = (cur[i] as i32 + add) as u8;
|
||||
}
|
||||
out.extend_from_slice(&cur);
|
||||
prev = cur;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the file
|
||||
|
||||
/// Every indirect object of a PDF file, plus the trailer dictionaries found.
|
||||
pub struct File {
|
||||
pub objects: HashMap<u32, Obj>,
|
||||
pub trailers: Vec<Dict>,
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
if from >= hay.len() || needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
|
||||
}
|
||||
|
||||
impl File {
|
||||
pub fn parse(data: &[u8]) -> File {
|
||||
let mut f = File { objects: HashMap::new(), trailers: vec![] };
|
||||
let mut i = 0;
|
||||
while let Some(p) = find(data, b"obj", i) {
|
||||
i = p + 3;
|
||||
if p >= 3 && &data[p - 3..p] == b"end" {
|
||||
continue;
|
||||
}
|
||||
if data.get(p + 3).is_some_and(|c| !is_white(*c) && !is_delim(*c)) {
|
||||
continue;
|
||||
}
|
||||
// Walk back over "num gen ".
|
||||
let mut q = p;
|
||||
let back_ws = |q: &mut usize| {
|
||||
let s = *q;
|
||||
while *q > 0 && is_white(data[*q - 1]) {
|
||||
*q -= 1;
|
||||
}
|
||||
*q < s
|
||||
};
|
||||
let back_digits = |q: &mut usize| {
|
||||
let e = *q;
|
||||
while *q > 0 && data[*q - 1].is_ascii_digit() {
|
||||
*q -= 1;
|
||||
}
|
||||
(*q < e).then(|| std::str::from_utf8(&data[*q..e]).ok()?.parse::<u32>().ok()).flatten()
|
||||
};
|
||||
if !back_ws(&mut q) {
|
||||
continue;
|
||||
}
|
||||
let Some(_gen) = back_digits(&mut q) else { continue };
|
||||
if !back_ws(&mut q) {
|
||||
continue;
|
||||
}
|
||||
let Some(num) = back_digits(&mut q) else { continue };
|
||||
if q > 0 && !is_white(data[q - 1]) && !is_delim(data[q - 1]) {
|
||||
continue;
|
||||
}
|
||||
let mut lx = Lexer::new(data, p + 3);
|
||||
let Some(obj) = lx.next() else { continue };
|
||||
let obj = lx.maybe_ref(obj);
|
||||
let obj = match obj {
|
||||
Obj::Dict(d) => {
|
||||
let save = lx.pos;
|
||||
lx.skip_ws();
|
||||
if data[lx.pos..].starts_with(b"stream") {
|
||||
let mut s = lx.pos + 6;
|
||||
if data.get(s) == Some(&b'\r') {
|
||||
s += 1;
|
||||
}
|
||||
if data.get(s) == Some(&b'\n') {
|
||||
s += 1;
|
||||
}
|
||||
let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize);
|
||||
let end = match declared.filter(|l| {
|
||||
s + l <= data.len() && {
|
||||
let mut k = s + l;
|
||||
while k < data.len() && is_white(data[k]) {
|
||||
k += 1;
|
||||
}
|
||||
data[k..].starts_with(b"endstream")
|
||||
}
|
||||
}) {
|
||||
Some(l) => s + l,
|
||||
None => {
|
||||
let mut e = find(data, b"endstream", s).unwrap_or(data.len());
|
||||
if e > s && data[e - 1] == b'\n' {
|
||||
e -= 1;
|
||||
}
|
||||
if e > s && data[e - 1] == b'\r' {
|
||||
e -= 1;
|
||||
}
|
||||
e
|
||||
}
|
||||
};
|
||||
i = end;
|
||||
Obj::Stream(d, data[s..end].to_vec())
|
||||
} else {
|
||||
lx.pos = save;
|
||||
Obj::Dict(d)
|
||||
}
|
||||
}
|
||||
o => o,
|
||||
};
|
||||
if let Obj::Stream(d, _) = &obj
|
||||
&& d.get("Type").and_then(Obj::name) == Some("XRef")
|
||||
{
|
||||
f.trailers.push(d.clone());
|
||||
}
|
||||
f.objects.insert(num, obj);
|
||||
}
|
||||
// Trailer dictionaries of classic cross-reference sections.
|
||||
let mut i = 0;
|
||||
while let Some(p) = find(data, b"trailer", i) {
|
||||
i = p + 7;
|
||||
let mut lx = Lexer::new(data, i);
|
||||
if let Some(Obj::Dict(d)) = lx.next() {
|
||||
f.trailers.push(d);
|
||||
}
|
||||
}
|
||||
// Object streams.
|
||||
let streams: Vec<(Dict, Vec<u8>)> = f
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|o| match o {
|
||||
Obj::Stream(d, raw) if d.get("Type").and_then(Obj::name) == Some("ObjStm") => Some((d.clone(), raw.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (d, raw) in streams {
|
||||
let Some(dec) = decode_stream(&f, &d, &raw) else { continue };
|
||||
let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize;
|
||||
let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize;
|
||||
let mut lx = Lexer::new(&dec, 0);
|
||||
let mut heads = vec![];
|
||||
for _ in 0..n.min(100_000) {
|
||||
let (Some(Obj::Num(num)), Some(Obj::Num(off))) = (lx.next(), lx.next()) else { break };
|
||||
heads.push((num as u32, off as usize));
|
||||
}
|
||||
for (num, off) in heads {
|
||||
if f.objects.contains_key(&num) || first + off >= dec.len() {
|
||||
continue;
|
||||
}
|
||||
let mut lx = Lexer::new(&dec, first + off);
|
||||
if let Some(o) = lx.next() {
|
||||
let o = lx.maybe_ref(o);
|
||||
f.objects.insert(num, o);
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Follow references (bounded).
|
||||
pub fn resolve<'a>(&'a self, o: &'a Obj) -> &'a Obj {
|
||||
let mut o = o;
|
||||
for _ in 0..32 {
|
||||
match o {
|
||||
Obj::Ref(n, _) => match self.objects.get(n) {
|
||||
Some(x) => o = x,
|
||||
None => return &Obj::Null,
|
||||
},
|
||||
_ => return o,
|
||||
}
|
||||
}
|
||||
&Obj::Null
|
||||
}
|
||||
|
||||
pub fn get<'a>(&'a self, d: &'a Dict, key: &str) -> Option<&'a Obj> {
|
||||
d.get(key).map(|o| self.resolve(o)).filter(|o| !matches!(o, Obj::Null))
|
||||
}
|
||||
|
||||
pub fn get_dict<'a>(&'a self, d: &'a Dict, key: &str) -> Option<&'a Dict> {
|
||||
self.get(d, key)?.dict()
|
||||
}
|
||||
|
||||
pub fn get_num(&self, d: &Dict, key: &str) -> Option<f64> {
|
||||
self.get(d, key)?.num()
|
||||
}
|
||||
|
||||
/// Numbers of an array (references resolved).
|
||||
pub fn nums(&self, o: &Obj) -> Vec<f64> {
|
||||
self.resolve(o).array().map(|a| a.iter().filter_map(|x| self.resolve(x).num()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The document catalog.
|
||||
pub fn catalog(&self) -> Option<&Dict> {
|
||||
for t in self.trailers.iter().rev() {
|
||||
if let Some(Obj::Dict(d)) = t.get("Root").map(|r| self.resolve(r)) {
|
||||
return Some(d);
|
||||
}
|
||||
}
|
||||
let mut cats: Vec<(&u32, &Obj)> =
|
||||
self.objects.iter().filter(|(_, o)| matches!(o, Obj::Dict(d) if d.get("Type").and_then(Obj::name) == Some("Catalog"))).collect();
|
||||
cats.sort_by_key(|(n, _)| **n);
|
||||
cats.last().and_then(|(_, o)| o.dict())
|
||||
}
|
||||
|
||||
pub fn encrypted(&self) -> bool {
|
||||
self.trailers.iter().any(|t| t.contains_key("Encrypt"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,573 @@
|
|||
//! Pages and content streams (ISO 32000-1 §7.7.3, §8): the page tree, the graphics state,
|
||||
//! path construction and painting, clipping, colour, shading patterns and `sh`, form XObjects
|
||||
//! and optional-content marked sequences. Text, images and tiling patterns are skipped (listed
|
||||
//! in [`effectcraft_svg::Doc::skipped`]).
|
||||
|
||||
use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Gradient, GradientKind, Join, Paint, Spread, Stroke};
|
||||
use kurbo::Point;
|
||||
|
||||
use crate::build::Builder;
|
||||
use crate::color::{Cs, Func, color_space, text_string};
|
||||
use crate::object::{Dict, File, Lexer, Obj, decode_stream};
|
||||
|
||||
/// A page: its dictionary with inherited attributes resolved.
|
||||
pub struct Page {
|
||||
pub dict: Dict,
|
||||
pub resources: Dict,
|
||||
/// CropBox (else MediaBox) `[x0, y0, x1, y1]`.
|
||||
pub bbox: [f64; 4],
|
||||
pub rotate: i32,
|
||||
}
|
||||
|
||||
/// The pages in order.
|
||||
pub fn pages(file: &File) -> Vec<Page> {
|
||||
let mut out = vec![];
|
||||
let Some(cat) = file.catalog() else { return out };
|
||||
let Some(root) = file.get_dict(cat, "Pages") else { return out };
|
||||
fn walk(file: &File, node: &Dict, inherited: (Option<Dict>, Option<Vec<f64>>, Option<Vec<f64>>, i32), out: &mut Vec<Page>, depth: usize) {
|
||||
if depth > 32 || out.len() > 10_000 {
|
||||
return;
|
||||
}
|
||||
let res = file.get_dict(node, "Resources").cloned().or(inherited.0);
|
||||
let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.1);
|
||||
let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.2);
|
||||
let rot = file.get_num(node, "Rotate").map(|r| r as i32).unwrap_or(inherited.3);
|
||||
match file.get(node, "Kids").and_then(Obj::array) {
|
||||
Some(kids) if file.get(node, "Type").and_then(Obj::name) != Some("Page") => {
|
||||
for k in kids {
|
||||
if let Some(d) = file.resolve(k).dict() {
|
||||
walk(file, d, (res.clone(), media.clone(), crop.clone(), rot), out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let b = crop.or(media).unwrap_or_else(|| vec![0.0, 0.0, 612.0, 792.0]);
|
||||
let bbox = [b[0].min(b[2]), b[1].min(b[3]), b[0].max(b[2]), b[1].max(b[3])];
|
||||
out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, rotate: rot.rem_euclid(360) });
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(file, root, (None, None, None, 0), &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// The page's content streams, decoded and joined.
|
||||
pub fn page_content(file: &File, page: &Page) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
let parts: Vec<&Obj> = match page.dict.get("Contents").map(|c| file.resolve(c)) {
|
||||
Some(Obj::Array(a)) => a.iter().map(|x| file.resolve(x)).collect(),
|
||||
Some(o) => vec![o],
|
||||
None => vec![],
|
||||
};
|
||||
for p in parts {
|
||||
if let Obj::Stream(d, raw) = p
|
||||
&& let Some(data) = decode_stream(file, d, raw)
|
||||
{
|
||||
out.extend_from_slice(&data);
|
||||
out.push(b'\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Fill {
|
||||
Color([f64; 3]),
|
||||
Paint(Paint),
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct GState {
|
||||
ctm: Affine,
|
||||
fill_cs: Cs,
|
||||
stroke_cs: Cs,
|
||||
fill: Fill,
|
||||
stroke: Fill,
|
||||
lw: f64,
|
||||
cap: Cap,
|
||||
join: Join,
|
||||
miter: f64,
|
||||
dash: Option<(Vec<f64>, f64)>,
|
||||
fill_alpha: f64,
|
||||
stroke_alpha: f64,
|
||||
}
|
||||
|
||||
impl Default for GState {
|
||||
fn default() -> Self {
|
||||
GState {
|
||||
ctm: Affine::IDENTITY,
|
||||
fill_cs: Cs::Gray,
|
||||
stroke_cs: Cs::Gray,
|
||||
fill: Fill::Color([0.0; 3]),
|
||||
stroke: Fill::Color([0.0; 3]),
|
||||
lw: 1.0,
|
||||
cap: Cap::Butt,
|
||||
join: Join::Miter,
|
||||
miter: 10.0,
|
||||
dash: None,
|
||||
fill_alpha: 1.0,
|
||||
stroke_alpha: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Interp<'a> {
|
||||
file: &'a File,
|
||||
pub b: Builder,
|
||||
gs: GState,
|
||||
saved: Vec<(GState, usize)>,
|
||||
path: BezPath,
|
||||
cur: Option<Point>,
|
||||
start: Option<Point>,
|
||||
pending_clip: Option<FillRule>,
|
||||
marked: Vec<Option<usize>>,
|
||||
/// The page box in default user space (for `sh`).
|
||||
page_box: [f64; 4],
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
fn affine(v: &[f64]) -> Affine {
|
||||
if v.len() < 6 {
|
||||
return Affine::IDENTITY;
|
||||
}
|
||||
Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]])
|
||||
}
|
||||
|
||||
impl<'a> Interp<'a> {
|
||||
pub fn new(file: &'a File, page_box: [f64; 4]) -> Self {
|
||||
Interp {
|
||||
file,
|
||||
b: Builder::new(),
|
||||
gs: GState::default(),
|
||||
saved: vec![],
|
||||
path: BezPath::new(),
|
||||
cur: None,
|
||||
start: None,
|
||||
pending_clip: None,
|
||||
marked: vec![],
|
||||
page_box,
|
||||
budget: 20_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a content stream with resources `res`; `pattern_base` maps pattern space to the page
|
||||
/// (the CTM where the stream's default coordinate system starts).
|
||||
pub fn run(&mut self, content: &[u8], res: &Dict, pattern_base: Affine, depth: usize) {
|
||||
let mut lx = Lexer::new(content, 0);
|
||||
let mut args: Vec<Obj> = Vec::with_capacity(8);
|
||||
let mut in_text = false;
|
||||
while let Some(o) = lx.next() {
|
||||
let Obj::Op(op) = o else {
|
||||
if args.len() < 64 {
|
||||
args.push(o);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if self.budget == 0 {
|
||||
self.b.skip("content (operation limit)");
|
||||
return;
|
||||
}
|
||||
self.budget -= 1;
|
||||
if in_text {
|
||||
if op == "ET" {
|
||||
in_text = false;
|
||||
}
|
||||
args.clear();
|
||||
continue;
|
||||
}
|
||||
let n = |i: usize| args.get(i).and_then(Obj::num).unwrap_or(0.0);
|
||||
let nums = || args.iter().filter_map(Obj::num).collect::<Vec<f64>>();
|
||||
match op.as_str() {
|
||||
"q" => self.saved.push((self.gs.clone(), self.b.depth())),
|
||||
"Q" => {
|
||||
if let Some((g, d)) = self.saved.pop() {
|
||||
self.gs = g;
|
||||
self.b.close_to(d);
|
||||
}
|
||||
}
|
||||
"cm" => self.gs.ctm *= affine(&nums()),
|
||||
"w" => self.gs.lw = n(0),
|
||||
"J" => self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(n(0) as usize).min(2)],
|
||||
"j" => self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(n(0) as usize).min(2)],
|
||||
"M" => self.gs.miter = n(0),
|
||||
"d" => {
|
||||
let arr = args.first().map(|a| self.file.nums(a)).unwrap_or_default();
|
||||
self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then(|| (arr, n(1)));
|
||||
}
|
||||
"gs" => {
|
||||
if let Some(name) = args.first().and_then(Obj::name)
|
||||
&& let Some(g) = self.file.get_dict(res, "ExtGState").and_then(|e| self.file.get_dict(e, name))
|
||||
{
|
||||
self.ext_gstate(g);
|
||||
}
|
||||
}
|
||||
// Path construction.
|
||||
"m" => {
|
||||
let p = Point::new(n(0), n(1));
|
||||
self.path.move_to(p);
|
||||
self.cur = Some(p);
|
||||
self.start = Some(p);
|
||||
}
|
||||
"l" => {
|
||||
let p = Point::new(n(0), n(1));
|
||||
self.ensure_start();
|
||||
self.path.line_to(p);
|
||||
self.cur = Some(p);
|
||||
}
|
||||
"c" => {
|
||||
self.ensure_start();
|
||||
let p = Point::new(n(4), n(5));
|
||||
self.path.curve_to(Point::new(n(0), n(1)), Point::new(n(2), n(3)), p);
|
||||
self.cur = Some(p);
|
||||
}
|
||||
"v" => {
|
||||
self.ensure_start();
|
||||
let c0 = self.cur.unwrap_or_default();
|
||||
let p = Point::new(n(2), n(3));
|
||||
self.path.curve_to(c0, Point::new(n(0), n(1)), p);
|
||||
self.cur = Some(p);
|
||||
}
|
||||
"y" => {
|
||||
self.ensure_start();
|
||||
let p = Point::new(n(2), n(3));
|
||||
self.path.curve_to(Point::new(n(0), n(1)), p, p);
|
||||
self.cur = Some(p);
|
||||
}
|
||||
"h" => {
|
||||
if self.cur.is_some() {
|
||||
self.path.close_path();
|
||||
self.cur = self.start;
|
||||
}
|
||||
}
|
||||
"re" => {
|
||||
let (x, y, w, h) = (n(0), n(1), n(2), n(3));
|
||||
self.path.move_to((x, y));
|
||||
self.path.line_to((x + w, y));
|
||||
self.path.line_to((x + w, y + h));
|
||||
self.path.line_to((x, y + h));
|
||||
self.path.close_path();
|
||||
self.cur = Some(Point::new(x, y));
|
||||
self.start = self.cur;
|
||||
}
|
||||
// Painting.
|
||||
"S" => self.paint(false, None, true),
|
||||
"s" => {
|
||||
self.path.close_path();
|
||||
self.paint(false, None, true)
|
||||
}
|
||||
"f" | "F" => self.paint(true, Some(FillRule::NonZero), false),
|
||||
"f*" => self.paint(true, Some(FillRule::EvenOdd), false),
|
||||
"B" => self.paint(true, Some(FillRule::NonZero), true),
|
||||
"B*" => self.paint(true, Some(FillRule::EvenOdd), true),
|
||||
"b" => {
|
||||
self.path.close_path();
|
||||
self.paint(true, Some(FillRule::NonZero), true)
|
||||
}
|
||||
"b*" => {
|
||||
self.path.close_path();
|
||||
self.paint(true, Some(FillRule::EvenOdd), true)
|
||||
}
|
||||
"n" => self.paint(false, None, false),
|
||||
"W" => self.pending_clip = Some(FillRule::NonZero),
|
||||
"W*" => self.pending_clip = Some(FillRule::EvenOdd),
|
||||
// Colour.
|
||||
"g" => self.gs.fill_cs = Cs::Gray,
|
||||
"G" => self.gs.stroke_cs = Cs::Gray,
|
||||
"rg" => self.gs.fill_cs = Cs::Rgb,
|
||||
"RG" => self.gs.stroke_cs = Cs::Rgb,
|
||||
"k" => self.gs.fill_cs = Cs::Cmyk,
|
||||
"K" => self.gs.stroke_cs = Cs::Cmyk,
|
||||
"cs" | "CS" => {
|
||||
let cs = args.first().map(|a| color_space(self.file, a, Some(res))).unwrap_or(Cs::Gray);
|
||||
let init = Fill::Color(cs.to_rgb(&cs.initial()));
|
||||
if op == "cs" {
|
||||
self.gs.fill_cs = cs;
|
||||
self.gs.fill = init;
|
||||
} else {
|
||||
self.gs.stroke_cs = cs;
|
||||
self.gs.stroke = init;
|
||||
}
|
||||
}
|
||||
"sc" | "scn" | "SC" | "SCN" => {
|
||||
let fill = op.starts_with('s');
|
||||
let cs = if fill { self.gs.fill_cs.clone() } else { self.gs.stroke_cs.clone() };
|
||||
let v = if let (Cs::Pattern, Some(name)) = (&cs, args.last().and_then(Obj::name)) {
|
||||
self.pattern(res, name, pattern_base)
|
||||
} else {
|
||||
Fill::Color(cs.to_rgb(&nums()))
|
||||
};
|
||||
if fill {
|
||||
self.gs.fill = v;
|
||||
} else {
|
||||
self.gs.stroke = v;
|
||||
}
|
||||
}
|
||||
"sh" => {
|
||||
if let Some(name) = args.first().and_then(Obj::name) {
|
||||
self.shade(res, name);
|
||||
}
|
||||
}
|
||||
"Do" => {
|
||||
if let Some(name) = args.first().and_then(Obj::name) {
|
||||
self.xobject(res, name, depth);
|
||||
}
|
||||
}
|
||||
"BT" => {
|
||||
in_text = true;
|
||||
self.b.skip("text");
|
||||
}
|
||||
"BI" => {
|
||||
// Inline image: skip to EI.
|
||||
let d = lx.data;
|
||||
let mut p = lx.pos;
|
||||
while p + 2 < d.len() && !(d[p] == b'I' && d[p - 1] == b'D' && crate::object::is_white(d[p + 1])) {
|
||||
p += 1;
|
||||
}
|
||||
while p + 2 < d.len()
|
||||
&& !(crate::object::is_white(d[p - 1]) && d[p] == b'E' && d[p + 1] == b'I' && (p + 2 == d.len() || crate::object::is_white(d[p + 2])))
|
||||
{
|
||||
p += 1;
|
||||
}
|
||||
lx.pos = (p + 2).min(d.len());
|
||||
self.b.skip("image");
|
||||
}
|
||||
"BMC" => self.marked.push(None),
|
||||
"BDC" => {
|
||||
let layer = match (args.first().and_then(Obj::name), args.get(1)) {
|
||||
(Some("OC"), Some(Obj::Name(prop))) => self
|
||||
.file
|
||||
.get_dict(res, "Properties")
|
||||
.and_then(|p| self.file.get_dict(p, prop))
|
||||
.map(|ocg| self.ocg_name(ocg).unwrap_or_else(|| prop.clone())),
|
||||
(Some("OC"), Some(Obj::Dict(ocg))) => self.ocg_name(ocg),
|
||||
_ => None,
|
||||
};
|
||||
match layer {
|
||||
Some(name) => {
|
||||
self.marked.push(Some(self.b.depth()));
|
||||
self.b.open(&name, true);
|
||||
}
|
||||
None => self.marked.push(None),
|
||||
}
|
||||
}
|
||||
"EMC" => {
|
||||
if let Some(Some(d)) = self.marked.pop() {
|
||||
self.b.close_to(d);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Colour operands set by the device-space shorthands.
|
||||
match op.as_str() {
|
||||
"g" | "rg" | "k" => self.gs.fill = Fill::Color(self.gs.fill_cs.to_rgb(&nums())),
|
||||
"G" | "RG" | "K" => self.gs.stroke = Fill::Color(self.gs.stroke_cs.to_rgb(&nums())),
|
||||
_ => {}
|
||||
}
|
||||
args.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn ocg_name(&self, ocg: &Dict) -> Option<String> {
|
||||
// An optional-content membership dictionary names its groups in /OCGs.
|
||||
let d = match self.file.get(ocg, "OCGs") {
|
||||
Some(Obj::Dict(g)) => g,
|
||||
Some(Obj::Array(a)) => a.first().map(|x| self.file.resolve(x)).and_then(Obj::dict).unwrap_or(ocg),
|
||||
_ => ocg,
|
||||
};
|
||||
match self.file.get(d, "Name") {
|
||||
Some(Obj::Str(s)) => Some(text_string(s)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_start(&mut self) {
|
||||
if self.cur.is_none() {
|
||||
self.path.move_to((0.0, 0.0));
|
||||
self.cur = Some(Point::ZERO);
|
||||
self.start = self.cur;
|
||||
}
|
||||
}
|
||||
|
||||
fn ext_gstate(&mut self, g: &Dict) {
|
||||
let f = self.file;
|
||||
if let Some(v) = f.get_num(g, "LW") {
|
||||
self.gs.lw = v;
|
||||
}
|
||||
if let Some(v) = f.get_num(g, "LC") {
|
||||
self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(v as usize).min(2)];
|
||||
}
|
||||
if let Some(v) = f.get_num(g, "LJ") {
|
||||
self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(v as usize).min(2)];
|
||||
}
|
||||
if let Some(v) = f.get_num(g, "ML") {
|
||||
self.gs.miter = v;
|
||||
}
|
||||
if let Some(v) = f.get_num(g, "CA") {
|
||||
self.gs.stroke_alpha = v.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(v) = f.get_num(g, "ca") {
|
||||
self.gs.fill_alpha = v.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(Obj::Array(d)) = f.get(g, "D") {
|
||||
let arr = d.first().map(|a| f.nums(a)).unwrap_or_default();
|
||||
let ph = d.get(1).and_then(|p| f.resolve(p).num()).unwrap_or(0.0);
|
||||
self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then_some((arr, ph));
|
||||
}
|
||||
if f.get(g, "SMask").is_some_and(|s| !matches!(s, Obj::Name(n) if n == "None")) {
|
||||
self.b.skip("soft mask");
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_of(f: &Fill) -> Option<Paint> {
|
||||
match f {
|
||||
Fill::Color(c) => Some(Paint::Color(*c)),
|
||||
Fill::Paint(p) => Some(p.clone()),
|
||||
Fill::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&mut self, fill: bool, rule: Option<FillRule>, stroke: bool) {
|
||||
let path = std::mem::take(&mut self.path);
|
||||
self.cur = None;
|
||||
self.start = None;
|
||||
let ctm = self.gs.ctm;
|
||||
let fill = if fill { Self::paint_of(&self.gs.fill).map(|p| (p, self.gs.fill_alpha, rule.unwrap_or(FillRule::NonZero))) } else { None };
|
||||
let stroke = if stroke {
|
||||
Self::paint_of(&self.gs.stroke).map(|p| {
|
||||
// Width 0: the thinnest line the device can draw (one pixel).
|
||||
let s = { (ctm.determinant().abs()).sqrt() };
|
||||
let width = if self.gs.lw <= 0.0 { if s > 0.0 { 1.0 / s } else { 1.0 } } else { self.gs.lw };
|
||||
Stroke {
|
||||
paint: p,
|
||||
opacity: self.gs.stroke_alpha,
|
||||
width,
|
||||
cap: self.gs.cap,
|
||||
join: self.gs.join,
|
||||
miter: self.gs.miter.max(1.0),
|
||||
dash: self.gs.dash.clone(),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if fill.is_some() || stroke.is_some() {
|
||||
self.b.fill_stroke(path.clone(), ctm, fill, stroke);
|
||||
}
|
||||
if let Some(r) = self.pending_clip.take() {
|
||||
self.b.clip(ctm * path, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// A shading dictionary → gradient paint in shading space.
|
||||
fn shading_gradient(&mut self, sh: &Dict) -> Option<Gradient> {
|
||||
let f = self.file;
|
||||
let ty = f.get_num(sh, "ShadingType").unwrap_or(0.0) as i32;
|
||||
let cs = f.get(sh, "ColorSpace").map(|c| color_space(f, c, None)).unwrap_or(Cs::Rgb);
|
||||
let coords = f.get(sh, "Coords").map(|c| f.nums(c)).unwrap_or_default();
|
||||
let func = f.get(sh, "Function").map(|x| Func::read(f, x)).unwrap_or(Func::Unsupported);
|
||||
let dom = f.get(sh, "Domain").map(|d| f.nums(d)).filter(|d| d.len() == 2).unwrap_or_else(|| vec![0.0, 1.0]);
|
||||
let kind = match (ty, coords.len()) {
|
||||
(2, 4) => GradientKind::Linear { x1: coords[0], y1: coords[1], x2: coords[2], y2: coords[3] },
|
||||
(3, 6) => GradientKind::Radial { cx: coords[3], cy: coords[4], r: coords[5], fx: coords[0], fy: coords[1] },
|
||||
_ => {
|
||||
self.b.skip(&format!("shading type {ty}"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if func == Func::Unsupported {
|
||||
self.b.skip("shading function");
|
||||
}
|
||||
// Sample the function: evenly, plus exactly at stitching bounds.
|
||||
let mut ts: Vec<f64> = (0..=24).map(|i| i as f64 / 24.0).collect();
|
||||
for b in func.breakpoints() {
|
||||
if dom[1] > dom[0] {
|
||||
ts.push(((b - dom[0]) / (dom[1] - dom[0])).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
let stops = ts
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let c = cs.to_rgb(&func.eval1(dom[0] + t * (dom[1] - dom[0])));
|
||||
(t, [c[0], c[1], c[2], 1.0])
|
||||
})
|
||||
.collect();
|
||||
Some(Gradient { kind, stops, bbox_units: false, transform: Affine::IDENTITY, spread: Spread::Pad })
|
||||
}
|
||||
|
||||
fn pattern(&mut self, res: &Dict, name: &str, base: Affine) -> Fill {
|
||||
let f = self.file;
|
||||
let Some(p) = f.get_dict(res, "Pattern").and_then(|pp| f.get_dict(pp, name)) else { return Fill::None };
|
||||
match f.get_num(p, "PatternType").unwrap_or(0.0) as i32 {
|
||||
2 => {
|
||||
let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY);
|
||||
let Some(sh) = f.get(p, "Shading").and_then(Obj::dict) else { return Fill::None };
|
||||
let sh = sh.clone();
|
||||
match self.shading_gradient(&sh) {
|
||||
// Gradient space → shape user space (shapes carry the CTM).
|
||||
Some(mut g) => {
|
||||
g.transform = self.gs.ctm.inverse() * base * m;
|
||||
Fill::Paint(Paint::Gradient(g))
|
||||
}
|
||||
None => Fill::None,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.b.skip("tiling pattern");
|
||||
Fill::Color([0.5; 3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shade(&mut self, res: &Dict, name: &str) {
|
||||
let f = self.file;
|
||||
let Some(sh) = f.get_dict(res, "Shading").and_then(|s| f.get_dict(s, name)).cloned() else { return };
|
||||
let Some(g) = self.shading_gradient(&sh) else { return };
|
||||
// Fill everything (the current clip limits it): the page box in user space.
|
||||
let inv = self.gs.ctm.inverse();
|
||||
let b = self.page_box;
|
||||
let mut path = BezPath::new();
|
||||
for (i, (x, y)) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].into_iter().enumerate() {
|
||||
let p = inv * Point::new(x, y);
|
||||
if i == 0 {
|
||||
path.move_to(p);
|
||||
} else {
|
||||
path.line_to(p);
|
||||
}
|
||||
}
|
||||
path.close_path();
|
||||
self.b.fill_stroke(path, self.gs.ctm, Some((Paint::Gradient(g), self.gs.fill_alpha, FillRule::NonZero)), None);
|
||||
}
|
||||
|
||||
fn xobject(&mut self, res: &Dict, name: &str, depth: usize) {
|
||||
let f = self.file;
|
||||
let Some(x) = f.get_dict(res, "XObject").and_then(|xs| xs.get(name)).map(|x| f.resolve(x)) else { return };
|
||||
let Obj::Stream(d, raw) = x else { return };
|
||||
match f.get(d, "Subtype").and_then(Obj::name) {
|
||||
Some("Form") if depth < 12 => {
|
||||
let Some(content) = decode_stream(f, d, raw) else { return };
|
||||
let m = f.get(d, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY);
|
||||
let form_res = f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone());
|
||||
self.saved.push((self.gs.clone(), self.b.depth()));
|
||||
self.gs.ctm *= m;
|
||||
let bb = f.get(d, "BBox").map(|b| f.nums(b)).unwrap_or_default();
|
||||
if bb.len() == 4 {
|
||||
let r = kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]);
|
||||
self.b.clip(self.gs.ctm * kurbo::Shape::to_path(&r, 0.1), FillRule::NonZero);
|
||||
}
|
||||
let base = self.gs.ctm;
|
||||
let path = std::mem::take(&mut self.path);
|
||||
self.run(&content, &form_res, base, depth + 1);
|
||||
self.path = path;
|
||||
if let Some((g, dd)) = self.saved.pop() {
|
||||
self.gs = g;
|
||||
self.b.close_to(dd);
|
||||
}
|
||||
}
|
||||
Some("Image") => self.b.skip("image"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,150 @@
|
|||
//! Test files are generated here (no third-party samples).
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::write::pdf;
|
||||
|
||||
const CONTENT: &str = "/OC /MC0 BDC\n0 1 0 rg 0 0 200 100 re f\nEMC\n\
|
||||
/OC /MC1 BDC\nq 10 10 80 80 re W n\n1 0 0 rg 0 0 50 100 re f\nQ\n\
|
||||
/Pattern cs /P0 scn 100 0 100 50 re f\n\
|
||||
0 0 1 RG 4 w 120 80 m 180 80 l S\n\
|
||||
BT /F1 12 Tf (ignored) Tj ET\nEMC\n";
|
||||
|
||||
pub(crate) fn sample_pdf(object_stream: bool) -> Vec<u8> {
|
||||
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Properties << /MC0 5 0 R /MC1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>";
|
||||
let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [100 0 200 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> >>";
|
||||
let mut objs = vec![
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), None),
|
||||
(3, page.to_string(), None),
|
||||
(4, "<< >>".to_string(), Some(CONTENT.as_bytes().to_vec())),
|
||||
(7, pattern.to_string(), None),
|
||||
];
|
||||
let ocg5 = "<< /Type /OCG /Name (Background) >>";
|
||||
let ocg6 = "<< /Type /OCG /Name <FEFF00410072007400> >>"; // "Art" in UTF-16BE
|
||||
if object_stream {
|
||||
let body = format!("{ocg5} {ocg6}");
|
||||
let head = format!("5 0 6 {} ", ocg5.len() + 1);
|
||||
let data = format!("{head}{body}");
|
||||
objs.push((8, format!("<< /Type /ObjStm /N 2 /First {} >>", head.len()), Some(data.into_bytes())));
|
||||
} else {
|
||||
objs.push((5, ocg5.to_string(), None));
|
||||
objs.push((6, ocg6.to_string(), None));
|
||||
}
|
||||
pdf(&objs, 1)
|
||||
}
|
||||
|
||||
fn px(doc: &Doc, x: i64, y: i64) -> [f32; 4] {
|
||||
let (w, h) = doc.pixel_size();
|
||||
let img = effectcraft_svg::rasterize(doc, w, h, 1.0);
|
||||
img.get(x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_paths_clip_gradient_and_layers() {
|
||||
for objstm in [false, true] {
|
||||
let bytes = sample_pdf(objstm);
|
||||
assert_eq!(sniff(&bytes), Some(Format::Pdf));
|
||||
assert_eq!(page_count(&bytes), 1);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
assert_eq!((doc.width, doc.height), (200.0, 100.0));
|
||||
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}");
|
||||
assert!(doc.skipped.contains(&"text".to_string()));
|
||||
let (w, h) = doc.pixel_size();
|
||||
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
|
||||
let at = |x: i64, y: i64| img.get(x, y);
|
||||
// Red rectangle clipped to x ≥ 10, over the green background.
|
||||
assert!(at(30, 50)[0] > 0.99 && at(30, 50)[1] < 0.01, "{:?}", at(30, 50));
|
||||
assert!(at(5, 50)[1] > 0.99 && at(5, 50)[0] < 0.01, "clipped out: {:?}", at(5, 50));
|
||||
assert!(at(70, 50)[1] > 0.99, "{:?}", at(70, 50));
|
||||
// Axial shading, red → blue left to right (in the lower half: PDF y is up).
|
||||
let (l, r) = (at(110, 75), at(190, 75));
|
||||
assert!(l[0] > 0.8 && l[2] < 0.2 && r[2] > 0.8 && r[0] < 0.2, "{l:?} {r:?}");
|
||||
assert!(at(150, 25)[1] > 0.99, "the gradient stays in its rectangle: {:?}", at(150, 25));
|
||||
// Stroked line (PDF y = 80 → 20 px from the top).
|
||||
assert!(at(150, 20)[2] > 0.99 && at(150, 20)[1] < 0.01, "{:?}", at(150, 20));
|
||||
// One layer at a time.
|
||||
let bg = layer_doc(&doc, 0);
|
||||
assert!(px(&bg, 30, 50)[1] > 0.99);
|
||||
let art = layer_doc(&doc, 1);
|
||||
assert_eq!(px(&art, 5, 50)[3], 0.0);
|
||||
// Continuous rasterisation: twice the size stays sharp at the clip edge.
|
||||
let big = effectcraft_svg::rasterize(&doc, 400, 200, 2.0);
|
||||
assert!(big.get(21, 100)[0] > 0.99 && big.get(18, 100)[1] > 0.99);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_forms_rotation_cmyk_and_alpha() {
|
||||
let content = "q 2 0 0 2 0 0 cm /Fm0 Do Q\n/GS0 gs 0 0 0 1 k 0 0 10 10 re f\n";
|
||||
let form = "0 1 1 0 k 0 0 20 20 re f";
|
||||
let objs = vec![
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 /Rotate 90 >>".to_string(), None),
|
||||
(
|
||||
3,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /XObject << /Fm0 5 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >> /Contents 4 0 R >>"
|
||||
.to_string(),
|
||||
None,
|
||||
),
|
||||
(4, "<< >>".to_string(), Some(content.as_bytes().to_vec())),
|
||||
(5, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>".to_string(), Some(form.as_bytes().to_vec())),
|
||||
];
|
||||
let doc = parse(&pdf(&objs, 1)).unwrap();
|
||||
// Rotated a quarter turn: 50 × 100.
|
||||
assert_eq!((doc.width, doc.height), (50.0, 100.0));
|
||||
let (w, h) = doc.pixel_size();
|
||||
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
|
||||
// The form (CMYK red, clipped to its 10×10 box, scaled ×2) covers PDF (0..20, 0..20); the
|
||||
// half-transparent black square covers PDF (0..10, 0..10). /Rotate 90: PDF (x, y) → (y, x).
|
||||
let red = img.get(15, 15);
|
||||
assert!(red[0] > 0.99 && red[1] < 0.01 && red[3] > 0.99, "{red:?}");
|
||||
let dark = img.get(5, 5);
|
||||
assert!((dark[0] - 0.5).abs() < 0.02 && dark[3] > 0.99, "{dark:?}");
|
||||
assert_eq!(img.get(30, 30)[3], 0.0, "form clipped to its bounding box");
|
||||
}
|
||||
|
||||
const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
|
||||
/m {moveto} bind def /l {lineto} bind def\n\
|
||||
/cm { 6 array astore concat } bind def\n\
|
||||
/box { 4 dict begin /h exch def /w exch def /y exch def /x exch def x y m w 0 rlineto 0 h rlineto w neg 0 rlineto closepath end } bind def\n\
|
||||
1 0 0 setrgbcolor\n10 10 30 30 box fill\n\
|
||||
gsave 0 0 1 setrgbcolor 70 50 20 0 360 arc fill grestore\n\
|
||||
gsave 1 0 0 1 50 0 cm 0 1 0 setrgbcolor 0 0 10 10 box fill grestore\n\
|
||||
0 setgray 2 setlinewidth 0 90 m 100 90 l stroke\n\
|
||||
/Helvetica findfont 12 scalefont setfont 5 5 moveto (text) show\n\
|
||||
showpage\n%%EOF\n";
|
||||
|
||||
#[test]
|
||||
fn eps_postscript_subset() {
|
||||
let doc = parse(EPS.as_bytes()).unwrap();
|
||||
assert_eq!((doc.width, doc.height), (100.0, 100.0));
|
||||
assert!(doc.skipped.contains(&"text".to_string()));
|
||||
let at = |x, y| px(&doc, x, y);
|
||||
assert!(at(25, 75)[0] > 0.99 && at(25, 75)[3] > 0.99, "red box {:?}", at(25, 75));
|
||||
assert!(at(70, 50)[2] > 0.99, "blue disc {:?}", at(70, 50));
|
||||
assert!(at(55, 95)[1] > 0.99, "translated green box {:?}", at(55, 95));
|
||||
let line = at(50, 10);
|
||||
assert!(line[3] > 0.99 && line[0] < 0.01, "black line {line:?}");
|
||||
assert_eq!(at(95, 70)[3], 0.0);
|
||||
// DOS EPS binary header (with a fake TIFF preview after the PostScript).
|
||||
let ps = EPS.as_bytes();
|
||||
let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6];
|
||||
dos.extend_from_slice(&30u32.to_le_bytes());
|
||||
dos.extend_from_slice(&(ps.len() as u32).to_le_bytes());
|
||||
dos.extend_from_slice(&[0; 18]);
|
||||
dos.extend_from_slice(ps);
|
||||
dos.extend_from_slice(b"II*\0 not a real preview");
|
||||
let d2 = parse(&dos).unwrap();
|
||||
assert_eq!(d2.root.children.len(), doc.root.children.len());
|
||||
assert_eq!(codec("art.eps", &dos), Some("EPS"));
|
||||
assert_eq!(codec("art.ai", &sample_pdf(false)), Some("AI"));
|
||||
assert_eq!(parse(b"hello"), Err(Error::NotVector));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_decode() {
|
||||
assert_eq!(object::ascii85(b"<~87cURD]i,\"Ebo80~>"), b"Hello World!".to_vec());
|
||||
let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6);
|
||||
assert_eq!(object::inflate(&z).unwrap(), b"abc");
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
//! A minimal PDF writer for generating test files (EffectCraft does not export PDF).
|
||||
|
||||
/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed), a
|
||||
/// cross-reference table and a trailer naming object `root` as the catalog.
|
||||
pub fn pdf(objects: &[(u32, String, Option<Vec<u8>>)], root: u32) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec();
|
||||
let mut offsets = vec![];
|
||||
for (n, dict, stream) in objects {
|
||||
offsets.push((*n, out.len()));
|
||||
match stream {
|
||||
Some(data) => {
|
||||
let z = miniz_oxide::deflate::compress_to_vec_zlib(data, 6);
|
||||
let d = format!("{} /Filter /FlateDecode /Length {} >>", dict.trim_end().trim_end_matches(">>"), z.len());
|
||||
out.extend_from_slice(format!("{n} 0 obj\n{d}\nstream\n").as_bytes());
|
||||
out.extend_from_slice(&z);
|
||||
out.extend_from_slice(b"\nendstream\nendobj\n");
|
||||
}
|
||||
None => out.extend_from_slice(format!("{n} 0 obj\n{dict}\nendobj\n").as_bytes()),
|
||||
}
|
||||
}
|
||||
let xref = out.len();
|
||||
let max = objects.iter().map(|o| o.0).max().unwrap_or(0);
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", max + 1).as_bytes());
|
||||
for i in 1..=max {
|
||||
match offsets.iter().find(|o| o.0 == i) {
|
||||
Some((_, off)) => out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()),
|
||||
None => out.extend_from_slice(b"0000000000 65535 f \n"),
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root {root} 0 R >>\nstartxref\n{xref}\n%%EOF\n", max + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
|
@ -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>),
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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![];
|
||||
|
|
|
|||
|
|
@ -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`]).
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -170,6 +170,9 @@ pub struct Group {
|
|||
pub transform: Affine,
|
||||
pub opacity: f64,
|
||||
pub children: Vec<Node>,
|
||||
/// Clipping paths in the group's space (after `transform`); the children show only where
|
||||
/// every one of them covers (PDF / EPS clipping; SVG `clipPath` is not read yet).
|
||||
pub clip: Vec<(BezPath, FillRule)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -666,7 +669,7 @@ impl<'a, 'i> Parser<'a, 'i> {
|
|||
for c in n.children() {
|
||||
self.node(c, &st, &mut children, depth + 1);
|
||||
}
|
||||
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children }));
|
||||
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] }));
|
||||
}
|
||||
"svg" => {
|
||||
// Nested viewport.
|
||||
|
|
@ -685,7 +688,7 @@ impl<'a, 'i> Parser<'a, 'i> {
|
|||
self.node(c, &st, &mut children, depth + 1);
|
||||
}
|
||||
(self.vw, self.vh) = saved;
|
||||
out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children }));
|
||||
out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children, clip: vec![] }));
|
||||
}
|
||||
"use" => {
|
||||
let Some(target) = self.href(n) else { return };
|
||||
|
|
@ -706,11 +709,11 @@ impl<'a, 'i> Parser<'a, 'i> {
|
|||
for c in target.children() {
|
||||
self.node(c, &sst, &mut inner, depth + 1);
|
||||
}
|
||||
children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner }));
|
||||
children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner, clip: vec![] }));
|
||||
} else {
|
||||
self.node(target, &st, &mut children, depth + 1);
|
||||
}
|
||||
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children }));
|
||||
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] }));
|
||||
}
|
||||
"path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => {
|
||||
if !st.visible {
|
||||
|
|
@ -750,7 +753,7 @@ impl<'a, 'i> Parser<'a, 'i> {
|
|||
self.node(c, &st, &mut children, depth + 1);
|
||||
}
|
||||
if !children.is_empty() {
|
||||
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children }));
|
||||
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -942,7 +945,7 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
|
|||
p.node(c, &rst, &mut children, 1);
|
||||
}
|
||||
let transform = viewbox_transform(root, dw, dh);
|
||||
Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children }, skipped: std::mem::take(&mut p.skipped) })
|
||||
Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children, clip: vec![] }, skipped: std::mem::take(&mut p.skipped) })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -31,6 +31,27 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
|
|||
if o <= 0.0 {
|
||||
return;
|
||||
}
|
||||
if !g.clip.is_empty() {
|
||||
// Draw the children on their own, keep what every clip path covers, composite.
|
||||
let mut tmp = Image::new(dst.width, dst.height);
|
||||
for c in &g.children {
|
||||
draw_node(c, m, 1.0, &mut tmp);
|
||||
}
|
||||
for (path, rule) in &g.clip {
|
||||
let rule = match rule {
|
||||
FillRule::NonZero => effectcraft_path::FillRule::NonZero,
|
||||
FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd,
|
||||
};
|
||||
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), dst.width, dst.height, rule);
|
||||
tmp.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(p, c)| {
|
||||
for v in p.iter_mut() {
|
||||
*v *= c;
|
||||
}
|
||||
});
|
||||
}
|
||||
over(dst, &tmp, o as f32);
|
||||
return;
|
||||
}
|
||||
if g.opacity < 1.0 && g.children.len() > 1 {
|
||||
let mut tmp = Image::new(dst.width, dst.height);
|
||||
for c in &g.children {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const LAYERS: &[(&str, u8)] = &[
|
|||
("effects", 2),
|
||||
("track", 2),
|
||||
("svg", 2),
|
||||
("pdf", 2),
|
||||
("model", 2),
|
||||
("render", 3),
|
||||
("media", 3),
|
||||
|
|
@ -58,6 +59,7 @@ const SAME_LAYER: &[(&str, &str)] = &[
|
|||
("path", "keyframe"),
|
||||
("path", "raster"),
|
||||
("text", "path"),
|
||||
("pdf", "svg"),
|
||||
("effects", "project"),
|
||||
("effects", "text"),
|
||||
("effects", "path"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue