diff --git a/Cargo.lock b/Cargo.lock index c3dcb41c6..073487278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1223,6 +1223,7 @@ dependencies = [ "effectcraft-text", "effectcraft-time", "effectcraft-track", + "exr", "image", "kurbo", "log", @@ -1409,9 +1410,14 @@ dependencies = [ name = "effectcraft-pdf" version = "0.1.0" dependencies = [ + "effectcraft-raster", "effectcraft-svg", + "effectcraft-text", + "image", "kurbo", "miniz_oxide 0.8.9", + "skrifa", + "zune-jpeg", ] [[package]] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7a7adb30b..6381de0bb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,3 +34,4 @@ log = { workspace = true } rayon = { workspace = true } image = { workspace = true } web-time = { workspace = true } +exr = { workspace = true } diff --git a/crates/engine/src/commands/align.rs b/crates/engine/src/commands/align.rs new file mode 100644 index 000000000..1c8abaef2 --- /dev/null +++ b/crates/engine/src/commands/align.rs @@ -0,0 +1,285 @@ +//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`. +//! +//! Layers are aligned by their content bounds in comp space (source size, text glyphs, shape +//! contents through the layer's transform and its parents'). Each layer moves by a comp-space +//! offset that is converted into its parent's space before it is added to Position (Separate +//! Dimensions writes X/Y Position), at the current time (a key when Position is animated). All +//! moves are one undo step. + +use effectcraft_geom::{Mat4, Vec3}; +use effectcraft_keyframe::Value as KValue; +use effectcraft_project::{ItemId, LayerId}; +use effectcraft_render::EvalCtx; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// A layer's comp-space bounds and how a comp-space offset maps into its Position. +struct Placed { + id: LayerId, + /// `[x0, y0, x1, y1]` in comp space. + b: [f64; 4], + /// Comp space → the parent's space (the space Position lives in). + to_parent: Mat4, +} + +fn placed(s: &Session, cid: ItemId, ids: &[LayerId]) -> Result> { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, s.time()) }; + let mut out = vec![]; + for &id in ids { + let Some(l) = comp.layer(id) else { continue }; + if l.transform().is_none() { + continue; + } + let Some(b) = effectcraft_render::content_bounds(&ctx, l) else { continue }; + let w = ctx.world_matrix(l); + let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| w.apply(Vec3::from([p[0], p[1], 0.0]))); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in pts { + x0 = x0.min(p.x); + y0 = y0.min(p.y); + x1 = x1.max(p.x); + y1 = y1.max(p.y); + } + // Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer). + let mut par = Mat4::IDENTITY; + let mut cur = l.parent; + let mut guard = 0; + while let Some(pid) = cur { + guard += 1; + let Some(p) = comp.layer(pid).filter(|_| guard < 64) else { break }; + par = ctx.local_matrix(p) * par; + cur = p.parent; + } + let to_parent = par.inverse().unwrap_or(Mat4::IDENTITY); + out.push(Placed { id, b: [x0, y0, x1, y1], to_parent }); + } + Ok(out) +} + +/// Move layers by comp-space offsets, as one undo step. +fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>) -> Result { + let t = s.time(); + let moved: Vec = moves.iter().filter(|m| m.2[0].abs() > 1e-9 || m.2[1].abs() > 1e-9).map(|m| m.0.0).collect(); + s.edit(label, None, |proj, _| { + for (lid, to_parent, d) in &moves { + if d[0].abs() <= 1e-9 && d[1].abs() <= 1e-9 { + continue; + } + let o = to_parent.apply(Vec3::from([0.0, 0.0, 0.0])); + let q = to_parent.apply(Vec3::from([d[0], d[1], 0.0])); + let dp = [q.x - o.x, q.y - o.y, q.z - o.z]; + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + let three = l.is_3d(); + let tr = l.transform_mut().ok_or_else(|| bad("layer.align", "layer has no transform"))?; + if tr.get("positionX").is_some() { + let dims = if three { 3 } else { 2 }; + for (k, m) in ["positionX", "positionY", "positionZ"].iter().enumerate().take(dims) { + if let Some(pr) = tr.get_mut(m) { + let v = pr.value_at(lt).as_f64() + dp[k]; + pr.set_value_at(lt, KValue::Scalar(v)); + } + } + } else if let Some(pr) = tr.get_mut("position") { + let cur = pr.value_at(lt); + let a = cur.to_json(); + let get = |k: usize| a.get(k).and_then(Value::as_f64).unwrap_or(0.0); + let nv = json!([get(0) + dp[0], get(1) + dp[1], get(2) + if three { dp[2] } else { 0.0 }]); + if let Some(v) = cur.coerce_json(&nv) { + pr.set_value_at(lt, v); + } + } + } + Ok(()) + })?; + Ok(json!({"moved": moved})) +} + +const EDGES: &str = "left|hcenter|right|top|vcenter|bottom"; + +/// The coordinate of an edge (or centre) of bounds; `true` for horizontal edges. +fn edge_of(b: &[f64; 4], edge: &str) -> Option<(f64, bool)> { + Some(match edge { + "left" => (b[0], true), + "hcenter" | "horizontalCenter" => ((b[0] + b[2]) * 0.5, true), + "right" => (b[2], true), + "top" => (b[1], false), + "vcenter" | "verticalCenter" => ((b[1] + b[3]) * 0.5, false), + "bottom" => (b[3], false), + _ => return None, + }) +} + +fn align(s: &mut Session, p: &Value) -> Result { + let c = "layer.align"; + let edge = str_p(p, "edge").ok_or_else(|| bad(c, format!("missing `edge` ({EDGES})")))?; + let to = str_p(p, "to").unwrap_or("composition"); + let (cid, ids) = layers_p(s, p)?; + let items = placed(s, cid, &ids)?; + if items.is_empty() { + return Err(bad(c, "no layers with bounds to align")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let target: [f64; 4] = match to { + "composition" | "comp" => [0.0, 0.0, comp.width as f64, comp.height as f64], + "selection" => { + if items.len() < 2 { + return Err(bad(c, "aligning to the selection needs two or more layers")); + } + items.iter().fold([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY], |a, it| { + [a[0].min(it.b[0]), a[1].min(it.b[1]), a[2].max(it.b[2]), a[3].max(it.b[3])] + }) + } + x => return Err(bad(c, format!("unknown `to` `{x}` (selection|composition)"))), + }; + let (goal, horiz) = edge_of(&target, edge).ok_or_else(|| bad(c, format!("unknown edge `{edge}` ({EDGES})")))?; + let moves = items + .into_iter() + .map(|it| { + let (v, _) = edge_of(&it.b, edge).unwrap_or((goal, horiz)); + let d = if horiz { [goal - v, 0.0] } else { [0.0, goal - v] }; + (it.id, it.to_parent, d) + }) + .collect(); + apply_moves(s, cid, "Align Layers", moves) +} + +fn distribute(s: &mut Session, p: &Value) -> Result { + let c = "layer.distribute"; + let mode = str_p(p, "mode").or(str_p(p, "edge")).ok_or_else(|| bad(c, format!("missing `mode` ({EDGES})")))?; + let (cid, ids) = layers_p(s, p)?; + let mut items = placed(s, cid, &ids)?; + if items.len() < 3 { + return Err(bad(c, "distributing needs three or more layers")); + } + edge_of(&items[0].b, mode).ok_or_else(|| bad(c, format!("unknown mode `{mode}` ({EDGES})")))?; + let key = |it: &Placed| edge_of(&it.b, mode).map_or(0.0, |e| e.0); + items.sort_by(|a, b| key(a).total_cmp(&key(b))); + let (first, last) = (key(&items[0]), key(&items[items.len() - 1])); + let n = items.len() - 1; + let horiz = edge_of(&items[0].b, mode).is_some_and(|e| e.1); + let moves = items + .iter() + .enumerate() + .map(|(i, it)| { + let goal = first + (last - first) * i as f64 / n as f64; + let dv = goal - key(it); + (it.id, it.to_parent, if horiz { [dv, 0.0] } else { [0.0, dv] }) + }) + .collect(); + apply_moves(s, cid, "Distribute Layers", moves) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "layer.align", + "Align Layers", + [], + None, + "{edge: left|hcenter|right|top|vcenter|bottom, to?: composition|selection (default composition), layers?}", + has_layers, + align + ), + cmd!( + "layer.distribute", + "Distribute Layers", + [], + None, + "{mode: left|hcenter|right|top|vcenter|bottom (edges or centres), layers?}", + has_layers, + distribute + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + s + } + + fn solid(s: &mut Session, name: &str, w: u32, h: u32, pos: [f64; 2]) -> u64 { + let id = s.execute("layer.newSolid", json!({"name": name, "width": w, "height": h})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [pos[0], pos[1], 0.0]})).unwrap(); + id + } + + fn bounds(s: &Session, id: u64) -> [f64; 4] { + let cid = s.active_comp_id().unwrap(); + placed(s, cid, &[LayerId(id)]).unwrap()[0].b + } + + #[test] + fn align_to_composition_and_selection_is_one_undo_step() { + let mut s = session(); + let a = solid(&mut s, "A", 50, 40, [100.0, 100.0]); + let b = solid(&mut s, "B", 80, 20, [250.0, 180.0]); + s.execute("layer.align", json!({"edge": "left", "layers": [a, b]})).unwrap(); + assert!(bounds(&s, a)[0].abs() < 1e-6 && bounds(&s, b)[0].abs() < 1e-6); + // One step undoes both. + s.execute("edit.undo", json!({})).unwrap(); + assert!((bounds(&s, a)[0] - 75.0).abs() < 1e-6); + assert!((bounds(&s, b)[0] - 210.0).abs() < 1e-6); + s.execute("layer.align", json!({"edge": "bottom", "to": "composition", "layers": [a, b]})).unwrap(); + assert!((bounds(&s, a)[3] - 300.0).abs() < 1e-6 && (bounds(&s, b)[3] - 300.0).abs() < 1e-6); + s.execute("edit.undo", json!({})).unwrap(); + // Selection: to the selection's right edge (B's) and vertical centre. + s.execute("layer.align", json!({"edge": "right", "to": "selection", "layers": [a, b]})).unwrap(); + assert!((bounds(&s, a)[2] - 290.0).abs() < 1e-6 && (bounds(&s, b)[2] - 290.0).abs() < 1e-6); + s.execute("layer.align", json!({"edge": "vcenter", "to": "selection", "layers": [a, b]})).unwrap(); + let (ca, cb) = (bounds(&s, a), bounds(&s, b)); + assert!(((ca[1] + ca[3]) / 2.0 - (cb[1] + cb[3]) / 2.0).abs() < 1e-6); + assert!(s.execute("layer.align", json!({"edge": "left", "to": "selection", "layers": [a]})).is_err()); + assert!(s.execute("layer.align", json!({"edge": "diagonal", "layers": [a]})).is_err()); + } + + #[test] + fn align_honours_parent_scale_and_rotation() { + let mut s = session(); + let parent = solid(&mut s, "P", 10, 10, [200.0, 150.0]); + s.execute("prop.set", json!({"layer": parent, "path": "transform/scale", "value": [200.0, 200.0, 100.0]})).unwrap(); + s.execute("prop.set", json!({"layer": parent, "path": "transform/rotation", "value": 90.0})).unwrap(); + let child = solid(&mut s, "C", 20, 10, [30.0, 10.0]); + s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap(); + s.execute("layer.align", json!({"edge": "top", "layers": [child]})).unwrap(); + assert!(bounds(&s, child)[1].abs() < 1e-6, "{:?}", bounds(&s, child)); + s.execute("layer.align", json!({"edge": "hcenter", "layers": [child]})).unwrap(); + let b = bounds(&s, child); + assert!(((b[0] + b[2]) / 2.0 - 200.0).abs() < 1e-6 && b[1].abs() < 1e-6); + } + + #[test] + fn distribute_edges_and_centres() { + let mut s = session(); + let a = solid(&mut s, "A", 20, 20, [50.0, 50.0]); + let b = solid(&mut s, "B", 60, 20, [90.0, 100.0]); + let c = solid(&mut s, "C", 20, 20, [350.0, 200.0]); + s.execute("layer.distribute", json!({"mode": "hcenter", "layers": [a, b, c]})).unwrap(); + let cx = |id| { + let b = bounds(&s, id); + (b[0] + b[2]) / 2.0 + }; + assert!((cx(b) - 200.0).abs() < 1e-6 && (cx(a) - 50.0).abs() < 1e-6 && (cx(c) - 350.0).abs() < 1e-6); + s.execute("layer.distribute", json!({"mode": "top", "layers": [a, b, c]})).unwrap(); + assert!((bounds(&s, b)[1] - 115.0).abs() < 1e-6); + assert!(s.execute("layer.distribute", json!({"mode": "top", "layers": [a, b]})).is_err()); + } + + #[test] + fn separated_position() { + let mut s = session(); + let a = solid(&mut s, "A", 40, 40, [100.0, 100.0]); + s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap(); + assert!(s.active_comp().unwrap().layers.iter().any(|l| l.transform().is_some_and(|t| t.get("positionX").is_some()))); + s.execute("layer.align", json!({"edge": "right", "layers": [a]})).unwrap(); + assert!((bounds(&s, a)[2] - 400.0).abs() < 1e-6); + } +} diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index fb0473d4f..414fae223 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -395,7 +395,7 @@ fn report(s: &mut Session, p: &Value) -> Result { /// Orientation (degrees, AE order) of a camera whose +X, +Y (down) and +Z axes point along the /// given world directions: searched over quarter turns, which is all the cube faces need. -fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] { +pub(crate) fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] { let mut best = ([0.0; 3], f64::MAX); for a in 0..4 { for b in 0..4 { @@ -416,7 +416,7 @@ fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] { /// Cube faces in the VR Converter's order (+X, −X, +Y, −Y, +Z, −Z): name, forward, right and up /// in the VR convention (X right, Y up, Z front). -const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [ +pub(crate) const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [ ("Right", [1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]), ("Left", [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), ("Top", [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]), @@ -425,10 +425,10 @@ const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [ ("Back", [0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), ]; /// (column, row) of each face in the 3:2 cube map. -const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]; +pub(crate) const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]; /// VR world direction → After Effects world (Y down). -fn ae(v: [f64; 3]) -> Vec3 { +pub(crate) fn ae(v: [f64; 3]) -> Vec3 { vec3(v[0], -v[1], v[2]) } diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/commands/create.rs index a1d22fb59..07e6af7d3 100644 --- a/crates/engine/src/commands/create.rs +++ b/crates/engine/src/commands/create.rs @@ -33,7 +33,7 @@ fn create(s: &mut Session, p: &Value) -> Result { continue; }; let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?; - let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref()) + let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page) .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| { diff --git a/crates/engine/src/commands/essential.rs b/crates/engine/src/commands/essential.rs index a24fcdc0f..d7340f685 100644 --- a/crates/engine/src/commands/essential.rs +++ b/crates/engine/src/commands/essential.rs @@ -88,6 +88,14 @@ fn add_property(s: &mut Session, p: &Value) -> Result { let group = p.get("group").and_then(Value::as_u64); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); let rename = str_p(p, "name").map(str::to_string); + // `as`: show the property as another control (Source Text as `font`, Scale as `scale`). + let as_type = match str_p(p, "as") { + None => None, + Some(a) => { + let t = ControlType::parse(a).ok_or_else(|| bad(C, format!("as: font|scale|text|color|slider|checkbox|point|angle|dropdown, not `{a}`")))?; + Some(t) + } + }; let mut added = vec![]; for (cid, lid, uid) in &targets { let l = s.project.comp(*cid).and_then(|c| c.layer(*lid)).ok_or(EngineError::NoComp)?; @@ -95,20 +103,32 @@ fn add_property(s: &mut Session, p: &Value) -> Result { if control_type(pr).is_none() { return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name))); } - if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for(*lid, *uid).is_some()) { + // The natural control needs no `as`. + let as_type = as_type.filter(|t| control_type(pr) != Some(*t)); + if let Some(t) = as_type + && !essential::can_show_as(pr, t) + { + return Err(bad(C, format!("`{}` can't be shown as a {} control", pr.name, t.label()))); + } + if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for_as(*lid, *uid, as_type).is_some()) { continue; } - added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone()))); + let name = rename.clone().unwrap_or_else(|| match as_type { + Some(ControlType::Font) => format!("{} Font", l.name), + _ => pr.name.clone(), + }); + added.push((*cid, *lid, *uid, name, as_type)); } let ids = s.edit("Add Property to Essential Graphics", None, |proj, st| { if let Some((cid, ..)) = added.first() { st.essential_primary.get_or_insert(*cid); } let mut ids = vec![]; - for (k, (cid, lid, uid, name)) in added.iter().enumerate() { + for (k, (cid, lid, uid, name, as_type)) in added.iter().enumerate() { let id = proj.alloc(); let eg = eg_of(proj, *cid)?; - insert(eg, EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid } }, group, index.map(|i| i + k), C)?; + let ctl = EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid }, as_type: *as_type }; + insert(eg, ctl, group, index.map(|i| i + k), C)?; ids.push(id); } Ok(ids) @@ -120,6 +140,39 @@ fn control_type(pr: &effectcraft_project::Property) -> Option { essential::control_type(pr) } +/// A Font control's value for `prop.set`: `cur` (a Source Text value) with the font family, +/// style and size from `v` (`{font?, style?, size?}` or a family name). `None` when `cur` is +/// not text. +pub fn font_value(cur: &KV, v: &Value) -> Option { + let KV::Text(doc) = cur else { return None }; + let mut d = (**doc).clone(); + match v { + Value::String(f) => d.font = f.clone(), + Value::Object(o) => { + if let Some(f) = o.get("font").and_then(Value::as_str) { + d.font = f.to_string(); + } + if let Some(st) = o.get("style").and_then(Value::as_str) { + d.style = st.to_string(); + } + if let Some(z) = o.get("size").and_then(Value::as_f64) { + d.size = z.max(0.1); + } + } + _ => return None, + } + serde_json::to_value(&d).ok() +} + +/// A control's value for agents: Font controls show `{font, style, size}`, the rest the +/// property's value. +fn value_json(ty: Option, v: &KV) -> Value { + match (ty, v) { + (Some(ControlType::Font), KV::Text(d)) => json!({"font": d.font, "style": d.style, "size": d.size}), + _ => v.to_json(), + } +} + /// Media Replacement for a footage layer. fn add_media(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.addMedia"; @@ -137,7 +190,7 @@ fn add_media(s: &mut Session, p: &Value) -> Result { if eg.flat().iter().any(|c| matches!(c.kind, EgKind::Media { layer } if layer == lid)) { return Err(bad(C, "this layer already has a Media Replacement control")); } - insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid } }, group, None, C)?; + insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid }, as_type: None }, group, None, C)?; Ok(id) })?; Ok(json!({"control": id})) @@ -150,7 +203,7 @@ fn add_group(s: &mut Session, p: &Value) -> Result { let id = s.edit("Add Essential Graphics Group", None, |proj, st| { st.essential_primary.get_or_insert(cid); let id = proj.alloc(); - insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] } }, None, index, "essential.addGroup")?; + insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] }, as_type: None }, None, index, "essential.addGroup")?; Ok(id) })?; Ok(json!({"control": id})) @@ -163,7 +216,13 @@ fn add_comment(s: &mut Session, p: &Value) -> Result { let id = s.edit("Add Essential Graphics Comment", None, |proj, st| { st.essential_primary.get_or_insert(cid); let id = proj.alloc(); - insert(eg_of(proj, cid)?, EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text } }, group, None, "essential.addComment")?; + insert( + eg_of(proj, cid)?, + EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text }, as_type: None }, + group, + None, + "essential.addComment", + )?; Ok(id) })?; Ok(json!({"control": id})) @@ -262,6 +321,35 @@ fn edit_dropdown(s: &mut Session, p: &Value) -> Result { Ok(json!({"prop": uid, "items": items})) } +/// Composition ▸ Open in Essential Graphics. +fn open_in_eg(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + s.state.essential_primary = Some(cid); + s.bump(); + s.events.push(crate::Event::Frontend { command: "window.panel".into(), params: json!({"panel": "essentialGraphics"}) }); + let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + Ok(json!({"comp": cid.0, "name": name})) +} + +/// Whether a property can be exposed (`property.canAddToMotionGraphicsTemplate`). +fn can_add(s: &mut Session, p: &Value) -> Result { + const C: &str = "essential.canAdd"; + let (cid, lid, uid) = super::prop::prop_ref(s, p, C)?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + let pr = l.props.find(uid).ok_or_else(|| bad(C, "no such property"))?; + let as_type = str_p(p, "as").and_then(ControlType::parse); + let ty = match as_type { + Some(t) if essential::can_show_as(pr, t) => Some(t), + Some(t) => return Ok(json!({"ok": false, "reason": format!("`{}` can't be shown as a {} control", pr.name, t.label())})), + None => control_type(pr), + }; + let in_eg = super::match_path_of(&l.props, uid).is_some_and(|m| m.starts_with(essential::GROUP)); + Ok(match ty { + Some(t) if !in_eg => json!({"ok": true, "type": t}), + _ => json!({"ok": false, "reason": format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name)}), + }) +} + fn solo_supported(s: &mut Session, p: &Value) -> Result { let on = b_p(p, "on").unwrap_or(!s.state.essential_solo); s.state.essential_solo = on; @@ -299,8 +387,8 @@ fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value { "id": c.id, "name": c.name, "kind": "property", "layer": layer.0, "prop": prop, "layerName": src.map(|(l, _)| l.name.clone()), "path": src.and_then(|(l, _)| super::match_path_of(&l.props, *prop)), - "type": src.and_then(|(_, pr)| essential::control_type(pr)), - "value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()), + "type": src.and_then(|(_, pr)| essential::effective_type(c, pr)), + "value": src.map(|(_, pr)| value_json(essential::effective_type(c, pr), &pr.value_at(Tick::ZERO))), "missing": src.is_none(), }) } @@ -384,8 +472,8 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { "control": c.id, "name": c.name, "kind": kind, "uid": pr.as_ref().map(|x| x.uid), "path": pr.as_ref().and_then(|x| super::match_path_of(&l.props, x.uid)), - "value": pr.as_ref().map(|x| x.value_at(l.layer_time(t)).to_json()), - "type": pr.as_ref().and_then(essential::control_type).or((kind == "media").then_some(ControlType::Media)), + "value": pr.as_ref().map(|x| value_json(essential::effective_type(c, x), &x.value_at(l.layer_time(t)))), + "type": pr.as_ref().and_then(|x| essential::effective_type(c, x)).or((kind == "media").then_some(ControlType::Media)), "overridden": pr.as_ref().is_some_and(|x| over.contains(&x.uid)), })); } @@ -395,7 +483,7 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { /// Set an instance's value for a control (`value`, or `item` for Media Replacement). fn set_override(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.set"; - let (cid, lid, _) = instance(s, p, C)?; + let (cid, lid, src) = instance(s, p, C)?; let control = control_p(p, C)?; let uid = master_uid(s, cid, lid, control).ok_or_else(|| bad(C, format!("no control {control} on this instance")))?; if let Some(item) = p.get("item") { @@ -414,7 +502,13 @@ fn set_override(s: &mut Session, p: &Value) -> Result { })?; return Ok(json!({"control": control, "item": item.0})); } - let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?}); + let mut value = p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?; + let as_type = s.project.comp(src).and_then(|c| c.essential.as_ref()).and_then(|e| e.find(control)).and_then(|c| c.as_type); + if as_type == Some(ControlType::Font) { + let cur = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)).map(|pr| pr.value.clone()).unwrap_or(KV::Scalar(0.0)); + value = font_value(&cur, &value).ok_or_else(|| bad(C, "a Font control takes {font?, style?, size?} or a font family name"))?; + } + let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": value}); if let Some(m) = p.get("merge") { q["merge"] = m.clone(); } @@ -477,12 +571,21 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result { let mut writes = vec![]; for (control, uid) in &ts { let (Some(ctl), Some(pr)) = (eg.find(*control), l.props.find(*uid)) else { continue }; - writes.push((ctl.kind.clone(), pr.clone())); + writes.push((ctl.kind.clone(), pr.clone(), ctl.as_type)); } s.edit("Push Override Values to Source", None, |proj, _| { let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?; - for (kind, pr) in &writes { + for (kind, pr, as_type) in &writes { match kind { + // A Font control writes only the font (the source keeps its text and keys). + EgKind::Property { layer, prop } if *as_type == Some(ControlType::Font) => { + if let Some(target) = sc.layer_mut(*layer).and_then(|l| l.props.find_mut(*prop)) { + essential::merge_font(&mut target.value, &pr.value); + for k in &mut target.keys { + essential::merge_font(&mut k.value, &pr.value); + } + } + } EgKind::Property { layer, prop } => { let Some(inner) = sc.layer_mut(*layer) else { continue }; // Instance keys are in the precomp layer's time (= the source comp's time); @@ -740,7 +843,7 @@ pub fn specs() -> Vec { "Add Property to Essential Graphics", ["Animation"], None, - "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?}", + "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?, as?: font (Source Text font family/style/size) | scale (uniform Scale)}", has_selected_props, add_property ), @@ -774,6 +877,16 @@ pub fn specs() -> Vec { cmd!("essential.revert", "Revert", [], None, "{layer, control? (default: all overridden)}", has_instance, revert), cmd!("effect.editDropdown", "Edit Dropdown Menu", [], None, "{layer, path?|prop?, items: [string]}", has_comp, edit_dropdown), query!("essential.list", "Essential Graphics controls", "{comp?, supported?: bool}", list), + query!("essential.canAdd", "Can Add Property to Essential Graphics", "{layer, path|prop, as?} → {ok, type?, reason?}", can_add), + cmd!( + "comp.openInEssentialGraphics", + "Open in Essential Graphics", + ["Composition"], + None, + "{comp?} → makes it the Essential Graphics panel's Primary composition and shows the panel", + has_comp, + open_in_eg + ), query!("essential.instance", "Essential Properties of a precomp layer", "{layer}", instance_json), query!("essential.templateInfo", "Read a template's manifest", "{path}", template_info), ] diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index fe057683a..14cbb7587 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -102,6 +102,14 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { "compositionLayerSizes" | "compositionRetainLayerSizes" | "layerSizes" => Some(true), other => return Err(bad("file.import", format!("importAs: footage|composition|compositionLayerSizes, not `{other}`"))), }; + // PDF / Illustrator: which page (1-based, as in the Import dialog). + let page = match p.get("page") { + None | Some(Value::Null) => 0, + Some(v) => match v.as_u64().or_else(|| v.as_f64().filter(|x| x.fract() == 0.0 && *x >= 1.0).map(|x| x as u64)) { + Some(n) if n >= 1 => (n - 1) as u32, + _ => return Err(bad("file.import", "page: a page number from 1")), + }, + }; let mut paths = paths; let mut out_comps = vec![]; let mut ids = vec![]; @@ -113,7 +121,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { 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) { + match import_vector_comp(s, &path, &b, page) { Ok((comp, items)) => { out_comps.push(comp); ids.extend(items); @@ -185,13 +193,31 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { f.frame_rate = r; f.duration = r.tick_of(frames.max(1)); } + // Page: another page of a PDF / Illustrator file. + if page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") { + match s + .services + .read_file(path) + .map_err(|e| e.to_string()) + .and_then(|b| effectcraft_pdf::parse_page(&b, page as usize).map_err(|e| e.to_string())) + { + Ok(doc) => { + (f.width, f.height) = doc.pixel_size(); + f.page = page; + } + Err(e) => { + errors.push(format!("{path}: {e}")); + continue; + } + } + } // Choose Layer: one layer of a Photoshop document (document-sized). if let Some(sel) = &psd_layer && 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, embedded: None }) + f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false }) } None => { errors.push(format!("{path}: no layer {sel}")); @@ -210,6 +236,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); let name = match &f.layer { Some(l) => format!("{}/{name}", l.name), + None if f.page > 0 => format!("{name} (Page {})", f.page + 1), None => name, }; let label = match f.kind { @@ -298,14 +325,18 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, 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)> { - let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into()); +/// Import page `page` of 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], page: u32) -> Result<(u64, Vec)> { + let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into()); + if page > 0 { + name = format!("{name} (Page {})", page + 1); + } 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)?; + let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?; st.project_selection = vec![r.0]; Ok(r) })?; @@ -478,7 +509,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{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)}", + "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}", always, import ), diff --git a/crates/engine/src/commands/frame_export.rs b/crates/engine/src/commands/frame_export.rs new file mode 100644 index 000000000..77e0f5d6d --- /dev/null +++ b/crates/engine/src/commands/frame_export.rs @@ -0,0 +1,240 @@ +//! Composition ▸ Save Frame As ▸ Photoshop Layers… and ProEXR…: the current frame with every +//! visible layer kept apart. +//! +//! - **Photoshop Layers** writes a layered PSD (crates/psd's writer): one pixel layer per comp +//! layer, each rendered in isolation (solo, Normal mode, 100% opacity) and cropped to its +//! pixels, carrying the layer's blend mode and opacity, plus the flattened frame as the merged +//! image. Photoshop (and EffectCraft's PSD import) rebuild the frame from the layers. +//! - **ProEXR** writes one multi-layer OpenEXR (half-float-free, 32-bit float, linear light, +//! premultiplied): the composite as `R`, `G`, `B`, `A` plus one `.R/G/B/A` channel +//! set per layer (the layer-prefixed channel convention multi-layer EXR readers use). + +use effectcraft_color::BlendMode; +use effectcraft_project::{ItemId, LayerId, Project}; +use effectcraft_raster::Image; +use effectcraft_render::{Backend, RenderOpts, Renderer}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, comp_id, f_p, has_comp, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// One isolated layer of a frame: name, blend mode, opacity (0..1), premultiplied pixels. +struct Isolated { + name: String, + blend: BlendMode, + opacity: f64, + image: Image, +} + +fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { + let opts = RenderOpts { + scale, + backend: Backend::Auto, + nested_switches: s.prefs.general.switches_affect_nested_comps, + draft_shadows: s.prefs.three_d.realtime_shadows, + ..Default::default() + }; + let mut r = Renderer::new(project, s.footage.as_ref(), opts); + r.expr = s.expr.as_deref(); + r.accel = s.accel.as_deref(); + r.comp_frame(comp, t) +} + +/// The comp frame and each visible layer rendered alone (top layer first). +fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec)> { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let full = render_with(s, &s.project, cid, t, scale); + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.has_video()); + let ids: Vec = comp + .layers + .iter() + .filter(|l| l.has_video() && l.is_active_at(t) && !l.switches.adjustment && !l.switches.guide && !l.environment && (!any_solo || l.switches.solo)) + .map(|l| l.id) + .collect(); + let mut out = vec![]; + for id in ids { + let mut p: Project = (*s.project).clone(); + let Some(c) = p.comp_mut(cid) else { continue }; + let mut info = None; + for l in c.layers.iter_mut() { + l.switches.solo = l.id == id; + if l.id == id { + let lt = l.layer_time(t); + let opacity = l.props.prop("transform/opacity").map(|o| o.value_at(lt).as_f64() / 100.0).unwrap_or(1.0); + info = Some((l.name.clone(), l.blend_mode, opacity.clamp(0.0, 1.0))); + l.blend_mode = BlendMode::Normal; + if let Some(o) = l.props.prop_mut("transform/opacity") { + o.keys.clear(); + o.expr = None; + o.value = effectcraft_keyframe::Value::Scalar(100.0); + } + } + } + let Some((name, blend, opacity)) = info else { continue }; + let image = render_with(s, &p, cid, t, scale); + out.push(Isolated { name, blend, opacity, image }); + } + Ok((full, out)) +} + +fn straight(p: [f32; 4]) -> [f32; 4] { + if p[3] <= 0.0 { [0.0; 4] } else { [p[0] / p[3], p[1] / p[3], p[2] / p[3], p[3].min(1.0)] } +} + +/// The Photoshop key of a blend mode (Normal for modes Photoshop lacks). +pub fn psd_blend_key(m: BlendMode) -> [u8; 4] { + const KEYS: [&[u8; 4]; 26] = [ + b"diss", b"dark", b"mul ", b"idiv", b"lbrn", b"dkCl", b"lite", b"scrn", b"div ", b"lddg", b"lgCl", b"over", b"sLit", b"hLit", b"vLit", b"lLit", + b"pLit", b"hMix", b"diff", b"smud", b"fsub", b"fdiv", b"hue ", b"sat ", b"colr", b"lum ", + ]; + KEYS.iter().find(|k| crate::psd_import::blend_mode(std::str::from_utf8(&k[..]).unwrap_or("")) == m).map(|k| **k).unwrap_or(*b"norm") +} + +/// The bounding box of non-transparent pixels (`None` when empty). +fn alpha_bounds(img: &Image) -> Option<(u32, u32, u32, u32)> { + let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0); + for y in 0..img.height { + for x in 0..img.width { + if img.data[(y * img.width + x) as usize][3] > 0.5 / 255.0 { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x + 1); + y1 = y1.max(y + 1); + } + } + } + (x1 > x0).then_some((x0, y0, x1, y1)) +} + +/// The layered PSD bytes of a frame. +pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec, usize, u32, u32)> { + use effectcraft_psd::Rect; + use effectcraft_psd::write::{WDoc, WLayer, write}; + let (full, layers) = isolate(s, cid, t, scale)?; + let mut doc = WDoc::new(full.width, full.height); + let n = layers.len(); + // File order is bottom first. + for l in layers.into_iter().rev() { + let mut w = match alpha_bounds(&l.image) { + Some((x0, y0, x1, y1)) => { + let mut px = Vec::with_capacity(((x1 - x0) * (y1 - y0)) as usize); + for y in y0..y1 { + for x in x0..x1 { + px.push(straight(l.image.data[(y * l.image.width + x) as usize])); + } + } + WLayer::pixels(&l.name, Rect::new(x0 as i32, y0 as i32, (x1 - x0) as i32, (y1 - y0) as i32), px) + } + None => WLayer::pixels(&l.name, Rect::default(), vec![]), + }; + w.blend = psd_blend_key(l.blend); + w.opacity = (l.opacity * 255.0).round() as u8; + doc.layers.push(w); + } + doc.composite = Some(full.data.iter().map(|p| straight(*p)).collect()); + Ok((write(&doc), n, full.width, full.height)) +} + +fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +/// A unique EXR layer name (no dots: they separate the layer from the channel). +fn exr_name(name: &str, used: &mut Vec) -> String { + let base: String = name.chars().map(|c| if c == '.' || c.is_control() { '_' } else { c }).collect(); + let base = if base.trim().is_empty() { "Layer".to_string() } else { base }; + let mut n = base.clone(); + let mut k = 2; + while used.contains(&n) { + n = format!("{base} {k}"); + k += 1; + } + used.push(n.clone()); + n +} + +/// The multi-layer EXR bytes of a frame and the channel names. +pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec, Vec, u32, u32)> { + use exr::prelude::*; + let (full, layers) = isolate(s, cid, t, scale)?; + let (w, h) = (full.width as usize, full.height as usize); + let planes = |img: &effectcraft_raster::Image| -> [Vec; 4] { + let mut out: [Vec; 4] = Default::default(); + for p in &img.data { + let sp = straight(*p); + let a = sp[3]; + for c in 0..3 { + out[c].push(srgb_to_linear(sp[c].clamp(0.0, 1.0)) * a); + } + out[3].push(a); + } + out + }; + let mut channels = vec![]; + let mut names = vec![]; + let mut push = |prefix: &str, img: &effectcraft_raster::Image| { + for (c, data) in ["R", "G", "B", "A"].iter().zip(planes(img)) { + let n = if prefix.is_empty() { c.to_string() } else { format!("{prefix}.{c}") }; + names.push(n.clone()); + channels.push(AnyChannel::new(n.as_str(), FlatSamples::F32(data))); + } + }; + push("", &full); + let mut used = vec![]; + for l in &layers { + let n = exr_name(&l.name, &mut used); + push(&n, &l.image); + } + let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, AnyChannels::sort(channels.into())); + let mut buf = std::io::Cursor::new(Vec::new()); + Image::from_layer(layer).write().to_buffered(&mut buf).map_err(|e| EngineError::Other(format!("EXR encoding failed: {e}")))?; + Ok((buf.into_inner(), names, full.width, full.height)) +} + +fn frame_args(s: &Session, p: &Value, cmd: &str, ext: &str) -> Result<(String, ItemId, Tick, f64)> { + let path = str_p(p, "path").ok_or_else(|| bad(cmd, format!("missing `path` (.{ext})")))?.to_string(); + let cid = comp_id(s, p)?; + let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time()); + let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0); + Ok((path, cid, t, scale)) +} + +fn save_psd(s: &mut Session, p: &Value) -> Result { + let (path, cid, t, scale) = frame_args(s, p, "comp.saveFrameAsPsd", "psd")?; + let (bytes, n, w, h) = layered_psd(s, cid, t, scale)?; + s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.toast(format!("Saved {n} layers to {path}")); + Ok(json!({"path": path, "layers": n, "width": w, "height": h, "time": t.seconds()})) +} + +fn save_exr(s: &mut Session, p: &Value) -> Result { + let (path, cid, t, scale) = frame_args(s, p, "comp.saveFrameAsExr", "exr")?; + let (bytes, channels, w, h) = layered_exr(s, cid, t, scale)?; + s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.toast(format!("Saved a multi-layer EXR to {path}")); + Ok(json!({"path": path, "channels": channels, "width": w, "height": h, "time": t.seconds()})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "comp.saveFrameAsPsd", + "Photoshop Layers...", + ["Composition", "Save Frame As"], + None, + "{path (.psd), comp?, time?, scale?} → a layered PSD: one layer per visible comp layer (blend mode, opacity) + the merged frame", + has_comp, + save_psd + ), + cmd!( + "comp.saveFrameAsExr", + "ProEXR...", + ["Composition", "Save Frame As"], + None, + "{path (.exr), comp?, time?, scale?} → a multi-layer OpenEXR: composite R,G,B,A + `.R/G/B/A` per layer (linear, premultiplied)", + has_comp, + save_exr + ), + ] +} diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 7abd29238..62cfd351b 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -36,7 +36,14 @@ pub fn specs() -> Vec { fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always), fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always), // Preview. - fe!("playback.toggle", "Play Current Preview", ["Composition", "Preview"], Some("Space"), "{}", has_comp), + fe!( + "playback.toggle", + "Play Current Preview", + ["Composition", "Preview"], + Some("Space"), + "{shortcut?: spacebar|shiftSpacebar|numpad0|shiftNumpad0|altNumpad0 (whose Preview panel options to use; default spacebar)}", + has_comp + ), fe!("playback.cacheWhenIdle", "Cache Frames When Idle", ["Composition", "Preview"], None, "{value?}", always), fe!("playback.audio", "Audio", ["Composition", "Preview"], None, "{value?: include audio in previews}", always), // Viewer. @@ -63,7 +70,7 @@ pub fn specs() -> Vec { "Show Panel", [], None, - "{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill}", + "{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor}", always ), fe!( diff --git a/crates/engine/src/commands/help.rs b/crates/engine/src/commands/help.rs index 2ed358b54..0e9dd04d2 100644 --- a/crates/engine/src/commands/help.rs +++ b/crates/engine/src/commands/help.rs @@ -35,6 +35,12 @@ pub fn specs() -> Vec { cmd!("help.website", "ArtCraft Website", ["Help"], None, "{}", always, |s, _| open(s, links::WEBSITE.into())), cmd!("help.appPage", "EffectCraft Home Page", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())), cmd!("help.github", "EffectCraft on GitHub", ["Help"], None, "{}", always, |s, _| open(s, links::GITHUB.into())), + cmd!("help.onlineTutorials", "Online Tutorials...", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())), + cmd!("help.inAppTutorials", "In-App Tutorials...", ["Help"], None, "{} → the Home screen's Learn tab", always, |s, p| super::frontend( + s, + "help.inAppTutorials", + p + )), cmd!("help.reportIssue", "Provide Feedback...", ["Help"], None, "{}", always, |s, _| open(s, links::ISSUES.into())), cmd!( "help.sibling", diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 58135b357..f8edd1029 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1163,7 +1163,7 @@ pub fn specs() -> Vec { "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?, 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?}", + "{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?, variations?: {tag: value} (variable font axes; null resets), 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 ), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 9ef10e095..151727ea6 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -1,11 +1,12 @@ //! The command registry. Ids follow After Effects' menu structure; every menu item, panel button, //! shortcut and viewer/timeline gesture maps to one of these. +mod align; mod align_data; mod anim; pub mod anim_tools; mod animation; -mod app_more; +pub(crate) mod app_more; mod autotrace; mod camera_cmds; mod comp; @@ -20,6 +21,7 @@ mod file; pub(crate) mod file_more; mod focus; pub mod footage_panel; +pub(crate) mod frame_export; mod frontend; mod help; mod key_labels; @@ -28,7 +30,7 @@ mod keys_more; mod layer; mod layer_menu; mod layer_time; -mod link; +pub(crate) mod link; mod liquify; mod lottie; mod markers; @@ -37,6 +39,7 @@ pub mod mask_interp; pub(crate) mod model3d; pub mod paint; pub mod panels_cmds; +pub(crate) mod path_nulls; mod paths; mod project_items; mod prop; @@ -60,7 +63,10 @@ pub mod time; mod track; mod view; pub mod viewer_cmds; +pub(crate) mod vpe; +pub(crate) mod vr_editor; mod warp_cmds; +pub(crate) mod watch_folder; #[cfg(test)] pub(crate) use app_more::report_text as report_text_for_tests; pub(crate) use app_more::view_command; @@ -161,6 +167,11 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(file_more::specs()); v.extend(lottie::specs()); v.extend(comp_more::specs()); + v.extend(frame_export::specs()); + v.extend(watch_folder::specs()); + v.extend(vpe::specs()); + v.extend(path_nulls::specs()); + v.extend(vr_editor::specs()); v.extend(frontend::specs()); v.extend(track::specs()); v.extend(mask_interp::specs()); @@ -182,9 +193,11 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(autotrace::specs()); v.extend(scene_detect::specs()); v.extend(align_data::specs()); + v.extend(align::specs()); v.extend(content_fill::specs()); v.extend(stubs::specs()); v.extend(crate::learn::specs()); + v.extend(crate::preview::specs()); v }) } diff --git a/crates/engine/src/commands/path_nulls.rs b/crates/engine/src/commands/path_nulls.rs new file mode 100644 index 000000000..1034f3d50 --- /dev/null +++ b/crates/engine/src/commands/path_nulls.rs @@ -0,0 +1,217 @@ +//! Window ▸ Create Nulls From Paths: our own panel (and these commands) linking a mask or shape +//! path to null layers with expressions, in three ways: +//! +//! - **Points Follow Nulls** (`paths.pointsFollowNulls`): one null per vertex, placed on it; the +//! path's expression rebuilds it from the nulls (`createPath` of each null's position brought +//! into the path's layer with `fromComp`), so moving a null moves its vertex. +//! - **Nulls Follow Points** (`paths.nullsFollowPoints`): one null per vertex whose Position +//! expression follows the vertex (`toComp(path.points()[i])`), for attaching things to an +//! animated path. +//! - **Trace Path** (`paths.tracePath`): one null with a Progress slider (keyframed 0 → 100 % over +//! the layer's span, optionally looping) whose Position follows `pointOnPath(progress)`. +//! +//! The path is `path`/`prop` of `layer`, else the selected path property, else the layer's first +//! mask or shape path. Each command is one undo step. + +use effectcraft_geom::vec2; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{ItemId, LayerId, Uid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, comp_id, has_layers}; +use crate::{EngineError, Result, Session, cmd}; + +fn quote(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\"")) +} + +/// The target path property: (comp, layer, uid). +fn target(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { + if p.get("path").is_some() || p.get("prop").is_some() { + let r = super::prop::prop_ref(s, p, cmd)?; + let is_path = s.project.comp(r.0).and_then(|c| c.layer(r.1)).and_then(|l| l.props.find(r.2)).is_some_and(|pr| matches!(pr.value, KV::Path(_))); + return if is_path { Ok(r) } else { Err(bad(cmd, "that property is not a mask or shape path")) }; + } + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + // A selected path property. + for (l, u) in super::selected_leaf_props(s) { + if comp.layer(l).and_then(|ly| ly.props.find(u)).is_some_and(|pr| matches!(pr.value, KV::Path(_))) { + return Ok((cid, l, u)); + } + } + // The first path of `layer` (or of the first selected layer). + let lid = match p.get("layer") { + Some(_) => super::layer_p(s, p, cmd)?.1, + None => *s.state.selected_layers.first().ok_or_else(|| bad(cmd, "select a layer with a mask or shape path"))?, + }; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let mut found = None; + layer.props.walk("", &mut |_, pr| { + if found.is_none() && matches!(pr.value, KV::Path(_)) && !matches!(pr.ui, effectcraft_project::ParamUi::Hidden) { + found = Some(pr.uid); + } + }); + found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, format!("`{}` has no mask or shape path", layer.name))) +} + +/// What the commands need about the path: its vertices in comp space (now), the path value, the +/// expression reference to it and its layer's name. +struct PathInfo { + comp_pts: Vec<[f64; 2]>, + path: effectcraft_keyframe::ShapePath, + reference: String, + layer_name: String, + prop_name: String, + span: (f64, f64), +} + +fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let pr = layer.props.find(uid).ok_or_else(|| bad(cmd, "no such property"))?; + let t = s.time(); + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) }; + if path.vertices.is_empty() { + return Err(bad(cmd, "the path has no vertices")); + } + let m = ctx.layer_to_comp(layer).0; + let comp_pts = path + .vertices + .iter() + .map(|v| { + let q = m.apply(vec2(v[0], v[1])); + [q.x, q.y] + }) + .collect(); + // Seen from another layer (the nulls). + let mut other = layer.clone(); + other.id = LayerId(u64::MAX); + other.name = "\u{0}".into(); + let reference = super::link::reference(comp, &other, layer, uid, true).ok_or_else(|| bad(cmd, "can't reference that path"))?; + let prop_name = layer.props.name_path_of(uid).and_then(|n| n.split('/').rev().nth(1).map(str::to_string)).unwrap_or_else(|| pr.name.clone()); + Ok(PathInfo { comp_pts, path, reference, layer_name: layer.name.clone(), prop_name, span: (layer.in_point.seconds(), layer.out_point.seconds()) }) +} + +/// A new null at `at` (comp space) named `name`, made unique in the comp (expressions find +/// layers by name). Returns (layer id, name). +fn new_null(s: &mut Session, cid: ItemId, name: &str, at: [f64; 2]) -> Result<(u64, String)> { + let taken = |n: &str| s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.name == n)); + let mut name = name.to_string(); + let base = name.clone(); + let mut k = 2; + while taken(&name) { + name = format!("{base} {k}"); + k += 1; + } + let r = s.execute("layer.newNull", json!({"comp": cid.0, "name": name}))?; + let id = r.get("layer").and_then(Value::as_u64).ok_or_else(|| EngineError::Other("no null layer".into()))?; + s.execute("prop.set", json!({"comp": cid.0, "layer": id, "path": "transform/position", "value": at}))?; + Ok((id, name)) +} + +fn nulls_follow_points(s: &mut Session, p: &Value) -> Result { + const C: &str = "paths.nullsFollowPoints"; + let (cid, lid, uid) = target(s, p, C)?; + let pi = info(s, cid, lid, uid, C)?; + let nulls = super::app_more::grouped(s, "Nulls Follow Points", |s| { + let mut out = vec![]; + for (i, at) in pi.comp_pts.iter().enumerate() { + let (id, _) = new_null(s, cid, &format!("{}: {} [{}]", pi.layer_name, pi.prop_name, i + 1), *at)?; + let e = format!("var src = thisComp.layer({});\nvar p = src.toComp({}.points()[{i}]);\n[p[0], p[1]]", quote(&pi.layer_name), pi.reference); + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "transform/position", "expression": e}))?; + out.push(id); + } + Ok(out) + })?; + Ok(json!({"nulls": nulls, "layer": lid.0, "prop": uid})) +} + +fn points_follow_nulls(s: &mut Session, p: &Value) -> Result { + const C: &str = "paths.pointsFollowNulls"; + let (cid, lid, uid) = target(s, p, C)?; + let pi = info(s, cid, lid, uid, C)?; + let nulls = super::app_more::grouped(s, "Points Follow Nulls", |s| { + let mut out = vec![]; + let mut names = vec![]; + for (i, at) in pi.comp_pts.iter().enumerate() { + let name = format!("{}: {} [{}]", pi.layer_name, pi.prop_name, i + 1); + let (id, name) = new_null(s, cid, &name, *at)?; + out.push(id); + names.push(quote(&name)); + } + let arr = |v: &[[f64; 2]]| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()); + let e = format!( + "var nulls = [{}];\nvar pts = [];\nfor (var i = 0; i < nulls.length; i++) {{\n var n = thisComp.layer(nulls[i]);\n var q = fromComp(n.toComp(n.transform.anchorPoint));\n pts.push([q[0], q[1]]);\n}}\ncreatePath(pts, {}, {}, {})", + names.join(", "), + arr(&pi.path.in_tangents), + arr(&pi.path.out_tangents), + pi.path.closed + ); + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": lid.0, "prop": uid, "expression": e}))?; + Ok(out) + })?; + Ok(json!({"nulls": nulls, "layer": lid.0, "prop": uid})) +} + +fn trace_path(s: &mut Session, p: &Value) -> Result { + const C: &str = "paths.tracePath"; + let (cid, lid, uid) = target(s, p, C)?; + let pi = info(s, cid, lid, uid, C)?; + let looping = b_p(p, "loop").unwrap_or(false); + let nid = super::app_more::grouped(s, "Trace Path", |s| { + let (id, _) = new_null(s, cid, &format!("Trace {}: {}", pi.layer_name, pi.prop_name), pi.comp_pts[0])?; + s.execute("effect.apply", json!({"comp": cid.0, "layers": [id], "effect": "ec.control.slider"}))?; + let fx = s.project.comp(cid).and_then(|c| c.layer(LayerId(id))).and_then(|l| l.props.group("effects/#1")).map(|g| g.uid); + if let Some(fx) = fx { + s.execute("prop.renameGroup", json!({"comp": cid.0, "layer": id, "prop": fx, "name": "Progress"}))?; + } + let (a, b) = pi.span; + s.execute("prop.addKey", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "time": a, "value": 0.0}))?; + s.execute("prop.addKey", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "time": b.max(a), "value": 100.0}))?; + if looping { + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "expression": "loopOut(\"cycle\")"}))?; + } + let e = format!( + "var src = thisComp.layer({});\nvar p = src.toComp({}.pointOnPath(effect(\"Progress\")(\"Slider\") / 100));\n[p[0], p[1]]", + quote(&pi.layer_name), + pi.reference + ); + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "transform/position", "expression": e}))?; + Ok(id) + })?; + Ok(json!({"null": nid, "layer": lid.0, "prop": uid})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "paths.pointsFollowNulls", + "Points Follow Nulls", + [], + None, + "{layer?, path?|prop?} → a null per vertex; the path follows them (expression)", + has_layers, + points_follow_nulls + ), + cmd!( + "paths.nullsFollowPoints", + "Nulls Follow Points", + [], + None, + "{layer?, path?|prop?} → a null per vertex following it (Position expressions)", + has_layers, + nulls_follow_points + ), + cmd!( + "paths.tracePath", + "Trace Path", + [], + None, + "{layer?, path?|prop?, loop?: bool} → a null moving along the path (Progress slider)", + has_layers, + trace_path + ), + ] +} diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 628533afe..49884bc0a 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -188,7 +188,7 @@ pub fn specs() -> Vec { crate::query!( "scriptui.get", "Script Window Controls", - "{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, children…}}", + "{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, draw (onDraw paint list), children…}}", scriptui::get ), cmd!( @@ -205,7 +205,7 @@ pub fn specs() -> Vec { "Set Script Window Control", [], None, - "{window?, widget, value: text | number | bool | item index | item text} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange", + "{window?, widget, value: text | number | bool | item index | item text, changing?: bool (a live update: each keystroke / slider step; fires onChanging only)} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange", always, scriptui::set ), diff --git a/crates/engine/src/commands/vpe.rs b/crates/engine/src/commands/vpe.rs new file mode 100644 index 000000000..f4932bc01 --- /dev/null +++ b/crates/engine/src/commands/vpe.rs @@ -0,0 +1,29 @@ +//! File ▸ Import ▸ Vanishing Point (.vpe): present in the menu like After Effects', but always +//! disabled. The `.vpe` exchange format Photoshop's Vanishing Point filter writes has no public +//! specification, and EffectCraft implements formats only from published specs (clean room), so +//! the entry explains that instead of importing. See docs/parity.md. + +use serde_json::Value; + +use super::{CommandSpec, bad}; +use crate::{Result, Session, cmd}; + +/// Why the entry is disabled (shown as its tooltip). +pub const WHY: &str = + "Vanishing Point Exchange (.vpe) files can't be imported: the format has no public specification, and EffectCraft only implements documented formats"; + +fn import(_: &mut Session, _: &Value) -> Result { + Err(bad("file.importVanishingPoint", WHY)) +} + +pub fn specs() -> Vec { + vec![cmd!( + "file.importVanishingPoint", + "Vanishing Point (.vpe)...", + ["File", "Import"], + None, + "{} (always disabled: undocumented format)", + |_| Err(WHY.into()), + import + )] +} diff --git a/crates/engine/src/commands/vr_editor.rs b/crates/engine/src/commands/vr_editor.rs new file mode 100644 index 000000000..e62281128 --- /dev/null +++ b/crates/engine/src/commands/vr_editor.rs @@ -0,0 +1,167 @@ +//! Window ▸ VR Comp Editor: our own panel over the VR environments Composition ▸ VR ▸ Create VR +//! Environment builds (an equirectangular output comp ← a 3:2 cube map ← six face comps, each +//! with a 90° camera). It lists the environments and edits the 360 view's camera orientation: +//! the six face cameras turn together, so the whole equirectangular output rotates (pan / tilt / +//! roll the viewer's starting direction) without touching the scene. +//! +//! The view orientation is not stored separately: it is read back from the Front camera +//! (`camera = view × face base`), so editing a camera by hand shows up here too. + +use effectcraft_geom::{Mat4, vec3}; +use effectcraft_project::{GroupKind, ItemId, LayerId, LayerSource, Project}; +use serde_json::{Value, json}; + +use super::app_more::{CELLS_3X2, FACES, ae, grouped, orientation_for_axes}; +use super::{CommandSpec, always, bad}; +use crate::{EngineError, Result, Session, cmd, query}; + +/// A VR environment: its output comp, cube map and the face cameras in [`FACES`] order. +pub struct VrEnv { + pub output: ItemId, + pub cube: ItemId, + pub faces: [(ItemId, LayerId); 6], +} + +/// The VR environments of a project. +pub fn environments(p: &Project) -> Vec { + let mut out = vec![]; + for (oid, oc) in p.comps() { + for l in &oc.layers { + let LayerSource::Comp { item: cube } = l.source else { continue }; + let vr = l.effects().is_some_and(|fx| fx.groups().any(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.vr.converter"))); + if !vr { + continue; + } + let Some(cc) = p.comp(cube) else { continue }; + let size = (cc.width / 3).max(1) as f64; + let mut faces: [Option<(ItemId, LayerId)>; 6] = [None; 6]; + for fl in &cc.layers { + let LayerSource::Comp { item: face } = fl.source else { continue }; + let pos = fl.props.prop("transform/position").map(|x| x.value.components()).unwrap_or_default(); + let (Some(x), Some(y)) = (pos.first(), pos.get(1)) else { continue }; + let cell = ((x / size).floor().max(0.0) as u32, (y / size).floor().max(0.0) as u32); + let Some(i) = CELLS_3X2.iter().position(|c| *c == cell) else { continue }; + let cam = p.comp(face).and_then(|fc| fc.layers.iter().find(|x| x.is_camera())).map(|x| x.id); + if let Some(cam) = cam { + faces[i] = Some((face, cam)); + } + } + if faces.iter().all(Option::is_some) { + out.push(VrEnv { output: *oid, cube, faces: faces.map(|f| f.unwrap_or((ItemId(0), LayerId(0)))) }); + } + } + } + out +} + +fn base(i: usize) -> [f64; 3] { + let (_, fw, r, u) = FACES[i]; + orientation_for_axes(ae(r), ae([-u[0], -u[1], -u[2]]), ae(fw)) +} + +fn orient(o: [f64; 3]) -> Mat4 { + Mat4::orientation(vec3(o[0], o[1], o[2])) +} + +fn transpose(m: Mat4) -> Mat4 { + let mut t = Mat4::IDENTITY; + for r in 0..3 { + for c in 0..3 { + t.0[r][c] = m.0[c][r]; + } + } + t +} + +fn norm(a: f64) -> f64 { + let r = a.rem_euclid(360.0); + let r = if (r - 360.0).abs() < 1e-7 { 0.0 } else { r }; + (r * 1e6).round() / 1e6 +} + +/// Orientation angles (X, Y, Z degrees, applied X then Y then Z) of a rotation matrix. +pub fn euler(m: Mat4) -> [f64; 3] { + let m = m.0; + let y = (-m[2][0]).clamp(-1.0, 1.0).asin(); + let (x, z) = if y.cos().abs() > 1e-6 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) }; + [norm(x.to_degrees()), norm(y.to_degrees()), norm(z.to_degrees())] +} + +/// The current view orientation of an environment (from its Front camera). +fn view_of(p: &Project, env: &VrEnv) -> [f64; 3] { + let (fc, cam) = env.faces[4]; + let o = p.comp(fc).and_then(|c| c.layer(cam)).and_then(|l| l.props.prop("transform/orientation")).map(|x| x.value.components()).unwrap_or_default(); + let o = [o.first().copied().unwrap_or(0.0), o.get(1).copied().unwrap_or(0.0), o.get(2).copied().unwrap_or(0.0)]; + euler(orient(o) * transpose(orient(base(4)))) +} + +fn env_json(p: &Project, e: &VrEnv) -> Value { + let name = |i: ItemId| p.item(i).map(|x| x.name.clone()).unwrap_or_default(); + json!({ + "output": e.output.0, "name": name(e.output), "cubeMap": e.cube.0, + "faces": e.faces.iter().enumerate().map(|(i, (c, cam))| json!({"face": FACES[i].0, "comp": c.0, "camera": cam.0})).collect::>(), + "view": view_of(p, e), + }) +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!(environments(&s.project).iter().map(|e| env_json(&s.project, e)).collect::>())) +} + +/// The environment `comp` (its output, cube map or a face comp), else the active comp's, else +/// the only one. +fn env_p(s: &Session, p: &Value, cmd: &str) -> Result { + let envs = environments(&s.project); + let want = p.get("comp").and_then(Value::as_u64).map(ItemId).or(s.state.active_comp); + let hit = |e: &VrEnv, c: ItemId| e.output == c || e.cube == c || e.faces.iter().any(|f| f.0 == c); + let i = want.and_then(|c| envs.iter().position(|e| hit(e, c))).or((envs.len() == 1).then_some(0)); + let i = i.ok_or_else(|| { + bad(cmd, if envs.is_empty() { "no VR environment (Composition ▸ VR ▸ Create VR Environment…)" } else { "give `comp`: a VR environment's output comp" }) + })?; + envs.into_iter().nth(i).ok_or(EngineError::NoComp) +} + +fn set_view(s: &mut Session, p: &Value) -> Result { + const C: &str = "comp.vr.setView"; + let env = env_p(s, p, C)?; + let mut o = view_of(&s.project, &env); + match p.get("orientation") { + Some(Value::Array(a)) => { + for (k, v) in a.iter().take(3).enumerate() { + o[k] = v.as_f64().ok_or_else(|| bad(C, "orientation: [x, y, z] degrees"))?; + } + } + Some(_) => return Err(bad(C, "orientation: [x, y, z] degrees")), + None => {} + } + // Pan (Y), tilt (X) and roll (Z) by name. + for (k, i) in [("tilt", 0), ("pan", 1), ("roll", 2)] { + if let Some(v) = p.get(k).and_then(Value::as_f64) { + o[i] = v; + } + } + let v = orient(o); + grouped(s, "VR View Orientation", |s| { + for (i, (fc, cam)) in env.faces.iter().enumerate() { + let e = euler(v * orient(base(i))); + s.execute("prop.set", json!({"comp": fc.0, "layer": cam.0, "path": "transform/orientation", "value": e}))?; + } + Ok(()) + })?; + Ok(json!({"output": env.output.0, "view": view_of(&s.project, &env)})) +} + +pub fn specs() -> Vec { + vec![ + query!("comp.vr.environments", "VR Environments", "{} → [{output, name, cubeMap, faces: [{face, comp, camera}], view: [x, y, z]}]", list), + cmd!( + "comp.vr.setView", + "VR View Orientation", + [], + None, + "{comp? (an environment's output / cube map / face comp), orientation?: [x, y, z] degrees, pan?, tilt?, roll?} → turns the six face cameras together", + always, + set_view + ), + ] +} diff --git a/crates/engine/src/commands/watch_folder.rs b/crates/engine/src/commands/watch_folder.rs new file mode 100644 index 000000000..b10cf8d50 --- /dev/null +++ b/crates/engine/src/commands/watch_folder.rs @@ -0,0 +1,154 @@ +//! File ▸ Watch Folder…: render farm without a farm. EffectCraft watches a folder for project +//! files (`.ecproj`, at the top level or one folder down, e.g. the folders File ▸ Dependencies ▸ +//! Collect Files writes) whose Render Queue has queued items, renders them, and writes a status +//! file next to each project (`.status.json`: `rendering`, then `done` or `failed`, with +//! every item's status and output path). A project with a status file is not picked up again; +//! delete the status file to render it once more. +//! +//! [`poll`] does one pass and is what the frontends call on a timer while a folder is watched +//! (`file.watchFolder.poll`); it is headless and synchronous, so tests and the CLI use it +//! directly. + +use std::path::{Path, PathBuf}; + +use effectcraft_project::render_queue::RenderStatus; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, str_p}; +use crate::{Result, Session, cmd}; + +/// The status file of a project. +pub fn status_path(project: &Path) -> PathBuf { + let mut s = project.as_os_str().to_owned(); + s.push(".status.json"); + PathBuf::from(s) +} + +/// Projects in `folder` (and its direct subfolders) without a status file, sorted. +pub fn pending(folder: &Path) -> std::io::Result> { + let mut out = vec![]; + let scan = |dir: &Path, out: &mut Vec, subdirs: &mut Vec| -> std::io::Result<()> { + for e in std::fs::read_dir(dir)? { + let p = e?.path(); + if p.is_dir() { + subdirs.push(p); + } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ecproj")) && !status_path(&p).exists() { + out.push(p); + } + } + Ok(()) + }; + let mut subdirs = vec![]; + scan(folder, &mut out, &mut subdirs)?; + for d in subdirs { + let mut ignore = vec![]; + let _ = scan(&d, &mut out, &mut ignore); + } + out.sort(); + Ok(out) +} + +fn write_status(path: &Path, v: &Value) { + let _ = std::fs::write(status_path(path), serde_json::to_vec_pretty(v).unwrap_or_default()); +} + +fn now() -> u64 { + web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// Render one project (in its own session sharing `host`'s services) and write its status. +fn render_project(host: &Session, path: &Path) -> Value { + let name = path.to_string_lossy().to_string(); + write_status(path, &json!({"project": name, "state": "rendering", "started": now()})); + let mut s = Session { services: host.services.clone(), footage: host.footage.clone(), expr: host.expr.clone(), ..Default::default() }; + s.importer = host.importer.clone(); + s.exporter = host.exporter.clone(); + s.accel = host.accel.clone(); + s.prefs = host.prefs.clone(); + let result = (|| -> std::result::Result { + s.execute("file.open", json!({"path": name})).map_err(|e| e.to_string())?; + let queued = s.project.render_queue.iter().filter(|i| i.render && i.status == RenderStatus::Queued).count(); + if queued == 0 { + return Ok(json!({"state": "done", "items": [], "note": "nothing queued"})); + } + s.start_render(true)?; + let items: Vec = s + .project + .render_queue + .iter() + .map(|i| { + json!({ + "comp": s.project.item(i.comp).map(|c| c.name.clone()), + "status": i.status.label(), + "error": if let RenderStatus::Failed(e) = &i.status { Some(e.clone()) } else { None }, + "output": i.last_output, + }) + }) + .collect(); + let failed = s.project.render_queue.iter().any(|i| matches!(i.status, RenderStatus::Failed(_))); + Ok(json!({"state": if failed { "failed" } else { "done" }, "items": items})) + })(); + let mut v = match result { + Ok(v) => v, + Err(e) => json!({"state": "failed", "error": e, "items": []}), + }; + v["project"] = json!(name); + v["finished"] = json!(now()); + write_status(path, &v); + v +} + +/// One pass over `folder`: render every new project; returns their statuses. +pub fn poll(s: &mut Session, folder: &str) -> std::result::Result, String> { + let projects = pending(Path::new(folder)).map_err(|e| format!("cannot read {folder}: {e}"))?; + Ok(projects.iter().map(|p| render_project(s, p)).collect()) +} + +fn watch(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.watchFolder"; + if p.get("stop").and_then(Value::as_bool) == Some(true) { + s.state.watch_folder = None; + s.bump(); + return Ok(json!({"watching": null})); + } + let folder = str_p(p, "folder").or(str_p(p, "path")).ok_or_else(|| bad(C, "missing `folder`"))?.to_string(); + if !Path::new(&folder).is_dir() { + return Err(bad(C, format!("{folder} is not a folder"))); + } + s.state.watch_folder = Some(folder.clone()); + s.bump(); + // The first pass right away. + let done = poll(s, &folder).map_err(|e| bad(C, e))?; + s.toast(format!("Watching {folder}")); + Ok(json!({"watching": folder, "rendered": done})) +} + +fn poll_cmd(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.watchFolder.poll"; + let folder = str_p(p, "folder").map(str::to_string).or_else(|| s.state.watch_folder.clone()).ok_or_else(|| bad(C, "no folder is being watched"))?; + let done = poll(s, &folder).map_err(|e| bad(C, e))?; + Ok(json!({"watching": folder, "rendered": done})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "file.watchFolder", + "Watch Folder...", + ["File"], + None, + "{folder, stop?: bool} → watches the folder for .ecproj files with queued renders, renders them and writes `.status.json`", + always, + watch + ), + cmd!( + "file.watchFolder.poll", + "Poll Watch Folder", + [], + None, + "{folder? (default: the watched one)} → renders new projects now: [{project, state: done|failed, items: [{comp, status, output}]}]", + |s: &Session| if s.state.watch_folder.is_some() { Ok(()) } else { Err("no folder is being watched".into()) }, + poll_cmd + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 71d20f984..b798d5dcb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -24,6 +24,7 @@ pub mod media_cache; pub mod menus; pub mod offload; pub mod prefs; +pub mod preview; pub mod psd_import; pub mod render_queue; pub mod roto; @@ -218,6 +219,10 @@ pub struct EditorState { /// Essential Graphics can expose). #[serde(default)] pub essential_solo: bool, + /// File ▸ Watch Folder: the folder being watched for projects to render (frontends call + /// `file.watchFolder.poll` periodically while it is set). + #[serde(default)] + pub watch_folder: Option, /// The Footage panel: the footage shown, its time and In/Out marks (source time). #[serde(default)] pub footage_panel: Option, @@ -785,12 +790,18 @@ mod tests_effects; #[cfg(test)] mod tests_essential; #[cfg(test)] +mod tests_essential_more; +#[cfg(test)] mod tests_face; #[cfg(test)] mod tests_fidelity; #[cfg(test)] +mod tests_frame_export; +#[cfg(test)] mod tests_lottie; #[cfg(test)] +mod tests_m137; +#[cfg(test)] mod tests_m145; #[cfg(test)] mod tests_markers; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index c286f5b94..b991be9b0 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -505,6 +505,7 @@ File Placeholder... | file.importPlaceholder Solid... | file.importSolid Lottie... | file.importLottie + Vanishing Point (.vpe)... | file.importVanishingPoint Essential Graphics Template... | essential.importTemplate Import Recent Footage @dynamic:recentFootage @@ -528,6 +529,7 @@ File Find Missing Effects | file.findMissing {"what":"effects"} Find Missing Fonts | file.findMissing {"what":"fonts"} Find Missing Footage | file.findMissing {"what":"footage"} + Watch Folder... | file.watchFolder --- Scripts @dynamic:scripts @@ -664,9 +666,12 @@ Composition Audio | playback.audio Save Frame As File... | comp.saveFrameAs + Photoshop Layers... | comp.saveFrameAsPsd + ProEXR... | comp.saveFrameAsExr Pre-render... | render.preRender Save Current Preview... | render.saveCurrentPreview --- + Open in Essential Graphics | comp.openInEssentialGraphics Responsive Design — Time Create Intro | comp.responsiveTime {"op":"intro"} Create Outro | comp.responsiveTime {"op":"outro"} @@ -1165,6 +1170,8 @@ Window Timeline | window.panel {"panel":"timeline"} @dynamic:openViewers --- + Create Nulls From Paths | window.panel {"panel":"createNullsFromPaths"} + VR Comp Editor | window.panel {"panel":"vrCompEditor"} @dynamic:scriptPanels Help EffectCraft Help... | help.docs {"page":"help"} | F1 @@ -1174,6 +1181,9 @@ Help Animation Presets... | anim.browsePresets Keyboard Shortcuts... | app.keyboardShortcuts --- + In-App Tutorials... | help.inAppTutorials + Online Tutorials... | help.onlineTutorials + --- System Compatibility Report... | help.systemReport Enable Logging | help.enableLogging Reveal Logging File | help.revealLogFile diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 8a4846010..8e73c0baf 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -281,6 +281,8 @@ pub struct Prefs { pub recent_presets: Vec, /// Character panel: recently used font families, newest first. pub recent_fonts: Vec, + /// Preview panel: every shortcut's options. + pub preview: crate::preview::PreviewSettings, /// Top-level keys this version doesn't know (kept for newer versions). #[serde(flatten)] pub extra: Extra, @@ -312,6 +314,7 @@ impl Default for Prefs { recent_footage: vec![], recent_presets: vec![], recent_fonts: vec![], + preview: crate::preview::PreviewSettings::default(), extra: Extra::new(), } } @@ -446,6 +449,7 @@ impl Prefs { recent_footage: std::mem::take(&mut self.recent_footage), recent_presets: std::mem::take(&mut self.recent_presets), recent_fonts: std::mem::take(&mut self.recent_fonts), + preview: std::mem::take(&mut self.preview), ..d }; return Ok(()); diff --git a/crates/engine/src/preview.rs b/crates/engine/src/preview.rs new file mode 100644 index 000000000..5b9733ddd --- /dev/null +++ b/crates/engine/src/preview.rs @@ -0,0 +1,550 @@ +//! Preview panel settings (After Effects' Preview panel): five keyboard shortcuts (Spacebar, +//! Shift+Spacebar, Numpad 0, Shift+Numpad 0, Alt+Numpad 0), each with its own saved options — +//! what to include (video, audio, overlays, layer controls), Loop, Cache Before Playback, Range, +//! Play From, Frame Rate, Skip, Resolution, Full Screen, "If caching, play cached frames" and +//! "Move time to preview time". +//! +//! The settings live in [`crate::prefs::Prefs::preview`] (saved with the other settings). +//! [`plan`] turns one shortcut's options, a comp and the current time into the frames to play; +//! the frontend's playback loop follows the plan. +//! +//! Commands: `playback.settings.get {shortcut?}`, `playback.settings.set {shortcut?, values?, +//! : value…, current?}`. + +use effectcraft_project::Comp; +use effectcraft_time::Tick; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always, bad, str_p}; +use crate::{Result, Session, cmd, query}; + +/// A Preview panel shortcut. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewShortcut { + #[default] + Spacebar, + ShiftSpacebar, + Numpad0, + ShiftNumpad0, + AltNumpad0, +} + +impl PreviewShortcut { + pub const ALL: [PreviewShortcut; 5] = [Self::Spacebar, Self::ShiftSpacebar, Self::Numpad0, Self::ShiftNumpad0, Self::AltNumpad0]; + pub fn label(self) -> &'static str { + match self { + Self::Spacebar => "Spacebar", + Self::ShiftSpacebar => "Shift+Spacebar", + Self::Numpad0 => "Numpad 0", + Self::ShiftNumpad0 => "Shift+Numpad 0", + Self::AltNumpad0 => "Alt+Numpad 0", + } + } + pub fn id(self) -> &'static str { + match self { + Self::Spacebar => "spacebar", + Self::ShiftSpacebar => "shiftSpacebar", + Self::Numpad0 => "numpad0", + Self::ShiftNumpad0 => "shiftNumpad0", + Self::AltNumpad0 => "altNumpad0", + } + } + /// From an id (`shiftSpacebar`) or a label (`Shift+Spacebar`). + pub fn parse(s: &str) -> Option { + let k = s.to_ascii_lowercase().replace([' ', '+', '-', '_'], ""); + Self::ALL.into_iter().find(|x| x.id().to_ascii_lowercase() == k || x.label().to_ascii_lowercase().replace([' ', '+'], "") == k) + } +} + +/// Range of a preview. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewRange { + WorkArea, + #[default] + WorkAreaExtended, + EntireDuration, + /// The pre-roll before and post-roll after the current time. + AroundCurrentTime, +} + +impl PreviewRange { + pub const ALL: [PreviewRange; 4] = [Self::WorkArea, Self::WorkAreaExtended, Self::EntireDuration, Self::AroundCurrentTime]; + pub fn label(self) -> &'static str { + match self { + Self::WorkArea => "Work Area", + Self::WorkAreaExtended => "Work Area Extended By Current Time", + Self::EntireDuration => "Entire Duration", + Self::AroundCurrentTime => "Play Around Current Time", + } + } +} + +/// Where playback starts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PlayFrom { + RangeStart, + #[default] + CurrentTime, +} + +impl PlayFrom { + pub const ALL: [PlayFrom; 2] = [Self::RangeStart, Self::CurrentTime]; + pub fn label(self) -> &'static str { + match self { + Self::RangeStart => "Range Start", + Self::CurrentTime => "Current Time", + } + } +} + +/// Resolution of a preview (Auto: the viewer's own). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewResolution { + #[default] + Auto, + Full, + Half, + Third, + Quarter, + /// Every n-th pixel ([`PreviewPreset::custom_resolution`]). + Custom, +} + +impl PreviewResolution { + pub const ALL: [PreviewResolution; 6] = [Self::Auto, Self::Full, Self::Half, Self::Third, Self::Quarter, Self::Custom]; + pub fn label(self) -> &'static str { + match self { + Self::Auto => "Auto", + Self::Full => "Full", + Self::Half => "Half", + Self::Third => "Third", + Self::Quarter => "Quarter", + Self::Custom => "Custom", + } + } + /// Pixel step (1 Full … 4 Quarter, `custom` for Custom); `None` for Auto. + pub fn factor(self, custom: u32) -> Option { + match self { + Self::Auto => None, + Self::Full => Some(1), + Self::Half => Some(2), + Self::Third => Some(3), + Self::Quarter => Some(4), + Self::Custom => Some(custom.clamp(1, 40)), + } + } +} + +/// The options of one shortcut. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PreviewPreset { + pub include_video: bool, + pub include_audio: bool, + /// Grids, guides and safe margins stay visible while playing. + pub include_overlays: bool, + /// Layer handles, paths and motion paths stay visible while playing. + pub include_layer_controls: bool, + #[serde(rename = "loop")] + pub loop_: bool, + /// Render the whole range before playing it. + pub cache_before_playback: bool, + pub range: PreviewRange, + /// Play Around Current Time: seconds before and after the current time. + pub pre_roll: f64, + pub post_roll: f64, + pub play_from: PlayFrom, + /// Frames per second; `None` = Auto (the comp's rate). + pub frame_rate: Option, + /// Frames skipped between the frames played (0 = every frame). + pub skip: u32, + pub resolution: PreviewResolution, + /// Custom resolution: render every n-th pixel (1–40). + pub custom_resolution: u32, + /// Play in a full-screen viewer. + pub full_screen: bool, + /// With Cache Before Playback, pressing the shortcut again while caching plays the frames + /// already cached. + pub play_cached_frames: bool, + /// When the preview stops, the current time moves to the last frame shown (off: it returns + /// to where the preview started). + pub move_time_to_preview_time: bool, +} + +impl Default for PreviewPreset { + fn default() -> Self { + PreviewPreset { + include_video: true, + include_audio: true, + include_overlays: true, + include_layer_controls: false, + loop_: true, + cache_before_playback: false, + range: PreviewRange::WorkAreaExtended, + pre_roll: 2.0, + post_roll: 2.0, + play_from: PlayFrom::CurrentTime, + frame_rate: None, + skip: 0, + resolution: PreviewResolution::Auto, + custom_resolution: 2, + full_screen: false, + play_cached_frames: true, + move_time_to_preview_time: true, + } + } +} + +impl PreviewPreset { + /// The factory options of a shortcut. + pub fn default_for(s: PreviewShortcut) -> Self { + let d = PreviewPreset::default(); + match s { + PreviewShortcut::Spacebar => d, + // A lighter preview: every other frame. + PreviewShortcut::ShiftSpacebar => PreviewPreset { skip: 1, ..d }, + PreviewShortcut::Numpad0 => PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, ..d }, + PreviewShortcut::ShiftNumpad0 => { + PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, skip: 1, resolution: PreviewResolution::Half, ..d } + } + // Audio only. + PreviewShortcut::AltNumpad0 => PreviewPreset { include_video: false, range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, ..d }, + } + } + + fn normalize(&mut self) { + self.pre_roll = if self.pre_roll.is_finite() { self.pre_roll.clamp(0.0, 3600.0) } else { 2.0 }; + self.post_roll = if self.post_roll.is_finite() { self.post_roll.clamp(0.0, 3600.0) } else { 2.0 }; + self.frame_rate = self.frame_rate.filter(|r| r.is_finite() && *r > 0.0).map(|r| r.clamp(0.01, 999.0)); + self.skip = self.skip.min(99); + self.custom_resolution = self.custom_resolution.clamp(1, 40); + if !self.include_video && !self.include_audio { + self.include_video = true; + } + } +} + +/// The Preview panel: the shortcut shown in the panel and every shortcut's options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PreviewSettings { + /// The shortcut whose options the panel shows (and the play button uses). + pub current: PreviewShortcut, + pub spacebar: PreviewPreset, + pub shift_spacebar: PreviewPreset, + pub numpad0: PreviewPreset, + pub shift_numpad0: PreviewPreset, + pub alt_numpad0: PreviewPreset, +} + +impl Default for PreviewSettings { + fn default() -> Self { + use PreviewShortcut as S; + PreviewSettings { + current: S::Spacebar, + spacebar: PreviewPreset::default_for(S::Spacebar), + shift_spacebar: PreviewPreset::default_for(S::ShiftSpacebar), + numpad0: PreviewPreset::default_for(S::Numpad0), + shift_numpad0: PreviewPreset::default_for(S::ShiftNumpad0), + alt_numpad0: PreviewPreset::default_for(S::AltNumpad0), + } + } +} + +impl PreviewSettings { + pub fn get(&self, s: PreviewShortcut) -> &PreviewPreset { + match s { + PreviewShortcut::Spacebar => &self.spacebar, + PreviewShortcut::ShiftSpacebar => &self.shift_spacebar, + PreviewShortcut::Numpad0 => &self.numpad0, + PreviewShortcut::ShiftNumpad0 => &self.shift_numpad0, + PreviewShortcut::AltNumpad0 => &self.alt_numpad0, + } + } + pub fn get_mut(&mut self, s: PreviewShortcut) -> &mut PreviewPreset { + match s { + PreviewShortcut::Spacebar => &mut self.spacebar, + PreviewShortcut::ShiftSpacebar => &mut self.shift_spacebar, + PreviewShortcut::Numpad0 => &mut self.numpad0, + PreviewShortcut::ShiftNumpad0 => &mut self.shift_numpad0, + PreviewShortcut::AltNumpad0 => &mut self.alt_numpad0, + } + } + /// The options of the shortcut shown in the panel. + pub fn active(&self) -> &PreviewPreset { + self.get(self.current) + } +} + +/// What a preview plays: frames `start..=end` (comp frames), starting at `first`, every +/// `step`-th frame, `fps` frames shown per second. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PreviewPlan { + pub start: i64, + pub end: i64, + pub first: i64, + pub step: i64, + /// Comp frames advanced per second of playback (the frame rate; Skip doesn't change speed). + pub fps: f64, + pub looping: bool, + pub cache_first: bool, + pub video: bool, + pub audio: bool, + /// Where the current time goes when the preview stops without moving it. + pub origin: Tick, +} + +impl PreviewPlan { + /// The frame shown `elapsed` seconds after the start; `None` once a non-looping preview has + /// played to its end. + pub fn frame_at(&self, elapsed: f64) -> Option { + let n = ((elapsed.max(0.0) * self.fps) / self.step as f64).floor() as i64; + self.frame_after(self.first, n) + } + + /// The frame `n` played frames after `from` (wrapping when looping). + pub fn frame_after(&self, from: i64, n: i64) -> Option { + let f = from + n * self.step; + if f <= self.end { + return Some(f); + } + if !self.looping { + return None; + } + // Loop back to the range start on the skip grid. + let len = ((self.end - self.start) / self.step + 1).max(1); + let past = (f - self.end - 1) / self.step; + Some(self.start + past.rem_euclid(len) * self.step) + } + + /// Every frame the preview shows, in range order. + pub fn frames(&self) -> impl Iterator + '_ { + (self.start..=self.end).step_by(self.step.max(1) as usize) + } +} + +/// The plan of a preview of `comp` with options `p`, started at comp time `now`. +pub fn plan(p: &PreviewPreset, comp: &Comp, now: Tick) -> PreviewPlan { + let fr = comp.frame_rate; + let last = fr.frame_at(comp.duration - comp.frame_duration()).max(0); + let cur = fr.frame_at(now).clamp(0, last); + let (wa, wb) = (fr.frame_at(comp.work_area.0), fr.frame_at(comp.work_area.1 - comp.frame_duration())); + let (wa, wb) = (wa.clamp(0, last), wb.clamp(0, last).max(wa.clamp(0, last))); + let (start, end) = match p.range { + PreviewRange::WorkArea => (wa, wb), + PreviewRange::WorkAreaExtended => (wa.min(cur), wb.max(cur)), + PreviewRange::EntireDuration => (0, last), + PreviewRange::AroundCurrentTime => { + let pre = (p.pre_roll * fr.as_f64()).round() as i64; + let post = (p.post_roll * fr.as_f64()).round() as i64; + ((cur - pre).max(0), (cur + post).min(last)) + } + }; + let step = p.skip as i64 + 1; + let first = match p.play_from { + PlayFrom::RangeStart => start, + // From the current time when it is inside the range (and not on its last frame). + PlayFrom::CurrentTime if cur >= start && cur < end => start + (cur - start) / step * step, + PlayFrom::CurrentTime => start, + }; + PreviewPlan { + start, + end, + first, + step, + fps: p.frame_rate.unwrap_or_else(|| fr.as_f64()), + looping: p.loop_, + cache_first: p.cache_before_playback, + video: p.include_video, + audio: p.include_audio, + origin: now, + } +} + +fn shortcut_p(s: &Session, p: &Value, cmd: &str) -> Result { + match str_p(p, "shortcut") { + Some(k) => { + PreviewShortcut::parse(k).ok_or_else(|| bad(cmd, format!("unknown shortcut `{k}` (spacebar|shiftSpacebar|numpad0|shiftNumpad0|altNumpad0)"))) + } + None => Ok(s.prefs.preview.current), + } +} + +fn get(s: &mut Session, p: &Value) -> Result { + let sc = shortcut_p(s, p, "playback.settings.get")?; + let mut out = serde_json::to_value(s.prefs.preview.get(sc)).map_err(|e| bad("playback.settings.get", e.to_string()))?; + out["shortcut"] = json!(sc.id()); + out["current"] = json!(s.prefs.preview.current.id()); + if let Some(c) = s.active_comp() { + let pl = plan(s.prefs.preview.get(sc), c, s.time()); + out["plan"] = json!({"start": pl.start, "end": pl.end, "first": pl.first, "step": pl.step, "fps": pl.fps}); + } + Ok(out) +} + +fn set(s: &mut Session, p: &Value) -> Result { + let c = "playback.settings.set"; + let sc = shortcut_p(s, p, c)?; + let mut fields = serde_json::Map::new(); + if let Some(Value::Object(m)) = p.get("values") { + fields.extend(m.clone()); + } + if let Value::Object(m) = p { + for (k, v) in m { + if !matches!(k.as_str(), "shortcut" | "values" | "current" | "reset") { + fields.insert(k.clone(), v.clone()); + } + } + } + let mut next = s.prefs.preview.clone(); + if p.get("reset").and_then(Value::as_bool) == Some(true) { + *next.get_mut(sc) = PreviewPreset::default_for(sc); + } + let mut cur = serde_json::to_value(next.get(sc)).map_err(|e| bad(c, e.to_string()))?; + for (k, v) in fields { + if cur.get(&k).is_none() { + return Err(bad(c, format!("unknown preview setting `{k}`"))); + } + // Frame rate: a number, or "auto"/null. + let v = if k == "frameRate" && (v.is_null() || v.as_str().is_some_and(|x| x.eq_ignore_ascii_case("auto"))) { Value::Null } else { v }; + cur[k.as_str()] = v; + } + let mut pr: PreviewPreset = serde_json::from_value(cur).map_err(|e| bad(c, e.to_string()))?; + pr.normalize(); + *next.get_mut(sc) = pr; + if let Some(k) = str_p(p, "current") { + next.current = PreviewShortcut::parse(k).ok_or_else(|| bad(c, format!("unknown shortcut `{k}`")))?; + } else if p.get("shortcut").is_some() && p.as_object().is_some_and(|m| m.len() == 1) { + // `{shortcut}` alone shows that shortcut in the panel. + next.current = sc; + } + s.prefs.preview = next; + s.save_prefs(); + get(s, &json!({"shortcut": sc.id()})) +} + +pub fn specs() -> Vec { + vec![ + query!( + "playback.settings.get", + "Preview Settings", + "{shortcut?: spacebar|shiftSpacebar|numpad0|shiftNumpad0|altNumpad0 (default: the one shown in the Preview panel)} → options + plan {start, end, first, step, fps}", + get + ), + cmd!( + "playback.settings.set", + "Change Preview Settings", + [], + None, + "{shortcut?, current?: shortcut shown in the panel, reset?: bool, values?: {…}, includeVideo?, includeAudio?, includeOverlays?, includeLayerControls?, loop?, cacheBeforePlayback?, range?: workArea|workAreaExtended|entireDuration|aroundCurrentTime, preRoll?, postRoll?: seconds, playFrom?: rangeStart|currentTime, frameRate?: fps|\"auto\", skip?, resolution?: auto|full|half|third|quarter|custom, customResolution?, fullScreen?, playCachedFrames?, moveTimeToPreviewTime?}", + always, + set + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Session; + + fn comp_session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 320, "height": 180, "frameRate": 24, "duration": 10})).unwrap(); + s + } + + #[test] + fn ranges_and_play_from() { + let mut s = comp_session(); + let mut c = s.active_comp().unwrap().clone(); + c.work_area = (Tick::from_seconds_f64(2.0), Tick::from_seconds_f64(4.0)); + let at = |sec: f64| Tick::from_seconds_f64(sec); + let mut p = PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, ..Default::default() }; + let pl = plan(&p, &c, at(1.0)); + assert_eq!((pl.start, pl.end, pl.first), (48, 95, 48)); + // Extended by the current time (before the work area). + p.range = PreviewRange::WorkAreaExtended; + let pl = plan(&p, &c, at(1.0)); + assert_eq!((pl.start, pl.end), (24, 95)); + // ... and after it. + let pl = plan(&p, &c, at(6.0)); + assert_eq!((pl.start, pl.end), (48, 144)); + p.range = PreviewRange::EntireDuration; + p.play_from = PlayFrom::CurrentTime; + let pl = plan(&p, &c, at(5.0)); + assert_eq!((pl.start, pl.end, pl.first), (0, 239, 120)); + p.range = PreviewRange::AroundCurrentTime; + p.pre_roll = 1.0; + p.post_roll = 0.5; + let pl = plan(&p, &c, at(5.0)); + assert_eq!((pl.start, pl.end, pl.first), (96, 132, 120)); + // Pre-roll clamps at the comp start. + let pl = plan(&p, &c, at(0.25)); + assert_eq!(pl.start, 0); + s.execute("playback.settings.set", json!({"range": "workArea"})).unwrap(); + } + + #[test] + fn skip_rate_and_loop() { + let s = comp_session(); + let c = s.active_comp().unwrap().clone(); + let p = PreviewPreset { range: PreviewRange::EntireDuration, play_from: PlayFrom::RangeStart, skip: 2, frame_rate: Some(12.0), ..Default::default() }; + let pl = plan(&p, &c, Tick::ZERO); + assert_eq!(pl.step, 3); + assert_eq!(pl.fps, 12.0); + // 12 comp frames per second, every third shown. + assert_eq!(pl.frame_at(0.0), Some(0)); + assert_eq!(pl.frame_at(0.26), Some(3)); + assert_eq!(pl.frame_at(1.0), Some(12)); + assert!(pl.frames().all(|f| f % 3 == 0)); + // Loop wraps onto the skip grid; no loop ends. + assert_eq!(pl.frame_after(237, 1), Some(0)); + let pl = PreviewPlan { looping: false, ..pl }; + assert_eq!(pl.frame_after(237, 1), None); + } + + #[test] + fn settings_commands_round_trip() { + let mut s = comp_session(); + let r = s.execute("playback.settings.get", json!({})).unwrap(); + assert_eq!(r["shortcut"], "spacebar"); + assert_eq!(r["range"], "workAreaExtended"); + // Each shortcut keeps its own options. + let r = s + .execute("playback.settings.set", json!({"shortcut": "shiftNumpad0", "skip": 3, "frameRate": 15, "resolution": "quarter", "loop": false})) + .unwrap(); + assert_eq!(r["skip"], 3); + assert_eq!(r["frameRate"], 15.0); + assert_eq!(r["plan"]["step"], 4); + let sp = s.execute("playback.settings.get", json!({"shortcut": "Spacebar"})).unwrap(); + assert_eq!(sp["skip"], 0); + assert_eq!(sp["frameRate"], Value::Null); + // Auto rate, panel shortcut, reset. + s.execute("playback.settings.set", json!({"shortcut": "shiftNumpad0", "frameRate": "auto", "current": "shiftNumpad0"})).unwrap(); + assert_eq!(s.prefs.preview.current, PreviewShortcut::ShiftNumpad0); + assert_eq!(s.prefs.preview.shift_numpad0.frame_rate, None); + assert!(s.execute("playback.settings.set", json!({"bogus": 1})).is_err()); + assert!(s.execute("playback.settings.set", json!({"shortcut": "f13"})).is_err()); + s.execute("playback.settings.set", json!({"shortcut": "shiftNumpad0", "reset": true})).unwrap(); + assert_eq!(s.prefs.preview.shift_numpad0, PreviewPreset::default_for(PreviewShortcut::ShiftNumpad0)); + // Persisted with the settings. + let v: Value = serde_json::from_str(&s.prefs.to_json()).unwrap(); + assert_eq!(v["preview"]["current"], "shiftNumpad0"); + let back: crate::prefs::Prefs = serde_json::from_value(v).unwrap(); + assert_eq!(back.preview, s.prefs.preview); + } + + #[test] + fn shortcut_names() { + for s in PreviewShortcut::ALL { + assert_eq!(PreviewShortcut::parse(s.id()), Some(s)); + assert_eq!(PreviewShortcut::parse(s.label()), Some(s)); + } + } +} diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index 555dd97b2..efd63a9d6 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -158,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(), embedded: None }), + layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None, placed: false }), ..Default::default() }; let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); @@ -191,8 +191,20 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz 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 (cw, ch) = 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(); + // Perspective quads and warps are baked over the placed bounds, at up to 4× the document + // resolution when the embedded content has that much detail. + let bake = so.needs_bake(); + let (w, h, k, bounds) = if bake { + let bb = so.placed_bounds(cw as f64, ch as f64); + let area = ((bb[2] - bb[0]) * (bb[3] - bb[1])).max(1.0); + let k = ((cw as f64 * ch as f64) / area).sqrt().clamp(1.0, 4.0); + let (w, h) = so.placed_size(cw as f64, ch as f64, k); + (w, h, k, bb) + } else { + (cw, ch, 1.0, [0.0; 4]) + }; let f = Footage { path: cx.path.to_string(), kind: FootageKind::Still, @@ -204,13 +216,20 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz 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()) }), + layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()), placed: bake }), ..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); + if bake { + // The baked pixels cover the placed bounds: centred there, scaled back to 1×. + let c = [bounds[0] + w as f64 / k / 2.0, bounds[1] + h as f64 / k / 2.0]; + set_vec2(&mut l, "transform/position", c); + set_vec2(&mut l, "transform/scale", [100.0 / k, 100.0 / k]); + return Some(l); + } 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]); diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 2a76ba04c..1b88bce74 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -112,9 +112,103 @@ pub struct Widget { pub handlers: Vec, /// The active tab of a tabbed panel (child index). pub active_tab: usize, + /// What the control's `onDraw` handler painted (ScriptUIGraphics), in control coordinates. + /// Frontends paint it instead of the control's default look, unless it contains + /// [`DrawOp::Os`] (`drawOSControl()`), which draws the default look first. + pub draw: Vec, pub children: Vec, } +/// One segment of a ScriptUIGraphics path (control coordinates, pixels). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "k")] +pub enum PathSeg { + /// `moveTo(x, y)`: starts a new subpath. + M { x: f64, y: f64 }, + /// `lineTo(x, y)`. + L { x: f64, y: f64 }, + /// `rectPath(x, y, w, h)`: a closed rectangle subpath. + R { x: f64, y: f64, w: f64, h: f64 }, + /// `ellipsePath(x, y, w, h)`: a closed ellipse inscribed in the rectangle. + E { x: f64, y: f64, w: f64, h: f64 }, + /// `closePath()`. + Z, +} + +/// One paint call of an `onDraw` handler. Colours are straight RGBA in 0..1; `None` is a theme +/// colour (a brush or pen made from a theme colour name). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "camelCase")] +pub enum DrawOp { + /// `fillPath(brush, path)`. + Fill { color: Option<[f32; 4]>, path: Vec }, + /// `strokePath(pen, path)`. + Stroke { color: Option<[f32; 4]>, width: f64, path: Vec }, + /// `drawString(text, pen, x, y, font)`: `(x, y)` is the text's top-left. + Text { + text: String, + color: Option<[f32; 4]>, + x: f64, + y: f64, + size: f64, + #[serde(default)] + style: String, + }, + /// `drawOSControl()`: the control's default look. + Os, + /// `drawImage(image, x, y, w?, h?)` (drawn as a placeholder frame). + Image { x: f64, y: f64, w: f64, h: f64 }, +} + +impl PathSeg { + /// The subpaths of `path` as polylines (ellipses flattened to `n` segments), each with + /// whether it is closed. + pub fn polylines(path: &[PathSeg], n: usize) -> Vec<(Vec<[f64; 2]>, bool)> { + fn flush(cur: &mut Vec<[f64; 2]>, out: &mut Vec<(Vec<[f64; 2]>, bool)>, closed: bool) { + if cur.len() > 1 { + out.push((std::mem::take(cur), closed)); + } else { + cur.clear(); + } + } + let mut out = vec![]; + let mut cur: Vec<[f64; 2]> = vec![]; + for seg in path { + match *seg { + PathSeg::M { x, y } => { + flush(&mut cur, &mut out, false); + cur.push([x, y]); + } + PathSeg::L { x, y } => cur.push([x, y]), + PathSeg::Z => { + let start = cur.first().copied(); + flush(&mut cur, &mut out, true); + // Drawing continues from the subpath's start. + cur.extend(start); + } + PathSeg::R { x, y, w, h } => { + flush(&mut cur, &mut out, false); + out.push((vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]], true)); + } + PathSeg::E { x, y, w, h } => { + flush(&mut cur, &mut out, false); + let (cx, cy, rx, ry) = (x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0); + let n = n.max(8); + let pts = (0..n) + .map(|i| { + let a = i as f64 / n as f64 * std::f64::consts::TAU; + [cx + rx * a.cos(), cy + ry * a.sin()] + }) + .collect(); + out.push((pts, true)); + } + } + } + flush(&mut cur, &mut out, false); + out + } +} + impl Widget { pub fn find(&self, id: u32) -> Option<&Widget> { if self.id == id { @@ -493,7 +587,8 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result { let i = w.items.iter().position(|it| it == t).ok_or_else(|| crate::commands::bad(c, format!("no item `{t}` (items: {:?})", w.items)))?; value = json!(i); } - dispatch(s, ScriptUiEvent { window, widget, kind: "change".into(), value }) + let kind = if p.get("changing").and_then(Value::as_bool) == Some(true) { "changing" } else { "change" }; + dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value }) } /// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel). @@ -557,6 +652,31 @@ mod tests { assert_eq!(win.root.lookup(&json!(1)).unwrap().id, 1); } + #[test] + fn draw_lists_flatten_to_polylines_and_round_trip() { + let path = vec![ + PathSeg::M { x: 0.0, y: 0.0 }, + PathSeg::L { x: 10.0, y: 0.0 }, + PathSeg::L { x: 10.0, y: 10.0 }, + PathSeg::Z, + PathSeg::R { x: 1.0, y: 2.0, w: 3.0, h: 4.0 }, + PathSeg::E { x: 0.0, y: 0.0, w: 20.0, h: 10.0 }, + ]; + let lines = PathSeg::polylines(&path, 16); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], (vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], true)); + assert_eq!(lines[1].0[2], [4.0, 6.0]); + assert_eq!(lines[2].0.len(), 16); + assert!((lines[2].0[0][0] - 20.0).abs() < 1e-9 && (lines[2].0[4][1] - 10.0).abs() < 1e-9); + let op = DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path }; + let j = serde_json::to_value(&op).unwrap(); + assert_eq!(j["op"], "fill"); + assert_eq!(j["path"][0], json!({"k": "M", "x": 0.0, "y": 0.0})); + assert_eq!(serde_json::from_value::(j).unwrap(), op); + let t: DrawOp = serde_json::from_value(json!({"op": "text", "text": "Hi", "color": null, "x": 1, "y": 2, "size": 14})).unwrap(); + assert!(matches!(t, DrawOp::Text { size, .. } if size == 14.0)); + } + #[test] fn publish_replaces_a_hosts_windows() { let mut ui = ScriptUi::default(); diff --git a/crates/engine/src/tests_essential_more.rs b/crates/engine/src/tests_essential_more.rs new file mode 100644 index 000000000..314f7b6d0 --- /dev/null +++ b/crates/engine/src/tests_essential_more.rs @@ -0,0 +1,94 @@ +//! Essential Graphics Font and uniform Scale controls, Composition ▸ Open in Essential Graphics. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::essential::{self, ControlType}; +use serde_json::json; + +use crate::{Event, Session}; + +#[test] +fn font_and_scale_controls_override_per_instance() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let title = s.active_comp_id().unwrap(); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + // Source Text: as text and as font; Scale as one slider. + let t = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap(); + let f = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap(); + let sc = s.execute("essential.addProperty", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["controls"][0].as_u64().unwrap(); + // Adding the same font control again is a no-op; opacity can't be a font. + assert!( + s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"].as_array().unwrap().is_empty() + ); + assert!(s.execute("essential.addProperty", json!({"layer": text, "path": "transform/opacity", "as": "font"})).is_err()); + assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/opacity", "as": "font"})).unwrap()["ok"], false); + assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["type"], "scale"); + let l = s.execute("essential.list", json!({})).unwrap(); + let types: Vec<&str> = l["controls"].as_array().unwrap().iter().map(|c| c["type"].as_str().unwrap()).collect(); + assert_eq!(types, ["text", "font", "scale"]); + assert!(l["controls"][1]["value"]["size"].as_f64().unwrap() > 0.0); + let eg = s.project.comp(title).unwrap().essential.clone().unwrap(); + assert_eq!(eg.find(f).unwrap().as_type, Some(ControlType::Font)); + // Survives save / load (serde). + let j = serde_json::to_value(&eg).unwrap(); + assert_eq!(serde_json::from_value::(j).unwrap(), eg); + + // An instance: change the text, then the font and the scale. + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [inst]})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": t, "value": "Bye"})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": f, "value": {"font": "Serif Test", "size": 40}})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": sc, "value": 50})).unwrap(); + let i = s.execute("essential.instance", json!({"layer": inst})).unwrap(); + assert_eq!(i["controls"][1]["value"]["font"], "Serif Test"); + assert_eq!(i["controls"][1]["value"]["size"], 40.0); + assert_eq!(i["controls"][1]["overridden"], true); + assert_eq!(i["controls"][2]["type"], "scale"); + assert_eq!(i["controls"][2]["value"][0], 50.0); + assert_eq!(i["controls"][2]["value"][1], 50.0); + // What the instance renders: the overridden text with the overridden font, scaled. + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(inst)).unwrap().clone(); + let over: Vec = { + let g = essential::group(&layer).unwrap(); + let mut v = vec![]; + g.walk("", &mut |_, p| { + if let Some(c) = essential::control_of(&p.match_id) { + v.push(essential::Override { control: c, value: p.value.clone() }); + } + }); + v + }; + let p = essential::with_overrides(&s.project, title, &over).unwrap(); + let src = p.comp(title).unwrap().layer(effectcraft_project::LayerId(text)).unwrap(); + match src.props.prop("text/sourceText").map(|x| &x.value) { + Some(KV::Text(d)) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)), + o => panic!("{o:?}"), + } + // Push to Comp with only the font: the source keeps its own text. + s.execute("essential.revert", json!({"layer": inst, "control": t})).unwrap(); + s.execute("essential.pushToComp", json!({"layer": inst, "control": f})).unwrap(); + let srcl = s.project.comp(title).unwrap().layer(effectcraft_project::LayerId(text)).unwrap(); + match srcl.props.prop("text/sourceText").map(|x| &x.value) { + Some(KV::Text(d)) => assert_eq!((d.text.as_str(), d.font.as_str()), ("Hello", "Serif Test")), + o => panic!("{o:?}"), + } +} + +#[test] +fn open_in_essential_graphics_sets_the_primary_comp() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 1})).unwrap(); + let a = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "B", "width": 32, "height": 32, "duration": 1})).unwrap(); + s.drain_events(); + let r = s.execute("comp.openInEssentialGraphics", json!({"comp": a.0})).unwrap(); + assert_eq!(r["name"], "A"); + assert_eq!(s.state.essential_primary, Some(a)); + assert!( + s.drain_events() + .iter() + .any(|e| matches!(e, Event::Frontend { command, params } if command == "window.panel" && params["panel"] == "essentialGraphics")) + ); + assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A"); +} diff --git a/crates/engine/src/tests_frame_export.rs b/crates/engine/src/tests_frame_export.rs new file mode 100644 index 000000000..4866619de --- /dev/null +++ b/crates/engine/src/tests_frame_export.rs @@ -0,0 +1,78 @@ +//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR…. + +use serde_json::json; + +use crate::Session; + +fn out_dir(name: &str) -> std::path::PathBuf { + let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A 32×32 comp: a red full-frame solid under a 16×16 blue Multiply solid at 50%. +fn frame() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Frame", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000"})).unwrap(); + let blue = s.execute("layer.newSolid", json!({"name": "Blue.Box", "color": "#0000ff", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setBlendMode", json!({"layers": [blue], "mode": "Multiply"})).unwrap(); + s.execute("prop.set", json!({"layer": blue, "path": "transform/opacity", "value": 50})).unwrap(); + s +} + +#[test] +fn photoshop_layers_writes_one_psd_layer_per_comp_layer() { + let mut s = frame(); + let dir = out_dir("frame-psd"); + let path = dir.join("frame.psd").to_string_lossy().to_string(); + let r = s.execute("comp.saveFrameAsPsd", json!({"path": path})).unwrap(); + assert_eq!(r["layers"], 2); + let psd = effectcraft_psd::Psd::parse(std::fs::read(&path).unwrap()).unwrap(); + assert_eq!((psd.width, psd.height), (32, 32)); + let names: Vec<&str> = psd.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Red", "Blue.Box"], "bottom first"); + let blue = &psd.layers[1]; + assert_eq!(blue.blend_key(), "mul "); + assert_eq!(blue.opacity, 128); + // Cropped to its pixels (16×16, centred) and rendered at full opacity. + assert_eq!((blue.rect.left, blue.rect.top, blue.rect.width(), blue.rect.height()), (8, 8, 16, 16)); + assert_eq!(psd.layers[0].blend_key(), "norm"); + assert_eq!(psd.layers[0].opacity, 255); + // The merged image is the comp frame: red × (50% blue multiply) in the middle. + let m = psd.composite().unwrap(); + let mid = m.data[16 * 32 + 16]; + assert!(mid[0] > 0.4 && mid[0] < 0.6 && mid[2] < 0.05, "{mid:?}"); + let corner = m.data[0]; + assert!(corner[0] > 0.99 && corner[2] < 0.01, "{corner:?}"); + // The menu has it. + assert!(crate::commands::find("comp.saveFrameAsPsd").is_some()); +} + +#[test] +fn proexr_writes_layer_prefixed_channels_and_the_composite() { + use exr::prelude::*; + let mut s = frame(); + let dir = out_dir("frame-exr"); + let path = dir.join("frame.exr").to_string_lossy().to_string(); + let r = s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap(); + let names: Vec<&str> = r["channels"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect(); + assert_eq!(names.len(), 12); + assert!(names.contains(&"R") && names.contains(&"A") && names.contains(&"Red.R") && names.contains(&"Blue_Box.B"), "{names:?}"); + let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_file(&path).unwrap(); + let layer = &img.layer_data[0]; + assert_eq!((layer.size.0, layer.size.1), (32, 32)); + let ch = |n: &str| layer.channel_data.list.iter().find(|c| c.name.to_string() == n).unwrap_or_else(|| panic!("no channel {n}")); + let at = |n: &str, x: usize, y: usize| match &ch(n).sample_data { + FlatSamples::F32(v) => v[y * 32 + x], + o => panic!("{o:?}"), + }; + // The blue layer alone: blue inside its box, transparent outside. + assert!((at("Blue_Box.B", 16, 16) - 1.0).abs() < 1e-3); + assert_eq!(at("Blue_Box.A", 0, 0), 0.0); + assert!((at("Red.R", 0, 0) - 1.0).abs() < 1e-3); + // Composite: opaque everywhere. + assert!((at("A", 16, 16) - 1.0).abs() < 1e-3); + assert!(at("R", 16, 16) < 0.5, "multiplied by 50% blue (linear)"); +} diff --git a/crates/engine/src/tests_m137.rs b/crates/engine/src/tests_m137.rs new file mode 100644 index 000000000..13abe9c64 --- /dev/null +++ b/crates/engine/src/tests_m137.rs @@ -0,0 +1,167 @@ +//! M13.7 menu items: File ▸ Watch Folder, Import ▸ Vanishing Point (disabled), Create Nulls From +//! Paths, VR Comp Editor and Help ▸ In-App / Online Tutorials. + +use std::sync::Arc; + +use serde_json::json; + +use crate::rq_tests::MockExporter; +use crate::{Event, Session}; + +fn out_dir(name: &str) -> std::path::PathBuf { + let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn watch_folder_renders_queued_projects_and_writes_status() { + let dir = out_dir("watch-folder"); + let ex = Arc::new(MockExporter { log: Default::default() }); + // A project with a queued render, and one (in a subfolder) with nothing queued. + let mut a = Session { exporter: Some(ex.clone()), ..Default::default() }; + a.execute("comp.new", json!({"name": "Shot", "width": 32, "height": 32, "frameRate": 10, "duration": 0.5})).unwrap(); + a.execute("renderQueue.add", json!({})).unwrap(); + let pa = dir.join("a.ecproj"); + a.execute("file.saveAs", json!({"path": pa.to_string_lossy()})).unwrap(); + std::fs::create_dir_all(dir.join("collected")).unwrap(); + let mut b = Session::default(); + b.execute("comp.new", json!({"name": "Idle", "width": 32, "height": 32, "duration": 1})).unwrap(); + let pb = dir.join("collected/b.ecproj"); + b.execute("file.saveAs", json!({"path": pb.to_string_lossy()})).unwrap(); + + let mut host = Session { exporter: Some(ex.clone()), ..Default::default() }; + assert!(!host.is_enabled("file.watchFolder.poll")); + let r = host.execute("file.watchFolder", json!({"folder": dir.to_string_lossy()})).unwrap(); + let done = r["rendered"].as_array().unwrap(); + assert_eq!(done.len(), 2, "{r}"); + assert_eq!(host.state.watch_folder.as_deref(), Some(dir.to_string_lossy().as_ref())); + let st: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("a.ecproj.status.json")).unwrap()).unwrap(); + assert_eq!(st["state"], "done", "{st}"); + assert_eq!(st["items"][0]["comp"], "Shot"); + assert_eq!(st["items"][0]["status"], "Done"); + assert_eq!(ex.log.lock().unwrap().len(), 1); + let st: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("collected/b.ecproj.status.json")).unwrap()).unwrap(); + assert_eq!(st["note"], "nothing queued"); + // Rendered projects are not picked up again; deleting the status file re-renders. + assert!(host.execute("file.watchFolder.poll", json!({})).unwrap()["rendered"].as_array().unwrap().is_empty()); + std::fs::remove_file(dir.join("a.ecproj.status.json")).unwrap(); + assert_eq!(host.execute("file.watchFolder.poll", json!({})).unwrap()["rendered"].as_array().unwrap().len(), 1); + assert_eq!(ex.log.lock().unwrap().len(), 2); + // A broken project fails with a status, not an error. + std::fs::write(dir.join("broken.ecproj"), b"not a project").unwrap(); + let r = host.execute("file.watchFolder.poll", json!({})).unwrap(); + assert_eq!(r["rendered"][0]["state"], "failed"); + host.execute("file.watchFolder", json!({"stop": true})).unwrap(); + assert!(host.state.watch_folder.is_none()); + assert!(host.execute("file.watchFolder", json!({"folder": "/no/such/folder"})).is_err()); +} + +#[test] +fn vanishing_point_import_is_disabled_with_a_reason() { + let mut s = Session::default(); + assert!(!s.is_enabled("file.importVanishingPoint")); + let spec = crate::commands::find("file.importVanishingPoint").unwrap(); + assert!((spec.enabled)(&s).unwrap_err().contains("public specification")); + assert!(s.execute("file.importVanishingPoint", json!({})).is_err()); +} + +#[test] +fn help_tutorials() { + let mut s = Session::default(); + s.drain_events(); + s.execute("help.onlineTutorials", json!({})).unwrap(); + s.execute("help.inAppTutorials", json!({})).unwrap(); + let ev = s.drain_events(); + assert!(ev.iter().any(|e| matches!(e, Event::OpenUrl(u) if u == crate::links::APP_PAGE))); + assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "help.inAppTutorials"))); +} + +/// A comp with a 2D solid at (50, 40) carrying a square mask. +fn masked() -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap(); + (s, l) +} + +#[test] +fn nulls_follow_points_and_points_follow_nulls() { + let (mut s, l) = masked(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + let r = s.execute("paths.nullsFollowPoints", json!({})).unwrap(); + let nulls: Vec = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(nulls.len(), 4); + let comp = s.active_comp().unwrap().clone(); + let n2 = comp.layer(effectcraft_project::LayerId(nulls[2])).unwrap(); + assert_eq!(n2.name, "Plate: Mask 1 [3]"); + // Placed on the vertex in comp space (the 40×40 solid is centred at (50, 40)). + let pos = n2.props.prop("transform/position").unwrap(); + assert_eq!(&pos.value.components()[..2], &[70.0, 60.0]); + let e = pos.expr.as_ref().unwrap().text.clone(); + assert!(e.contains("thisComp.layer(\"Plate\")") && e.contains(".points()[2]"), "{e}"); + // One undo step. + assert!(s.undo()); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + + let r = s.execute("paths.pointsFollowNulls", json!({"layer": l, "path": "masks/#1/path"})).unwrap(); + assert_eq!(r["nulls"].as_array().unwrap().len(), 4); + let comp = s.active_comp().unwrap().clone(); + let plate = comp.layer(effectcraft_project::LayerId(l)).unwrap(); + let e = plate.props.prop("masks/#1/path").unwrap().expr.as_ref().unwrap().text.clone(); + assert!(e.contains("createPath(pts") && e.contains("\"Plate: Mask 1 [1]\"") && e.contains("fromComp"), "{e}"); + // Not a path → error. + assert!(s.execute("paths.nullsFollowPoints", json!({"layer": l, "path": "transform/opacity"})).is_err()); +} + +#[test] +fn trace_path_adds_a_progress_null() { + let (mut s, l) = masked(); + let r = s.execute("paths.tracePath", json!({"layer": l, "loop": true})).unwrap(); + let n = r["null"].as_u64().unwrap(); + let comp = s.active_comp().unwrap().clone(); + let null = comp.layer(effectcraft_project::LayerId(n)).unwrap(); + let slider = null.props.prop("effects/#1/slider").unwrap(); + assert_eq!(slider.keys.len(), 2); + assert_eq!(slider.keys[1].value.as_f64(), 100.0); + assert!(slider.expr.as_ref().unwrap().text.contains("loopOut")); + assert_eq!(null.props.group("effects/#1").unwrap().name, "Progress"); + assert!(null.props.prop("transform/position").unwrap().expr.as_ref().unwrap().text.contains("pointOnPath(effect(\"Progress\")(\"Slider\") / 100)")); +} + +#[test] +fn vr_comp_editor_turns_the_face_cameras_together() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 64, "height": 64, "frameRate": 10, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Wall", "color": "#ff0000"})).unwrap(); + s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap(); + let envs = s.execute("comp.vr.environments", json!({})).unwrap(); + assert_eq!(envs.as_array().unwrap().len(), 1, "{envs}"); + let env = &envs[0]; + assert_eq!(env["name"], "Scene VR Output"); + assert_eq!(env["faces"].as_array().unwrap().len(), 6); + let view = env["view"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); + assert!(view.iter().all(|v| v.abs() < 1e-6 || (v - 360.0).abs() < 1e-6), "{view:?}"); + let out = env["output"].as_u64().unwrap(); + let r = s.execute("comp.vr.setView", json!({"comp": out, "pan": 30.0})).unwrap(); + let v = r["view"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); + assert!((v[1] - 30.0).abs() < 1e-6 && v[0].abs() < 1e-6 && v[2].abs() < 1e-6, "{v:?}"); + // Every face camera turned: Front by 30° about Y. + let front = &env["faces"][4]; + let fc = s.project.comp(effectcraft_project::ItemId(front["comp"].as_u64().unwrap())).unwrap(); + let cam = fc.layer(effectcraft_project::LayerId(front["camera"].as_u64().unwrap())).unwrap(); + let o = cam.props.prop("transform/orientation").unwrap().value.components(); + assert!((o[1] - 30.0).abs() < 1e-6, "{o:?}"); + // One undo step back to the start. + assert!(s.undo()); + let envs = s.execute("comp.vr.environments", json!({})).unwrap(); + assert!(envs[0]["view"][1].as_f64().unwrap().abs() < 1e-6); + // Round trip of the Euler decomposition. + for o in [[10.0, 20.0, 30.0], [350.0, 45.0, 5.0], [0.0, 0.0, 270.0]] { + let m = effectcraft_geom::Mat4::orientation(effectcraft_geom::vec3(o[0], o[1], o[2])); + let e = crate::commands::vr_editor::euler(m); + assert!(e.iter().zip(o).all(|(a, b)| (a - b).abs() < 1e-6), "{o:?} → {e:?}"); + } +} diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index da6c6c3ac..705425c02 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -326,6 +326,51 @@ fn variable_font_axes_animator() { let _ = info; } +#[test] +fn variable_font_axes_in_the_character_style() { + let mut s = comp(600, 200); + let t = s.execute("layer.newText", json!({"text": "Hamburg", "size": 60})).unwrap()["layer"].as_u64().unwrap(); + // The attribute round-trips and is undoable even for static fonts (it is kept, unused). + s.execute("layer.setText", json!({"layer": t, "variations": {"wght": 650}})).unwrap(); + let d = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap(); + assert_eq!(d.style_at(0).variations, vec![("wght".to_string(), 650.0)]); + assert_eq!(effectcraft_keyframe::text_doc::char_style_json(&d.style_at(0))["variations"], json!({"wght": 650.0})); + s.execute("layer.setText", json!({"layer": t, "range": [0, 3], "variations": {"wght": null}})).unwrap(); + let d = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap(); + assert!(d.style_at(0).variations.is_empty() && !d.style_at(5).variations.is_empty()); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(0).variations.is_empty()); + assert!(s.execute("layer.setText", json!({"layer": t, "variations": 3})).is_err()); + let Some((face, axes)) = effectcraft_text::variable::find_variable_face() else { + eprintln!("no variable font installed: rendering check skipped"); + return; + }; + let info = effectcraft_text::fonts::face(face).info.clone(); + if effectcraft_text::resolve(&info.family, &info.style).face != face { + eprintln!("variable face not reachable by family/style: skipped"); + return; + } + s.execute("layer.setText", json!({"layer": t, "font": info.family, "style": info.style})).unwrap(); + let a = axes.iter().find(|a| a.tag == "wght" && a.max > a.min).or_else(|| axes.iter().find(|a| a.max > a.min)).unwrap().clone(); + let geom = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time()); + let l = comp.layer(LayerId(t)).unwrap(); + let paths = effectcraft_render::text::glyph_paths(&ctx, l); + let area: f64 = paths.iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum(); + let right = paths.iter().map(|(p, _)| effectcraft_text::kurbo::Shape::bounding_box(p).x1).fold(f64::MIN, f64::max); + (area, right) + }; + s.execute("layer.setText", json!({"layer": t, "variations": {a.tag.trim_end(): a.min}})).unwrap(); + let lo = geom(&s); + s.execute("layer.setText", json!({"layer": t, "variations": {a.tag.trim_end(): a.max}})).unwrap(); + let hi = geom(&s); + assert!((hi.0 - lo.0).abs() > 1.0, "{} outlines {lo:?} → {hi:?}", a.tag); + assert!((hi.1 - lo.1).abs() > 0.5, "{} advances {lo:?} → {hi:?}", a.tag); +} + #[test] fn camera_focus_commands_undo_and_serde() { let mut s = comp(400, 300); diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 3465d3855..23fc3f24c 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -122,48 +122,147 @@ fn group(ids: &mut Ids, name: &str, items: Vec, opacity: f64) -> Prop g } +/// A Merge Paths item in `mode` (0 Merge, 3 Intersect). +fn merge_item(ids: &mut Ids, mode: u32) -> Option { + let mut g = build::shape_simple_op(ids, "merge")?; + set(&mut g, "mode", Value::Enum(mode)); + Some(g) +} + +/// A shape inside clipping groups: its geometry merged into one compound path (an inner group +/// with Merge Paths), the clip paths, and Merge Paths ▸ Intersect before its paint, so the fill +/// is exactly the clipped area. Open stroked paths stay unclipped (an intersection would close +/// them). +fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[BezPath]) -> Vec { + let items = shape_items(ids, s, m); + let path = s.geom.to_path(); + let closed = path.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)) || s.fill.is_some(); + if clips.is_empty() || !closed { + return items; + } + let (geom, paint): (Vec, Vec) = + items.into_iter().partition(|g| !matches!(g.match_id.as_str(), "fill" | "stroke" | "gfill" | "gstroke")); + let mut inner = geom; + inner.extend(merge_item(ids, 0)); + let mut out = vec![build::shape_group(ids, "Clipped Path", inner)]; + for (i, c) in clips.iter().enumerate() { + let mut parts = shape_paths(ids, c); + parts.extend(merge_item(ids, 0)); + out.push(build::shape_group(ids, &format!("Clip Path {}", i + 1), parts)); + } + out.extend(merge_item(ids, 3)); + out.extend(paint); + out +} + /// Shape layer contents for an SVG document, in document pixels (transforms are baked into the -/// geometry; group and element opacity go to the group transforms). Top of the paint order first. +/// geometry; group and element opacity go to the group transforms, blend modes to the groups' +/// Blend Mode). Top of the paint order first. +/// +/// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with +/// the clip paths through Merge Paths ▸ Intersect (see [`clipped_items`]). Clips enclosing the +/// whole document are layer masks instead ([`vector_clip_masks`], used by +/// [`shapes_from_vector`]). Images and soft masks are not converted. pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec { - fn walk(ids: &mut Ids, nodes: &[Node], m: Affine) -> Vec { + fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec { let mut out = vec![]; for n in nodes.iter().rev() { match n { Node::Group(g) => { - let items = walk(ids, &g.children, m * g.transform); + let gm = m * g.transform; + let mut inner_clips = clips.to_vec(); + inner_clips.extend(g.clip.iter().map(|(p, _)| gm * p.clone())); + let items = walk(ids, &g.children, gm, &inner_clips); if !items.is_empty() { - out.push(group(ids, &g.name, items, g.opacity)); + let mut grp = group(ids, &g.name, items, g.opacity); + if g.blend != effectcraft_svg::BlendMode::Normal { + set(&mut grp, "blend", Value::Enum(blend_index(g.blend))); + } + out.push(grp); } } Node::Shape(s) => { - let items = shape_items(ids, s, m * s.transform); + let sm = m * s.transform; + let items = if clips.is_empty() { shape_items(ids, s, sm) } else { clipped_items(ids, s, sm, clips) }; if !items.is_empty() { out.push(group(ids, &s.name, items, s.opacity)); } } + Node::Image(_) => {} } } out } let root = &doc.root; - let items = walk(ids, &root.children, root.transform); + let mut clips = vec![]; + clips.extend(root.clip.iter().map(|(p, _)| root.transform * p.clone())); + let items = walk(ids, &root.children, root.transform, &clips); if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items } } +/// The shape-layer Blend Mode index of an SVG / PDF blend mode. +fn blend_index(b: effectcraft_svg::BlendMode) -> u32 { + use effectcraft_color::BlendMode as P; + use effectcraft_svg::BlendMode as S; + let p = match b { + S::Normal => P::Normal, + S::Multiply => P::Multiply, + S::Screen => P::Screen, + S::Overlay => P::Overlay, + S::Darken => P::Darken, + S::Lighten => P::Lighten, + S::ColorDodge => P::ColorDodge, + S::ColorBurn => P::ColorBurn, + S::HardLight => P::HardLight, + S::SoftLight => P::SoftLight, + S::Difference => P::Difference, + S::Exclusion => P::Exclusion, + S::Hue => P::Hue, + S::Saturation => P::Saturation, + S::Color => P::Color, + S::Luminosity => P::Luminosity, + }; + P::ALL.iter().position(|x| *x == p).unwrap_or(0) as u32 +} + +/// Clips that enclose the whole document (on a chain of single-child groups from the root, as +/// an Illustrator artboard clip or a PDF page clip), in document pixels, and the document with +/// those clips removed: Create Shapes from Vector Layer makes them layer masks. +pub fn vector_clip_masks(doc: &Doc) -> (Vec, Doc) { + let mut out = doc.clone(); + let mut masks = vec![]; + let mut m = Affine::IDENTITY; + let mut g = &mut out.root; + loop { + m *= g.transform; + for (p, _) in g.clip.drain(..) { + masks.push(m * p); + } + if g.children.len() != 1 { + break; + } + match &mut g.children[0] { + Node::Group(sub) if sub.mask.is_none() && sub.blend == effectcraft_svg::BlendMode::Normal => g = sub, + _ => break, + } + } + (masks, out) +} + /// 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> { +/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page +/// `page` of a PDF, restricted to one layer when `layer` names it. +pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32) -> Option> { if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) { return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); } effectcraft_pdf::sniff(bytes)?; Some( - effectcraft_pdf::parse(bytes) + effectcraft_pdf::parse_page(bytes, page as usize) .map(|d| match layer { Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), None => d, @@ -172,20 +271,21 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project:: ) } -/// 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). +/// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the +/// 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, + page: u32, rate: effectcraft_time::FrameRate, duration: effectcraft_time::Tick, ) -> Result<(ItemId, ItemId, Vec), String> { use effectcraft_color::Label; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer}; - let doc = effectcraft_pdf::parse(bytes).map_err(|e| format!("{path}: {e}"))?; + let doc = effectcraft_pdf::parse_page(bytes, page as usize).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(); @@ -206,6 +306,7 @@ pub fn import_vector_comp( loop_count: 1, codec: codec.into(), layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }), + page, ..Default::default() }; let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f)); @@ -239,13 +340,28 @@ fn like_source(dst: &mut Layer, src: &Layer) { dst.switches.three_d = src.switches.three_d; } -/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. +/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. A clip +/// enclosing the whole document becomes the layer's masks (Add, then Intersect); nested clips +/// are kept with Merge Paths (see [`svg_contents`]). pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer { let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None); - let items = svg_contents(&mut Ids(&mut proj.next_id), doc); + let (clips, doc) = vector_clip_masks(doc); + let mut ids = Ids(&mut proj.next_id); + let items = svg_contents(&mut ids, &doc); if let Some(c) = l.props.sub_mut("contents") { c.children.extend(items.into_iter().map(Into::into)); } + let mut n = 0; + for (k, clip) in clips.iter().enumerate() { + for sp in effectcraft_path::from_kurbo(clip) { + n += 1; + let mode = if k == 0 { MaskMode::Add } else { MaskMode::Intersect }; + let mk = build::mask(&mut ids, &format!("Mask {n}"), sp, mode, [255, 255, 0]); + if let Some(masks) = l.props.sub_mut("masks") { + masks.children.push(mk.into()); + } + } + } copy_transform(proj, src, &mut l); like_source(&mut l, src); l.name = comp.unique_layer_name(&format!("{} Outlines", src.name)); diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 39770ee1e..fe95d0974 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -351,6 +351,52 @@ __Path.prototype.points = function () { return this.__pts; }; __Path.prototype.inTangents = function () { return this.__in; }; __Path.prototype.outTangents = function () { return this.__out; }; __Path.prototype.isClosed = function () { return this.__closed; }; +// pointOnPath / tangentOnPath: a point (or unit tangent) at a fraction of the path's arc length. +__Path.prototype.pointOnPath = function (pct) { return __pathAt(this, pct === undefined ? 0.5 : pct, false); }; +__Path.prototype.tangentOnPath = function (pct) { return __pathAt(this, pct === undefined ? 0.5 : pct, true); }; +__Path.prototype.normalOnPath = function (pct) { var t = this.tangentOnPath(pct); return [t[1], -t[0]]; }; + +function __pathSegs(p) { + var n = p.__pts.length, out = []; + var m = p.__closed ? n : n - 1; + for (var i = 0; i < m; i++) { + var j = (i + 1) % n, a = p.__pts[i], b = p.__pts[j]; + var o = p.__out[i] || [0, 0], ii = p.__in[j] || [0, 0]; + out.push([a, [a[0] + o[0], a[1] + o[1]], [b[0] + ii[0], b[1] + ii[1]], b]); + } + return out; +} +function __bezAt(s, u) { + var v = 1 - u; + return [0, 1].map(function (k) { return v * v * v * s[0][k] + 3 * v * v * u * s[1][k] + 3 * v * u * u * s[2][k] + u * u * u * s[3][k]; }); +} +function __bezDer(s, u) { + var v = 1 - u; + return [0, 1].map(function (k) { return 3 * v * v * (s[1][k] - s[0][k]) + 6 * v * u * (s[2][k] - s[1][k]) + 3 * u * u * (s[3][k] - s[2][k]); }); +} +function __pathAt(p, pct, tangent) { + var segs = __pathSegs(p); + if (!segs.length) return tangent ? [1, 0] : p.__pts.length ? [p.__pts[0][0], p.__pts[0][1]] : [0, 0]; + var N = 32, table = [], total = 0; + for (var k = 0; k < segs.length; k++) { + var prev = __bezAt(segs[k], 0); + for (var s = 1; s <= N; s++) { + var q = __bezAt(segs[k], s / N); + total += Math.sqrt((q[0] - prev[0]) * (q[0] - prev[0]) + (q[1] - prev[1]) * (q[1] - prev[1])); + table.push([total, k, s / N]); + prev = q; + } + } + var target = Math.max(0, Math.min(1, pct)) * total; + var i = 0; + while (i < table.length - 1 && table[i][0] < target) i++; + var e = table[i], seg = e[1]; + var l0 = i > 0 ? table[i - 1][0] : 0, u0 = i > 0 && table[i - 1][1] === seg ? table[i - 1][2] : 0; + var u = e[0] > l0 ? u0 + (e[2] - u0) * (target - l0) / (e[0] - l0) : e[2]; + if (!tangent) return __bezAt(segs[seg], u); + var d = __bezDer(segs[seg], u), len = Math.sqrt(d[0] * d[0] + d[1] * d[1]); + return len > 0 ? [d[0] / len, d[1] / len] : [1, 0]; +} function createPath(points, inTangents, outTangents, isClosed) { points = points || []; @@ -734,6 +780,9 @@ Prop.prototype = { inTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).inTangents(); }, outTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).outTangents(); }, isClosed: function () { return this.value.isClosed(); }, + pointOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).pointOnPath(pct); }, + tangentOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).tangentOnPath(pct); }, + normalOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).normalOnPath(pct); }, // Source Text style API. __docAt: function (t) { var d = __h(__DOC, this.__c, this.__l, this.__p, t, this.__self); diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index d75972f76..ac06485ed 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1116,6 +1116,10 @@ fn project_and_time_globals() { assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], true).points()[2][1]", 0.0), 10.0); assert_close!(f.num("createPath([[0, 0], [10, 0]], [[1, 1], [2, 2]], [], false).inTangents()[1][0]", 0.0), 2.0); assert_close!(f.num("mask(\"Mask 1\").maskPath.points().length", 0.0), 4.0); + assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[1]", 0.0), 5.0); + assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[0]", 0.0), 10.0); + assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).tangentOnPath(0.25)[0]", 0.0), 1.0); + assert_close!(f.num("mask(\"Mask 1\").maskPath.pointOnPath(0)[0] - mask(\"Mask 1\").maskPath.points()[0][0]", 0.0), 0.0); assert_close!(f.num("mask(\"Mask 1\").maskPath.isClosed() ? 1 : 0", 0.0), 1.0); // Marker keys carry protected regions and cue points. { diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs new file mode 100644 index 000000000..db7ca9f7e --- /dev/null +++ b/crates/gpu/src/bokeh.rs @@ -0,0 +1,59 @@ +//! Classic 3D depth of field on the GPU (`bokeh_*` kernels): the plane buffer padded, its +//! highlights boosted, row prefix sums, then one span gather per blur level +//! ([`effectcraft_render::three_d::bokeh::kernel_spans`]) accumulated with each pixel's level +//! weight — the CPU's `progressive_blur` step for step. + +use effectcraft_render::three_d::PlaneDof; +use effectcraft_render::three_d::bokeh::kernel_spans; + +use crate::context::{Enc, GpuImage, Params}; + +/// Blur `src` (an unpadded plane buffer) as `d` says. Returns the padded, blurred image. +pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option { + let (w, h) = (src.width + 2 * d.pad, src.height + 2 * d.pad); + if !e.g.fits(w + 1, h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { + return None; + } + let padded = e.image(w, h); + e.copy_into(src, &padded, d.pad, d.pad); + let boosted = if d.highlight.gain > 0.0 { + let out = e.image(w, h); + let mut p = Params::default(); + p.f[0] = [d.highlight.gain as f32, d.highlight.threshold as f32, d.highlight.saturation as f32, 0.0]; + e.pixels("bokeh_boost", &p, &padded, None, &out, None); + out + } else { + padded + }; + let prefix = e.image(w + 1, h); + e.dispatch("bokeh_prefix", &Params::default(), &boosted, None, &prefix, None, (h.div_ceil(64), 1)); + // Radius map, then every level's spans. + let mut data: Vec = d.radius.as_ref().clone(); + let mut levels = vec![]; + for &r in &d.levels { + let (kernels, norm) = kernel_spans(&d.iris, r as f64).unwrap_or_else(|| (vec![(1.0, vec![(0, -0.5, 0.5)])], 1.0)); + let off = data.len() as u32; + let mut count = 0u32; + for (wt, spans) in &kernels { + for &(dy, x0, x1) in spans { + data.extend_from_slice(&[dy as f32, x0 as f32, x1 as f32, *wt]); + count += 1; + } + } + levels.push((off, count, norm)); + } + let buf = e.data(&data); + let n = d.levels.len() as u32; + let (lo, hi) = (d.levels[0], d.levels[d.levels.len() - 1]); + let mut acc = e.image(w, h); + for (k, (off, count, norm)) in levels.into_iter().enumerate() { + let out = e.image(w, h); + let mut p = Params::default(); + p.u[0] = [off, count, k as u32, n]; + p.u[1] = [u32::from(k == 0), 0, 0, 0]; + p.f[0] = [1.0 / norm, lo, hi, 0.0]; + e.pixels("bokeh_gather", &p, &prefix, Some(&acc), &out, Some(&buf)); + acc = out; + } + Some(acc) +} diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs index 0c72dd601..99d429f95 100644 --- a/crates/gpu/src/classic3d.rs +++ b/crates/gpu/src/classic3d.rs @@ -136,11 +136,18 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option = Vec::with_capacity(run.planes.len()); for p in &run.planes { - texs.push((e.g.upload_buf(&p.buf)?, p.buf.offset)); + let up = e.g.upload_buf(&p.buf)?; + match &p.dof { + Some(d) => { + let pad = d.pad as f64; + texs.push((crate::bokeh::apply(e, &up, d)?, [p.buf.offset[0] + pad, p.buf.offset[1] + pad])); + } + None => texs.push((up, p.buf.offset)), + } } let sizes: Vec<(u32, u32)> = texs.iter().map(|(t, _)| (t.width, t.height)).collect(); let (aw, ah, pos) = pack(&sizes, e.g.max_dim)?; diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 7bc7860af..eac24208c 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -27,6 +27,9 @@ const ENTRIES: &[&str] = &[ "shadow_combine", "pointwise", "adjust_mix", + "bokeh_boost", + "bokeh_prefix", + "bokeh_gather", ]; /// Entry points that also bind group 1 (four read-only storage buffers; see diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 6955cf355..f4871fd75 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -28,6 +28,7 @@ //! egui-wgpu to draw. mod adv3d; +mod bokeh; mod classic3d; mod context; mod effects; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 443abcf9c..f471a86b8 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -844,3 +844,118 @@ fn adjust_mix(@builtin(global_invocation_id) gid: vec3) { let k = data[(gid.y * P.u[0].x + gid.x) * 4u + 3u] * P.f[0].x; textureStore(out, p, c + (a - c) * k); } + +// ---------------------------------------------------------------- Classic 3D bokeh depth of field +// (render::three_d::bokeh: boost_highlights, row prefix sums, span gathers per blur level +// blended by each pixel's radius.) + +// Highlight boost: f[0] = (gain, threshold, saturation). +@compute @workgroup_size(16, 16) +fn bokeh_boost(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var c = textureLoad(src, p, 0); + let gain = P.f[0].x; + let thr = clamp(P.f[0].y, 0.0, 1.0); + let satk = P.f[0].z; + if (c.w > 0.0 && gain > 0.0) { + let a = c.w; + let rgb = c.xyz / a; + let l = 0.2126 * rgb.x + 0.7152 * rgb.y + 0.0722 * rgb.z; + if (!(l < thr || (thr >= 1.0 && l < 1.0))) { + var over = 1.0; + if (thr < 1.0) { + over = clamp((l - thr) / (1.0 - thr), 0.0, 1.0); + } + let k = 1.0 + gain * 4.0 * max(over, 0.25); + let sat = 1.0 + satk; + let o = max(vec3(l) + (rgb - vec3(l)) * sat, vec3(0.0)) * k; + c = vec4(o * a, a); + } + } + textureStore(out, p, c); +} + +// Row prefix sums: out is (w + 1) × h, out[x] = sum of src[0..x]. One invocation per row. +@compute @workgroup_size(64, 1) +fn bokeh_prefix(@builtin(global_invocation_id) gid: vec3) { + let dims = vec2(textureDimensions(src)); + let y = i32(gid.x); + if (y >= dims.y) { + return; + } + var acc = vec4(0.0); + textureStore(out, vec2(0, y), acc); + for (var x = 0; x < dims.x; x = x + 1) { + acc = acc + textureLoad(src, vec2(x, y), 0); + textureStore(out, vec2(x + 1, y), acc); + } +} + +fn bokeh_prefix_at(y: i32, t: f32) -> vec4 { + let n = i32(textureDimensions(src).x) - 1; + if (t <= 0.0) { + return vec4(0.0); + } + if (t >= f32(n)) { + return textureLoad(src, vec2(n, y), 0); + } + let i = i32(floor(t)); + let f = t - f32(i); + let a = textureLoad(src, vec2(i, y), 0); + let b = textureLoad(src, vec2(i + 1, y), 0); + return a + (b - a) * f; +} + +// One blur level added into the accumulator `aux`: src = prefix sums; data = the radius map +// (w·h) then spans (dy, x0, x1, weight). u[0] = (span offset, span count, level, levels), +// u[1].x = 1 for the first level (no accumulator yet); f[0] = (1 / norm, lo, hi). +@compute @workgroup_size(16, 16) +fn bokeh_gather(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var acc = vec4(0.0); + if (P.u[1].x == 0u) { + acc = textureLoad(aux, p, 0); + } + let r = data[u32(p.y) * u32(dims.x) + u32(p.x)]; + let k = P.u[0].z; + let n = P.u[0].w; + var wgt = 1.0; + if (n > 1u) { + let lo = P.f[0].y; + let hi = P.f[0].z; + let f = clamp((r - lo) / (hi - lo) * f32(n - 1u), 0.0, f32(n - 1u)); + let kk = min(u32(floor(f)), n - 2u); + let t = f - f32(kk); + if (k == kk) { + wgt = 1.0 - t; + } else if (k == kk + 1u) { + wgt = t; + } else { + wgt = 0.0; + } + } + if (wgt > 0.0) { + var g = vec4(0.0); + let off = P.u[0].x; + for (var i = 0u; i < P.u[0].y; i = i + 1u) { + let s = off + i * 4u; + let sy = p.y + i32(data[s]); + if (sy < 0 || sy >= dims.y) { + continue; + } + let hi = bokeh_prefix_at(sy, f32(p.x) + data[s + 2u] + 0.5); + let lo = bokeh_prefix_at(sy, f32(p.x) + data[s + 1u] + 0.5); + g = g + (hi - lo) * data[s + 3u]; + } + acc = acc + g * P.f[0].x * wgt; + } + textureStore(out, p, acc); +} diff --git a/crates/gpu/src/tests_3d.rs b/crates/gpu/src/tests_3d.rs index f10368d20..bd9455b9e 100644 --- a/crates/gpu/src/tests_3d.rs +++ b/crates/gpu/src/tests_3d.rs @@ -218,3 +218,73 @@ fn the_gpu_draws_classic_3d_runs_itself() { let d = crate::tests::diff(&cpu, &img, 1e-3); assert!(d.over * 200 <= d.total, "{d:?}"); } + +/// Classic 3D bokeh depth of field runs on the GPU: iris shapes, highlights, fringe, Fast +/// Rectangle and a tilted plane (progressive blur) match the CPU. +#[test] +fn bokeh_depth_of_field_runs_on_the_gpu() { + let cases: [(&str, &[(&str, Value)]); 4] = [ + ("fast rectangle", &[]), + ("hexagon + highlights", &[("irisShape", Value::Enum(4)), ("irisRotation", n(15.0)), ("highlightGain", n(40.0)), ("highlightThreshold", n(150.0))]), + ("round + fringe", &[("irisShape", Value::Enum(6)), ("irisRoundness", n(60.0)), ("irisDiffractionFringe", n(80.0))]), + ("wide triangle", &[("irisShape", Value::Enum(1)), ("irisAspectRatio", n(2.0))]), + ]; + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (label, vals) in cases { + // The crossing planes are tilted (±35°): every plane blurs progressively. + let mut s = crossing(depth, BlendMode::Normal); + camera(&mut s, [80.0, 50.0, -220.0], Some((180.0, 40.0))); + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + if l.name == "Camera" { + for (k, v) in vals { + set(l, &format!("cameraOptions/{k}"), v.clone()); + } + } + } + check(&format!("bokeh {label} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // The blur is the GPU's: the prepared planes carry it unapplied. + if depth == BitDepth::Bpc32 + && let Some(g) = gpu() + { + let r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap(); + let run: Vec<_> = r.visible_layers(&ctx).into_iter().filter(|l| l.is_3d()).collect(); + let prep = r.prepare_3d_run(&ctx, &run, (W, H)).unwrap(); + assert!(prep.planes.iter().filter(|p| p.draw).all(|p| p.dof.is_some()), "{label}: depth of field deferred to the GPU"); + let mut e = crate::context::Enc::new(g.context()); + let canvas = e.image(W, H); + assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU"); + } + } + } +} + +/// A 2D layer whose track matte is a 3D layer: the matte is drawn through the camera on the +/// GPU walk as on the CPU. +#[test] +fn two_d_layer_with_a_three_d_track_matte() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for kind in [MatteKind::Alpha, MatteKind::Luma] { + let mut s = scene(depth); + let bg = s.solid([0.1, 0.12, 0.2], W, H); + s.push(bg); + let fill = s.footage(W, H); + let fid = s.push(fill); + // The matte: a solid turned in 3D, seen through a camera. + let m = three_d(s.solid([0.9, 0.9, 0.9], 70, 50), [80.0, 50.0, 30.0], 40.0); + let mid = s.push(m); + camera(&mut s, [40.0, 30.0, -200.0], None); + let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; + let fi = layers.iter().position(|l| l.id == fid).unwrap(); + layers[fi].track_matte = Some(TrackMatte { layer: mid, kind }); + if let Some(ml) = layers.iter_mut().find(|l| l.id == mid) { + ml.switches.video = false; + } + check(&format!("3d matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // The same with the Advanced 3D renderer (its CPU path draws 3D mattes through the + // camera with composite_iso_with). + s.p.comp_mut(s.cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D; + check(&format!("3d matte {kind:?} {depth:?} (Advanced 3D)"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } + } +} diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 9b77ccae4..d80825735 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -247,15 +247,13 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() { 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)); + // The clipped bar (rows 33–47) keeps its clip through Merge Paths ▸ Intersect. + let d = mean_diff(&shapes, &direct); assert!(d < 0.01, "{name}: shapes vs page mean diff {d}"); + for x in [10, 50, 100] { + let (a, b) = (shapes.get(x, 40), direct.get(x, 40)); + assert!((0..4).all(|c| (a[c] - b[c]).abs() < 0.02), "{name}: bar clipped at x = {x}: {a:?} vs {b:?}"); + } let fill = shapes.get(60, 30); assert!(fill[0] > 0.9 && fill[1] < 0.1, "red shape {fill:?}"); } @@ -334,3 +332,132 @@ fn eps_footage_imports() { 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)); } + +/// A two-page PDF: page 2 has text in a non-embedded standard font inside a page-wide clip. +fn two_page_pdf() -> Vec { + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(b"1 0 0 rg 0 0 120 80 re f".to_vec())), + ( + 5, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 60] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> >> /Contents 6 0 R >>".into(), + None, + ), + (6, "<< >>".into(), Some(b"10 5 80 50 re W n 0 0 1 rg 0 0 100 60 re f 1 1 0 rg BT /F1 40 Tf 15 15 Td (Hi) Tj ET".to_vec())), + ], + 1, + ) +} + +#[test] +fn pdf_pages_text_and_clips_to_masks() { + let bytes = two_page_pdf(); + let path = tmp("pages.pdf"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + // Page 2 as footage (File ▸ Import ▸ Page), named after its page. + let r = s.execute_checked("file.import", json!({"paths": [path], "page": 2})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let item = ItemId(r["items"][0].as_u64().unwrap()); + let it = s.project.item(item).unwrap(); + assert_eq!(it.name, "pages.pdf (Page 2)"); + let effectcraft_project::ItemKind::Footage(f) = &it.kind else { panic!() }; + assert_eq!((f.width, f.height, f.page), (100, 60, 1)); + assert!(s.execute_checked("file.import", json!({"paths": [path], "page": 3})).unwrap()["errors"][0].as_str().unwrap().contains("no page 3")); + assert!(s.execute("file.import", json!({"paths": [path], "page": 0})).is_err()); + let doc = effectcraft_pdf::parse_page(&bytes, 1).unwrap(); + let direct = effectcraft_svg::rasterize(&doc, 100, 60, 1.0); + s.execute("comp.new", json!({"name": "P", "width": 100, "height": 60, "frameRate": 30, "duration": 1})).unwrap(); + let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).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, "page 2 footage"); + // Text drew (yellow glyphs over the blue page). + let ink = (0..60).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| foot.get(x, y)[0] > 0.9).count(); + assert!(ink > 100, "text pixels {ink}"); + // Create Shapes from Vector Layer: the page-wide clip becomes the layer's mask, the text a + // shape group named after it. + s.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let sid = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap()["layers"][0].as_u64().unwrap(); + let c = s.active_comp().unwrap(); + let l = c.layer(effectcraft_project::LayerId(sid)).unwrap(); + assert_eq!(l.masks().map(|m| m.groups().count()), Some(1)); + fn names(g: &effectcraft_project::PropGroup, out: &mut Vec) { + for c in g.groups() { + out.push(c.name.clone()); + names(c, out); + } + } + let mut names_found = vec![]; + names(l.props.sub("contents").unwrap(), &mut names_found); + let names = names_found; + assert!(names.iter().any(|n| n == "Text: Hi"), "{names:?}"); + let shapes = s.render(cid, t, RenderOpts::default()); + let d = mean_diff(&shapes, &direct); + assert!(d < 0.01, "shapes vs page {d}"); + assert_eq!(shapes.get(5, 30)[3], 0.0, "masked outside the clip"); + // Page 1 as a composition. + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "page": 1})).unwrap(); + let comp = s.project.comp(ItemId(r["comps"][0].as_u64().unwrap())).unwrap(); + assert_eq!((comp.width, comp.height), (120, 80)); +} + +#[test] +fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() { + 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); + // A perspective trapezoid, and an affine placement with an Arc warp. + let trap = [[20.0, 4.0], [44.0, 10.0], [44.0, 38.0], [20.0, 44.0]]; + let warp = warp_descriptor("warpArc", 50.0, 0.0, 0.0, [0.0, 0.0, 20.0, 10.0], None); + d.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]), + WLayer::solid("Pinned", Rect::new(20, 4, 24, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped("so-1", trap, [20.0, 10.0], None)), + ]; + d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())]; + let mut d2 = WDoc::new(64, 48); + d2.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]), + WLayer::solid("Arc", Rect::new(20, 10, 40, 20), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped( + "so-2", + [[20.0, 10.0], [60.0, 10.0], [60.0, 30.0], [20.0, 30.0]], + [20.0, 10.0], + Some(warp), + )), + ]; + d2.linked = vec![("so-2".into(), "inner.psd".into(), *b"8BPS", inner_bytes)]; + let parsed = effectcraft_psd::Psd::parse(write(&d2)).unwrap(); + let so = parsed.layers.iter().find_map(|l| l.smart_object.clone()).unwrap(); + assert_eq!(so.warp.as_ref().map(|w| w.style.as_str()), Some("warpArc")); + let mut s = effectcraft_host::session(); + let render = |s: &mut effectcraft_engine::Session, doc: &WDoc, file: &str| { + let path = tmp(file); + std::fs::write(&path, write(doc)).unwrap(); + 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 placed = s.project.comp(cid).unwrap().layers.iter().any(|l| { + let effectcraft_project::LayerSource::Footage { item } = l.source else { return false }; + matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.layer.as_ref().is_some_and(|x| x.placed)) + }); + assert!(placed, "{file}: baked as placed"); + s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()) + }; + let img = render(&mut s, &d, "pinned.psd"); + let (red, green) = (img.get(24, 24), img.get(40, 24)); + assert!(red[0] > 0.9 && red[1] < 0.1, "{red:?}"); + assert!(green[1] > 0.9 && green[0] < 0.1, "{green:?}"); + // Above the slanted top edge (y = 4 + 6·(x − 20)/24): background. + assert!(img.get(42, 6)[0] < 0.05 && img.get(42, 6)[1] < 0.05, "{:?}", img.get(42, 6)); + let img = render(&mut s, &d2, "arc.psd"); + // The arc keeps the top centre and pulls the corners down and out. + let top = img.get(40, 11); + assert!(top[0] > 0.5 || top[1] > 0.5, "top centre covered {top:?}"); + assert!(img.get(21, 11)[0] < 0.05 && img.get(21, 11)[1] < 0.05, "corner region empty {:?}", img.get(21, 11)); +} diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index ae0b2e568..f1845993a 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -299,6 +299,10 @@ pub struct CharStyle { /// OpenType features (stylistic sets, figures, fractions…). #[serde(skip_serializing_if = "OpenType::is_default")] pub opentype: OpenType, + /// Variable font axis values (tag, user units; axes not listed stay at their defaults): + /// the Character panel's Variable Font Axes. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub variations: Vec<(String, f32)>, } impl Default for CharStyle { @@ -383,6 +387,9 @@ pub struct TextDoc { /// Base OpenType features (see [`CharStyle::opentype`]). #[serde(skip_serializing_if = "OpenType::is_default")] pub opentype: OpenType, + /// Base variable font axis values (see [`CharStyle::variations`]). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub variations: Vec<(String, f32)>, pub indent_left: f64, pub indent_right: f64, pub indent_first: f64, @@ -435,6 +442,7 @@ impl Default for TextDoc { tate_chu_yoko: false, vertical_roman_upright: false, opentype: OpenType::default(), + variations: vec![], indent_left: 0.0, indent_right: 0.0, indent_first: 0.0, @@ -522,6 +530,7 @@ impl TextDoc { tate_chu_yoko: self.tate_chu_yoko, vertical_roman_upright: self.vertical_roman_upright, opentype: self.opentype, + variations: self.variations.clone(), } } @@ -550,6 +559,7 @@ impl TextDoc { self.tate_chu_yoko = s.tate_chu_yoko; self.vertical_roman_upright = s.vertical_roman_upright; self.opentype = s.opentype; + self.variations = s.variations.clone(); } /// The base (first paragraph's) settings. @@ -951,6 +961,7 @@ pub const CHAR_ATTRS: &[&str] = &[ "figureStyle", "figureWidth", "figures", + "variations", ]; /// Paragraph attribute keys of `layer.setText`. @@ -1068,6 +1079,28 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result match v { + J::Null => s.variations.clear(), + J::Object(m) => { + if m.is_empty() { + s.variations.clear(); + } + for (tag, val) in m { + if tag.is_empty() || tag.len() > 4 { + return Err(format!("variations: `{tag}` is not an axis tag")); + } + s.variations.retain(|(t, _)| t != tag); + match val { + J::Null => {} + _ => s.variations.push((tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32)), + } + } + s.variations.sort_by(|a, b| a.0.cmp(&b.0)); + } + _ => return Err("variations: expected {tag: number, …}".into()), + }, k if k.len() == 4 && k.starts_with("ss") && k[2..].parse::().is_ok_and(|n| (1..=20).contains(&n)) => { let n = k[2..].parse::().unwrap_or(0); s.opentype.set_stylistic_set(n, flag(key, v)?); @@ -1123,6 +1156,7 @@ pub fn char_style_json(s: &CharStyle) -> J { "stylisticSets": (1..=20u32).filter(|n| s.opentype.stylistic_set(*n)).collect::>(), "swash": s.opentype.swash, "titling": s.opentype.titling, "ordinals": s.opentype.ordinals, "fractions": s.opentype.fractions, "allSmallCaps": s.opentype.all_small_caps, + "variations": s.variations.iter().map(|(t, v)| (t.clone(), J::from(*v as f64))).collect::>(), "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" }, }) diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index b2331d812..02546611f 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -13,15 +13,20 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool { path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) } -/// 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> { +/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page +/// `page` of a PDF, restricted to the footage's layer when it names one. +pub fn vector_doc( + path: &str, + bytes: &[u8], + layer: Option<&effectcraft_project::SourceLayer>, + page: u32, +) -> Option> { if is_svg(path, bytes) { return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); } effectcraft_pdf::sniff(bytes)?; Some( - effectcraft_pdf::parse(bytes) + effectcraft_pdf::parse_page(bytes, page as usize) .map(|d| match layer { Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), None => d, @@ -70,13 +75,26 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - // 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 px = 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))); + inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))? + } else { + let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + let rgba = img.to_rgba32f(); + effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() } + }; + // A perspective quad or a warp: baked as placed. + let l = footage.layer.as_ref().expect("embedded implies a layer"); + if l.placed + && let Some(so) = psd.layers.get(l.index as usize).and_then(|pl| pl.smart_object.as_ref()) + { + let bb = so.placed_bounds(px.width as f64, px.height as f64); + let k = footage.width as f64 / (bb[2] - bb[0]).max(1e-9); + if let Some((baked, _)) = so.render_placed(&px, k, Some((footage.width, footage.height))) { + return Ok(Some(straight_to_image(baked.width, baked.height, &baked.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))); + return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); } let px = match &footage.layer { Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size), @@ -86,7 +104,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); } 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 doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).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. @@ -97,8 +115,8 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - } /// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize). -pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, scale: f64) -> Option { - let doc = vector_doc(path, bytes, layer)?.ok()?; +pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option { + let doc = vector_doc(path, bytes, layer, page)?.ok()?; let (w, h) = doc.pixel_size(); let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384)); Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 130100579..3aa7fca70 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -489,9 +489,10 @@ impl Inner { 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 (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 + if l.embedded.is_some() { 1 << 40 } else { 0 }); + // by their embedded file; PDF pages by their number). + FootageKind::Still if footage.layer.is_some() || footage.page != 0 => { + 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 }) + + ((footage.page as i64) << 42); 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 }, @@ -622,7 +623,7 @@ impl FootageSource for MediaPool { return None; } let bytes = self.inner.read(&footage.path).ok()?; - crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), scale).map(Arc::new) + crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), footage.page, scale).map(Arc::new) } fn aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option> { diff --git a/crates/model/src/extrude.rs b/crates/model/src/extrude.rs index 344c67cb8..058be7620 100644 --- a/crates/model/src/extrude.rs +++ b/crates/model/src/extrude.rs @@ -299,3 +299,19 @@ pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive { } prim } + +/// Gap (pixels) between stacked extrusions of one layer: paint drawn later (a stroke over its +/// fill, an upper shape group) sits this much nearer the camera per level, so coplanar front +/// caps never fight in the depth buffer and the stacking order of the 2D layer is kept. +pub const STACK_GAP: f64 = 0.02; + +/// [`extrude`] at stacking level `level` (0 = bottom paint of the layer): the mesh moves +/// `level × STACK_GAP` towards the camera (−z). +pub fn extrude_stacked(outlines: &[Outline], p: &ExtrudeParams, level: usize) -> Primitive { + let mut prim = extrude(outlines, p); + let dz = (level as f64 * STACK_GAP) as f32; + for v in &mut prim.positions { + v[2] -= dz; + } + prim +} diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 1a3b13221..032ca47d7 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -360,3 +360,16 @@ fn gltf_cameras_and_punctual_lights() { let d = lights[0].world.apply_vec(vec3(0.0, 0.0, -1.0)); assert!((d.y + 1.0).abs() < 1e-6, "{d:?}"); } + +#[test] +fn stacked_extrusions_move_towards_the_camera() { + let square = Outline { outer: vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], holes: vec![] }; + let p = ExtrudeParams { bevel: BevelStyle::None, bevel_depth: 0.0, hole_bevel: 1.0, depth: 10.0 }; + let base = extrude(std::slice::from_ref(&square), &p); + let up = super::extrude::extrude_stacked(std::slice::from_ref(&square), &p, 3); + assert_eq!(base.indices, up.indices); + for (a, b) in base.positions.iter().zip(&up.positions) { + assert!((a[2] - b[2] - 3.0 * super::extrude::STACK_GAP as f32).abs() < 1e-6); + assert_eq!((a[0], a[1]), (b[0], b[1])); + } +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 93d082b5c..9b761f57b 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -1,6 +1,6 @@ [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)" +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, shadings, tiling patterns, clipping, text with embedded fonts, images, blend modes, soft masks, optional-content layers)" version.workspace = true edition.workspace = true license.workspace = true @@ -12,4 +12,14 @@ workspace = true [dependencies] effectcraft-svg = { workspace = true } kurbo = { workspace = true } +effectcraft-text = { workspace = true } miniz_oxide = "0.8" +# Embedded TrueType / OpenType font programs (MIT OR Apache-2.0). +skrifa = { workspace = true } +# DCT (JPEG) images (MIT OR Apache-2.0 OR Zlib; pure Rust). +zune-jpeg = "0.5" + +[dev-dependencies] +# Encodes JPEG fixtures for the DCT image test. +image = { workspace = true } +effectcraft-raster = { workspace = true } diff --git a/crates/pdf/README.md b/crates/pdf/README.md index a1a9d0b3e..43452da90 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -7,10 +7,15 @@ layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as comp 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). +structure, filters, content streams, graphics state, colour spaces, functions, shadings, text, +fonts, images, patterns, transparency (blend modes, soft masks) and optional content; Adobe +**Technical Note #5176** (The Compact Font Format Specification) and **#5177** (The Type 2 +Charstring Format) for CFF font programs; **Adobe Type 1 Font Format** (version 1.1) for Type 1 +font programs; the **PostScript Language Reference, third edition** and the **Encapsulated +PostScript File Format Specification 3.0** for EPS. TrueType font programs are read through +skrifa and JPEG (DCT) images through zune-jpeg (both permissively licensed, pure Rust). No +third-party PDF/PostScript/font code was read; test files (including the CFF and Type 1 fonts) +are generated in code ([`write`](src/write.rs) and the tests). ## What is read @@ -36,13 +41,52 @@ test files are generated in code ([`write`](src/write.rs) and the tests). binary headers and `%%HiResBoundingBox` / `%%BoundingBox` are honoured; `%%BeginData` / `%%BeginBinary` / preview sections are skipped. +- **Text** (§9): text objects and state (`Tc`, `Tw`, `Tz`, `TL`, `Ts`, `Tr`, `Tf`, `Td`, + `TD`, `Tm`, `T*`), `Tj`, `TJ` (with kerning), `'` and `"`; render modes fill / stroke / + fill-and-stroke / invisible and the clipping modes (glyph outlines clip until `Q`). Glyphs + become outlines (one compound shape per show operator, named "Text: …" after the string + through `ToUnicode` or the glyph names). Fonts: simple fonts with `/Widths`, base encodings + (Standard, WinAnsi, MacRoman), `/Differences` and built-in encodings; composite (Type 0) fonts + with Identity-H / Identity-V and embedded CMaps (`cidrange` / `cidchar`), `/W` and `/DW` + widths and `CIDToGIDMap`. Embedded programs: TrueType (`FontFile2`, cmap (3,1) / (3,0) / + (1,0) and `post` names), CFF (`FontFile3` `/Type1C`, `/CIDFontType0C`, `/OpenType`; + name-keyed and CID-keyed with FDArray / FDSelect, flex, hint masks, `seac`), Type 1 + (`FontFile`, eexec, `/Subrs`, flex and hint replacement through othersubrs, `seac`) and Type 3 + glyph procedures. Fonts that are not embedded (the standard 14 and others) are drawn with + the bundled OFL fonts by name: Inter (sans serif, Regular / Italic / SemiBold / Bold), Noto + Serif (Times and other serif names) and JetBrains Mono (Courier), with their own advances + when the PDF gives no widths. +- **Images** (§8.9): image XObjects and inline images (`BI … ID … EI`, abbreviated keys); + 1, 2, 4, 8 and 16 bits per component with `/Decode`; any colour space above (Indexed through + its palette, ICCBased through its `/Alternate` space); DCT (JPEG, through zune-jpeg; Adobe + inverted CMYK) and the general filters; stencil masks (`/ImageMask`) in the fill colour; + soft masks (`/SMask` images) and colour-key and explicit `/Mask`s. Images become image nodes + of the render tree (bilinear, box-filtered when minified). +- **Transparency:** blend modes (`/BM`, all 16 PDF modes) and soft masks in the graphics state + (`/SMask` with `/S /Luminosity` and `/BC` backdrops, or `/S /Alpha`) as groups of the render + tree, open until the state changes or `Q`. +- **Patterns:** tiling patterns (coloured and uncoloured, `XStep` / `YStep`, the cell clipped + to `/BBox`, at most 4096 tiles per fill) for fills; strokes with a tiling pattern use its + colour (uncoloured) or grey. +- **Functions:** sampled, exponential, stitching and PostScript calculator (type 4) functions. +- **Pages:** any page ([`parse_page`], [`page_count`]); File ▸ Import takes `page` (from 1) and + the Import dialog asks for it; footage remembers its page (`Footage::page`). + ## 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`. +- Mesh shadings (types 4–7) and function-based shadings (type 1), JPX / CCITT / JBIG2 images, + predefined CJK CMaps other than Identity, vertical metrics (`/W2`; vertical text advances by + the font size) and transfer functions (`/TR`) are not read; knockout and isolated transparency + groups composite as normal groups, and a soft mask applies to each painting operation while + it is set rather than to their composite. 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. + EPS text is not drawn. +- **Create Shapes from Vector Layer:** clips that enclose the whole page become layer masks + (the first clip Add, later ones Intersect). Nested clipping groups become Merge Paths: each + closed shape inside them is a group with its geometry merged into one compound path, the clip + paths, and Merge Paths ▸ Intersect before its fill and stroke, so fills match exactly and + strokes follow the clipped outline; open stroked paths inside clips stay unclipped. Blend + modes become the groups' Blend Mode; images and soft masks are not converted (shape layers + have no images; use the footage, or Import As: Composition). diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index ca063dee8..4eeeb53bd 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -19,7 +19,7 @@ pub(crate) struct Builder { } pub(crate) fn group(name: &str) -> Group { - Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![] } + Group::new(name) } impl Builder { @@ -70,7 +70,21 @@ impl Builder { self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain })); } + /// Open a group (blend mode, soft mask) closed with the graphics state. + pub fn open_group(&mut self, g: Group) { + self.stack.push((g, Kind::Plain)); + } + + /// Add a finished node (an image, pattern tiles). + pub fn push(&mut self, n: Node) { + self.add(n, false); + } + pub fn fill_stroke(&mut self, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option) { + self.fill_stroke_named("Path", path, transform, fill, stroke); + } + + pub fn fill_stroke_named(&mut self, name: &str, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option) { if path.elements().is_empty() || (fill.is_none() && stroke.is_none()) { return; } @@ -79,7 +93,7 @@ impl Builder { 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 }; + let s = Shape { name: name.into(), geom: Geom::Path(path), transform, fill: f, fill_opacity: fo, fill_rule: rule, stroke, opacity: 1.0 }; self.add(Node::Shape(s), false); } diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs new file mode 100644 index 000000000..7d647e7cc --- /dev/null +++ b/crates/pdf/src/cff.rs @@ -0,0 +1,1034 @@ +//! Compact Font Format programs (`FontFile3` `/Type1C` and `/CIDFontType0C`, and the `CFF ` +//! table of OpenType fonts), from Adobe Technical Note #5176 (The Compact Font Format +//! Specification) and #5177 (The Type 2 Charstring Format): INDEX and DICT structures, +//! charsets, encodings, CID-keyed FDArray / FDSelect, and a Type 2 charstring interpreter that +//! produces glyph outlines in glyph space. + +use kurbo::BezPath; + +/// The standard strings (SIDs 0–228: the ISOAdobe character set); SIDs 229–390 (the expert +/// set) are not named here and match no glyph name. +const STD: &[&str] = &[ + ".notdef", + "space", + "exclam", + "quotedbl", + "numbersign", + "dollar", + "percent", + "ampersand", + "quoteright", + "parenleft", + "parenright", + "asterisk", + "plus", + "comma", + "hyphen", + "period", + "slash", + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "colon", + "semicolon", + "less", + "equal", + "greater", + "question", + "at", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "bracketleft", + "backslash", + "bracketright", + "asciicircum", + "underscore", + "quoteleft", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "braceleft", + "bar", + "braceright", + "asciitilde", + "exclamdown", + "cent", + "sterling", + "fraction", + "yen", + "florin", + "section", + "currency", + "quotesingle", + "quotedblleft", + "guillemotleft", + "guilsinglleft", + "guilsinglright", + "fi", + "fl", + "endash", + "dagger", + "daggerdbl", + "periodcentered", + "paragraph", + "bullet", + "quotesinglbase", + "quotedblbase", + "quotedblright", + "guillemotright", + "ellipsis", + "perthousand", + "questiondown", + "grave", + "acute", + "circumflex", + "tilde", + "macron", + "breve", + "dotaccent", + "dieresis", + "ring", + "cedilla", + "hungarumlaut", + "ogonek", + "caron", + "emdash", + "AE", + "ordfeminine", + "Lslash", + "Oslash", + "OE", + "ordmasculine", + "ae", + "dotlessi", + "lslash", + "oslash", + "oe", + "germandbls", + "onesuperior", + "logicalnot", + "mu", + "trademark", + "Eth", + "onehalf", + "plusminus", + "Thorn", + "onequarter", + "divide", + "brokenbar", + "degree", + "thorn", + "threequarters", + "twosuperior", + "registered", + "minus", + "eth", + "multiply", + "threesuperior", + "copyright", + "Aacute", + "Acircumflex", + "Adieresis", + "Agrave", + "Aring", + "Atilde", + "Ccedilla", + "Eacute", + "Ecircumflex", + "Edieresis", + "Egrave", + "Iacute", + "Icircumflex", + "Idieresis", + "Igrave", + "Ntilde", + "Oacute", + "Ocircumflex", + "Odieresis", + "Ograve", + "Otilde", + "Scaron", + "Uacute", + "Ucircumflex", + "Udieresis", + "Ugrave", + "Yacute", + "Ydieresis", + "Zcaron", + "aacute", + "acircumflex", + "adieresis", + "agrave", + "aring", + "atilde", + "ccedilla", + "eacute", + "ecircumflex", + "edieresis", + "egrave", + "iacute", + "icircumflex", + "idieresis", + "igrave", + "ntilde", + "oacute", + "ocircumflex", + "odieresis", + "ograve", + "otilde", + "scaron", + "uacute", + "ucircumflex", + "udieresis", + "ugrave", + "yacute", + "ydieresis", + "zcaron", +]; + +const N_STD: usize = 391; + +/// An INDEX: the byte ranges of its objects. +fn index(d: &[u8], pos: usize) -> Option<(Vec>, usize)> { + let count = u16::from_be_bytes([*d.get(pos)?, *d.get(pos + 1)?]) as usize; + if count == 0 { + return Some((vec![], pos + 2)); + } + let os = *d.get(pos + 2)? as usize; + if !(1..=4).contains(&os) { + return None; + } + let off = |i: usize| -> Option { + let p = pos + 3 + i * os; + let mut v = 0usize; + for k in 0..os { + v = (v << 8) | *d.get(p + k)? as usize; + } + Some(v) + }; + let base = pos + 3 + (count + 1) * os - 1; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let (a, b) = (off(i)?, off(i + 1)?); + if b < a || base + b > d.len() { + return None; + } + out.push(base + a..base + b); + } + let end = base + off(count)?; + Some((out, end)) +} + +/// A DICT: (operator, operands) pairs; two-byte operators are `1200 + b1`. +fn dict(d: &[u8]) -> Vec<(u16, Vec)> { + let mut out = vec![]; + let mut ops: Vec = vec![]; + let mut i = 0; + while i < d.len() { + let b0 = d[i]; + match b0 { + 0..=21 => { + let op = if b0 == 12 { + i += 1; + 1200 + *d.get(i).unwrap_or(&0) as u16 + } else { + b0 as u16 + }; + out.push((op, std::mem::take(&mut ops))); + i += 1; + } + 28 => { + ops.push(i16::from_be_bytes([*d.get(i + 1).unwrap_or(&0), *d.get(i + 2).unwrap_or(&0)]) as f64); + i += 3; + } + 29 => { + let b = |k: usize| *d.get(i + k).unwrap_or(&0); + ops.push(i32::from_be_bytes([b(1), b(2), b(3), b(4)]) as f64); + i += 5; + } + 30 => { + // Real number: BCD nibbles. + let mut s = String::new(); + i += 1; + 'n: while i < d.len() { + for nib in [d[i] >> 4, d[i] & 15] { + match nib { + 0..=9 => s.push((b'0' + nib) as char), + 0xA => s.push('.'), + 0xB => s.push('E'), + 0xC => s.push_str("E-"), + 0xE => s.push('-'), + 0xF => { + i += 1; + break 'n; + } + _ => {} + } + } + i += 1; + } + ops.push(s.parse().unwrap_or(0.0)); + } + 32..=246 => { + ops.push(b0 as f64 - 139.0); + i += 1; + } + 247..=250 => { + ops.push((b0 as f64 - 247.0) * 256.0 + *d.get(i + 1).unwrap_or(&0) as f64 + 108.0); + i += 2; + } + 251..=254 => { + ops.push(-(b0 as f64 - 251.0) * 256.0 - *d.get(i + 1).unwrap_or(&0) as f64 - 108.0); + i += 2; + } + _ => i += 1, + } + } + out +} + +fn get(d: &[(u16, Vec)], op: u16) -> Option<&[f64]> { + d.iter().find(|(o, _)| *o == op).map(|(_, v)| v.as_slice()) +} + +/// One font of a CFF program. +#[derive(Clone, Debug)] +pub struct Cff { + data: Vec, + charstrings: Vec>, + gsubrs: Vec>, + /// Local subroutines of each Font DICT (one for name-keyed fonts). + lsubrs: Vec>>, + /// Nominal / default widths per Font DICT. + widths: Vec<(f64, f64)>, + /// Glyph → Font DICT (CID-keyed fonts). + fdselect: Vec, + /// Glyph → SID (name-keyed) or CID. + charset: Vec, + strings: Vec>, + pub cid: bool, + pub font_matrix: [f64; 6], + /// The built-in encoding: code → glyph. + pub encoding: [u16; 256], +} + +impl Cff { + pub fn parse(data: &[u8]) -> Option { + let d = data; + let hdr = *d.get(2)? as usize; + let (_names, p) = index(d, hdr)?; + let (tops, p) = index(d, p)?; + let (strings, p) = index(d, p)?; + let (gsubrs, _) = index(d, p)?; + let top = dict(&d[tops.first()?.clone()]); + let cs_off = get(&top, 17)?.first().copied()? as usize; + let (charstrings, _) = index(d, cs_off)?; + let n = charstrings.len(); + let cid = get(&top, 1230).is_some(); + let font_matrix = match get(&top, 1207) { + Some(m) if m.len() == 6 => [m[0], m[1], m[2], m[3], m[4], m[5]], + _ => [0.001, 0.0, 0.0, 0.001, 0.0, 0.0], + }; + let private = |pd: &[(u16, Vec)]| -> (Vec>, (f64, f64)) { + let Some(pv) = get(pd, 18).filter(|v| v.len() == 2) else { return (vec![], (0.0, 0.0)) }; + let (size, off) = (pv[0] as usize, pv[1] as usize); + let Some(bytes) = d.get(off..off + size) else { return (vec![], (0.0, 0.0)) }; + let pr = dict(bytes); + let nominal = get(&pr, 21).and_then(|v| v.first().copied()).unwrap_or(0.0); + let default = get(&pr, 20).and_then(|v| v.first().copied()).unwrap_or(0.0); + let subrs = get(&pr, 19).and_then(|v| v.first().copied()).and_then(|s| index(d, off + s as usize)).map(|x| x.0).unwrap_or_default(); + (subrs, (default, nominal)) + }; + let mut lsubrs = vec![]; + let mut widths = vec![]; + let mut fdselect = vec![]; + if cid { + if let Some(fa) = get(&top, 1236).and_then(|v| v.first().copied()) + && let Some((fds, _)) = index(d, fa as usize) + { + for r in fds { + let (s, w) = private(&dict(&d[r])); + lsubrs.push(s); + widths.push(w); + } + } + if let Some(fs) = get(&top, 1237).and_then(|v| v.first().copied()) { + fdselect = read_fdselect(d, fs as usize, n).unwrap_or_default(); + } + } else { + let (s, w) = private(&top); + lsubrs.push(s); + widths.push(w); + } + if lsubrs.is_empty() { + lsubrs.push(vec![]); + widths.push((0.0, 0.0)); + } + let charset = match get(&top, 15).and_then(|v| v.first().copied()).unwrap_or(0.0) as usize { + 0..=2 => (0..n as u16).collect(), + off => read_charset(d, off, n).unwrap_or_else(|| (0..n as u16).collect()), + }; + let mut cff = Cff { data: data.to_vec(), charstrings, gsubrs, lsubrs, widths, fdselect, charset, strings, cid, font_matrix, encoding: [0; 256] }; + let enc_off = get(&top, 16).and_then(|v| v.first().copied()).unwrap_or(0.0) as usize; + cff.encoding = cff.read_encoding(enc_off); + Some(cff) + } + + fn read_encoding(&self, off: usize) -> [u16; 256] { + let mut enc = [0u16; 256]; + if self.cid { + return enc; + } + if off <= 1 { + // Standard encoding by glyph name. + for code in 0..=255u8 { + if let Some(name) = crate::encoding::Base::Standard.name(code) + && let Some(g) = self.gid_by_name(&name) + { + enc[code as usize] = g; + } + } + return enc; + } + let d = &self.data; + let Some(&fmt) = d.get(off) else { return enc }; + let mut p = off + 1; + match fmt & 0x7F { + 0 => { + let n = *d.get(p).unwrap_or(&0) as usize; + p += 1; + for i in 0..n { + if let Some(&c) = d.get(p + i) { + enc[c as usize] = (i + 1) as u16; + } + } + p += n; + } + 1 => { + let n = *d.get(p).unwrap_or(&0) as usize; + p += 1; + let mut g = 1u16; + for _ in 0..n { + let (Some(&first), Some(&left)) = (d.get(p), d.get(p + 1)) else { break }; + for k in 0..=left as usize { + if let Some(e) = enc.get_mut(first as usize + k) { + *e = g; + } + g += 1; + } + p += 2; + } + } + _ => {} + } + if fmt & 0x80 != 0 { + let n = *d.get(p).unwrap_or(&0) as usize; + p += 1; + for _ in 0..n { + let (Some(&c), Some(&a), Some(&b)) = (d.get(p), d.get(p + 1), d.get(p + 2)) else { break }; + let sid = u16::from_be_bytes([a, b]); + if let Some(g) = self.charset.iter().position(|s| *s == sid) { + enc[c as usize] = g as u16; + } + p += 3; + } + } + enc + } + + #[cfg(test)] + pub fn glyph_count(&self) -> usize { + self.charstrings.len() + } + + fn sid_name(&self, sid: u16) -> Option<&str> { + let sid = sid as usize; + if sid < STD.len() { + return Some(STD[sid]); + } + if sid < N_STD { + return None; + } + let r = self.strings.get(sid - N_STD)?.clone(); + std::str::from_utf8(&self.data[r]).ok() + } + + /// A glyph by name (name-keyed fonts). + pub fn gid_by_name(&self, name: &str) -> Option { + if self.cid { + return None; + } + self.charset.iter().position(|&sid| self.sid_name(sid) == Some(name)).map(|g| g as u16) + } + + /// A glyph by CID (CID-keyed fonts; name-keyed fonts take the CID as the glyph index). + pub fn gid_by_cid(&self, cid: u16) -> Option { + if !self.cid { + return ((cid as usize) < self.charstrings.len()).then_some(cid); + } + self.charset.iter().position(|&c| c == cid).map(|g| g as u16) + } + + /// A glyph's outline in glyph space (`font_matrix` maps it to text space) and its advance. + pub fn outline(&self, gid: u16) -> Option<(BezPath, f64)> { + let r = self.charstrings.get(gid as usize)?.clone(); + let fd = self.fdselect.get(gid as usize).copied().unwrap_or(0) as usize; + let fd = fd.min(self.lsubrs.len() - 1); + let mut st = + T2 { cff: self, fd, path: BezPath::new(), x: 0.0, y: 0.0, stack: vec![], stems: 0, width: None, open: false, done: false, depth: 0, seac: None }; + st.run(&self.data[r]); + if st.open { + st.path.close_path(); + } + let width = match st.width { + Some(w) if !w.is_nan() => w + self.widths[fd].1, + _ => self.widths[fd].0, + }; + let mut path = st.path; + // endchar seac: base and accent glyphs by standard code. + if let Some((adx, ady, b, a)) = st.seac { + let find = |code: u8| crate::encoding::Base::Standard.name(code).and_then(|n| self.gid_by_name(&n)); + if let Some((bp, _)) = find(b).and_then(|g| self.outline(g)) { + path.extend(bp); + } + if let Some((ap, _)) = find(a).and_then(|g| self.outline(g)) { + path.extend(kurbo::Affine::translate((adx, ady)) * ap); + } + } + Some((path, width)) + } +} + +fn read_charset(d: &[u8], off: usize, n: usize) -> Option> { + let fmt = *d.get(off)?; + let mut out = vec![0u16]; + let mut p = off + 1; + let u16at = |p: usize| -> Option { Some(u16::from_be_bytes([*d.get(p)?, *d.get(p + 1)?])) }; + while out.len() < n { + match fmt { + 0 => { + out.push(u16at(p)?); + p += 2; + } + 1 | 2 => { + let first = u16at(p)?; + let left = if fmt == 1 { *d.get(p + 2)? as u16 } else { u16at(p + 2)? }; + p += if fmt == 1 { 3 } else { 4 }; + for k in 0..=left { + if out.len() >= n { + break; + } + out.push(first.wrapping_add(k)); + } + } + _ => return None, + } + } + Some(out) +} + +fn read_fdselect(d: &[u8], off: usize, n: usize) -> Option> { + match *d.get(off)? { + 0 => d.get(off + 1..off + 1 + n).map(<[u8]>::to_vec), + 3 => { + let nr = u16::from_be_bytes([*d.get(off + 1)?, *d.get(off + 2)?]) as usize; + let mut out = vec![0u8; n]; + for i in 0..nr { + let p = off + 3 + i * 3; + let first = u16::from_be_bytes([*d.get(p)?, *d.get(p + 1)?]) as usize; + let fd = *d.get(p + 2)?; + let next = u16::from_be_bytes([*d.get(p + 3)?, *d.get(p + 4)?]) as usize; + for g in first..next.min(n) { + out[g] = fd; + } + } + Some(out) + } + _ => None, + } +} + +fn bias(n: usize) -> i64 { + if n < 1240 { + 107 + } else if n < 33900 { + 1131 + } else { + 32768 + } +} + +/// Type 2 charstring interpreter state. +struct T2<'a> { + cff: &'a Cff, + fd: usize, + path: BezPath, + x: f64, + y: f64, + stack: Vec, + stems: usize, + width: Option, + open: bool, + done: bool, + depth: usize, + seac: Option<(f64, f64, u8, u8)>, +} + +impl T2<'_> { + fn move_to(&mut self, dx: f64, dy: f64) { + if self.open { + self.path.close_path(); + } + self.x += dx; + self.y += dy; + self.path.move_to((self.x, self.y)); + self.open = true; + } + fn line_to(&mut self, dx: f64, dy: f64) { + self.x += dx; + self.y += dy; + self.path.line_to((self.x, self.y)); + } + fn curve(&mut self, d: [f64; 6]) { + let (x1, y1) = (self.x + d[0], self.y + d[1]); + let (x2, y2) = (x1 + d[2], y1 + d[3]); + self.x = x2 + d[4]; + self.y = y2 + d[5]; + self.path.curve_to((x1, y1), (x2, y2), (self.x, self.y)); + } + /// The width argument before the first stack-clearing operator. + fn take_width(&mut self, expected_even: bool) { + if self.width.is_none() { + let odd = self.stack.len() % 2 == 1; + if expected_even == odd && !self.stack.is_empty() { + self.width = Some(self.stack.remove(0)); + } else { + self.width = Some(f64::NAN); + } + } + } + + fn run(&mut self, cs: &[u8]) { + if self.depth > 10 { + return; + } + let mut i = 0; + while i < cs.len() && !self.done { + let b0 = cs[i]; + i += 1; + match b0 { + 32..=246 => self.stack.push(b0 as f64 - 139.0), + 247..=250 => { + self.stack.push((b0 as f64 - 247.0) * 256.0 + *cs.get(i).unwrap_or(&0) as f64 + 108.0); + i += 1; + } + 251..=254 => { + self.stack.push(-(b0 as f64 - 251.0) * 256.0 - *cs.get(i).unwrap_or(&0) as f64 - 108.0); + i += 1; + } + 28 => { + self.stack.push(i16::from_be_bytes([*cs.get(i).unwrap_or(&0), *cs.get(i + 1).unwrap_or(&0)]) as f64); + i += 2; + } + 255 => { + let b = |k: usize| *cs.get(i + k).unwrap_or(&0); + self.stack.push(i32::from_be_bytes([b(0), b(1), b(2), b(3)]) as f64 / 65536.0); + i += 4; + } + 1 | 3 | 18 | 23 => { + self.take_width(true); + self.stems += self.stack.len() / 2; + self.stack.clear(); + } + 19 | 20 => { + self.take_width(true); + self.stems += self.stack.len() / 2; + self.stack.clear(); + i += self.stems.div_ceil(8); + } + 21 => { + self.take_width(true); + let s = self.args(); + let n = s.len(); + if n >= 2 { + self.move_to(s[n - 2], s[n - 1]); + } + } + 22 | 4 => { + self.take_width(false); + let s = self.args(); + let v = s.last().copied().unwrap_or(0.0); + if b0 == 22 { self.move_to(v, 0.0) } else { self.move_to(0.0, v) } + } + 5 => { + let s = self.args(); + for c in s.chunks_exact(2) { + self.line_to(c[0], c[1]); + } + } + 6 | 7 => { + let s = self.args(); + let mut horiz = b0 == 6; + for v in s { + if horiz { + self.line_to(v, 0.0) + } else { + self.line_to(0.0, v) + } + horiz = !horiz; + } + } + 8 => { + let s = self.args(); + for c in s.chunks_exact(6) { + self.curve([c[0], c[1], c[2], c[3], c[4], c[5]]); + } + } + 24 => { + // rcurveline + let s = self.args(); + let nc = s.len().saturating_sub(2) / 6; + for c in s[..nc * 6].chunks_exact(6) { + self.curve([c[0], c[1], c[2], c[3], c[4], c[5]]); + } + if s.len() >= nc * 6 + 2 { + self.line_to(s[nc * 6], s[nc * 6 + 1]); + } + } + 25 => { + // rlinecurve + let s = self.args(); + if s.len() >= 6 { + let nl = (s.len() - 6) / 2; + for c in s[..nl * 2].chunks_exact(2) { + self.line_to(c[0], c[1]); + } + let c = &s[nl * 2..]; + self.curve([c[0], c[1], c[2], c[3], c[4], c[5]]); + } + } + 26 => { + // vvcurveto + let mut s = self.args(); + let mut dx1 = 0.0; + if s.len() % 4 == 1 { + dx1 = s.remove(0); + } + for c in s.chunks_exact(4) { + self.curve([dx1, c[0], c[1], c[2], 0.0, c[3]]); + dx1 = 0.0; + } + } + 27 => { + // hhcurveto + let mut s = self.args(); + let mut dy1 = 0.0; + if s.len() % 4 == 1 { + dy1 = s.remove(0); + } + for c in s.chunks_exact(4) { + self.curve([c[0], dy1, c[1], c[2], c[3], 0.0]); + dy1 = 0.0; + } + } + 30 | 31 => { + // vhcurveto / hvcurveto: alternating tangents, optional final delta. + let s = self.args(); + let mut horiz = b0 == 31; + let mut k = 0; + while k + 4 <= s.len() { + let last = k + 4 == s.len() - 1; + let extra = if last { s[k + 4] } else { 0.0 }; + if horiz { + self.curve([s[k], 0.0, s[k + 1], s[k + 2], extra, s[k + 3]]); + } else { + self.curve([0.0, s[k], s[k + 1], s[k + 2], s[k + 3], extra]); + } + horiz = !horiz; + k += 4; + if last { + break; + } + } + } + 10 | 29 => { + let Some(idx) = self.stack.pop() else { continue }; + let subrs = if b0 == 10 { &self.cff.lsubrs[self.fd] } else { &self.cff.gsubrs }; + let k = idx as i64 + bias(subrs.len()); + if let Some(r) = usize::try_from(k).ok().and_then(|k| subrs.get(k)).cloned() { + self.depth += 1; + let data = &self.cff.data[r]; + self.run(data); + self.depth -= 1; + } + } + 11 => return, + 14 => { + self.take_width(false); + if self.stack.len() >= 4 { + let n = self.stack.len(); + let s = &self.stack[n - 4..]; + self.seac = Some((s[0], s[1], s[2] as u8, s[3] as u8)); + } + self.stack.clear(); + self.done = true; + } + 12 => { + let b1 = *cs.get(i).unwrap_or(&0); + i += 1; + self.escape(b1); + } + _ => self.stack.clear(), + } + } + } + + fn args(&mut self) -> Vec { + std::mem::take(&mut self.stack) + } + + fn escape(&mut self, b1: u8) { + let pop = |s: &mut Vec| s.pop().unwrap_or(0.0); + match b1 { + 35 => { + // flex: two curves. + let s = self.args(); + if s.len() >= 12 { + self.curve([s[0], s[1], s[2], s[3], s[4], s[5]]); + self.curve([s[6], s[7], s[8], s[9], s[10], s[11]]); + } + } + 34 => { + // hflex: dx1 dx2 dy2 dx3 dx4 dx5 dx6. + let s = self.args(); + if s.len() >= 7 { + self.curve([s[0], 0.0, s[1], s[2], s[3], 0.0]); + self.curve([s[4], 0.0, s[5], -s[2], s[6], 0.0]); + } + } + 36 => { + // hflex1: dx1 dy1 dx2 dy2 dx3 dx4 dx5 dy5 dx6. + let s = self.args(); + if s.len() >= 9 { + self.curve([s[0], s[1], s[2], s[3], s[4], 0.0]); + self.curve([s[5], 0.0, s[6], s[7], s[8], -(s[1] + s[3] + s[7])]); + } + } + 37 => { + // flex1 + let s = self.args(); + if s.len() >= 11 { + let (x0, y0) = (self.x, self.y); + let dx: f64 = s[0] + s[2] + s[4] + s[6] + s[8]; + let dy: f64 = s[1] + s[3] + s[5] + s[7] + s[9]; + self.curve([s[0], s[1], s[2], s[3], s[4], s[5]]); + let (lx, ly) = if dx.abs() > dy.abs() { (s[10], y0 - (self.y + s[7] + s[9])) } else { (x0 - (self.x + s[6] + s[8]), s[10]) }; + self.curve([s[6], s[7], s[8], s[9], lx, ly]); + } + } + 9 => { + let a = pop(&mut self.stack); + self.stack.push(a.abs()); + } + 10 => { + let (b, a) = (pop(&mut self.stack), pop(&mut self.stack)); + self.stack.push(a + b); + } + 11 => { + let (b, a) = (pop(&mut self.stack), pop(&mut self.stack)); + self.stack.push(a - b); + } + 12 => { + let (b, a) = (pop(&mut self.stack), pop(&mut self.stack)); + self.stack.push(if b != 0.0 { a / b } else { 0.0 }); + } + 14 => { + let a = pop(&mut self.stack); + self.stack.push(-a); + } + 18 => { + self.stack.pop(); + } + 24 => { + let (b, a) = (pop(&mut self.stack), pop(&mut self.stack)); + self.stack.push(a * b); + } + 27 => { + let a = pop(&mut self.stack); + self.stack.push(a); + self.stack.push(a); + } + 28 => { + let (b, a) = (pop(&mut self.stack), pop(&mut self.stack)); + self.stack.push(b); + self.stack.push(a); + } + _ => self.stack.clear(), + } + } +} + +/// A minimal CFF writer for tests: one name-keyed font whose glyphs are Type 2 charstrings, +/// named by custom strings. +#[cfg(test)] +pub(crate) fn write_test_cff(glyphs: &[(&str, Vec)]) -> Vec { + fn idx(items: &[Vec]) -> Vec { + let mut out = (items.len() as u16).to_be_bytes().to_vec(); + if items.is_empty() { + return out; + } + out.push(4); + let mut off = 1u32; + out.extend_from_slice(&off.to_be_bytes()); + for it in items { + off += it.len() as u32; + out.extend_from_slice(&off.to_be_bytes()); + } + for it in items { + out.extend_from_slice(it); + } + out + } + let int = |v: i32| { + let mut o = vec![29]; + o.extend_from_slice(&v.to_be_bytes()); + o + }; + let names = idx(&[b"Test".to_vec()]); + let strings: Vec> = glyphs.iter().skip(1).map(|(n, _)| n.as_bytes().to_vec()).collect(); + let strings_idx = idx(&strings); + let gsubrs = idx(&[]); + let cs = idx(&glyphs.iter().map(|(_, c)| c.clone()).collect::>()); + // charset format 0: SIDs of glyphs 1.. (custom strings from 391). + let mut charset = vec![0u8]; + for i in 0..glyphs.len() - 1 { + charset.extend_from_slice(&((N_STD + i) as u16).to_be_bytes()); + } + // Top DICT with fixed-size operands so offsets can be computed first. + let top_len = 4 * 5 + 3; + let header = [1u8, 0, 4, 4]; + let top_index_len = 2 + 1 + 8 + top_len; + let base = header.len() + names.len() + top_index_len + strings_idx.len() + gsubrs.len(); + let charset_off = base; + let cs_off = charset_off + charset.len(); + let priv_off = cs_off + cs.len(); + let mut top = vec![]; + top.extend(int(charset_off as i32)); + top.push(15); + top.extend(int(cs_off as i32)); + top.push(17); + top.extend(int(0)); + top.extend(int(priv_off as i32)); + top.push(18); + assert_eq!(top.len(), top_len); + let mut out = header.to_vec(); + out.extend(names); + out.extend(idx(&[top])); + out.extend(strings_idx); + out.extend(gsubrs); + out.extend(charset); + out.extend(cs); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape; + + #[test] + fn standard_strings_cover_isoadobe() { + assert_eq!(STD.len(), 229); + assert_eq!(STD[34], "A"); + assert_eq!(STD[66], "a"); + assert_eq!(STD[149], "germandbls"); + assert_eq!(STD[228], "zcaron"); + } + + #[test] + fn type2_charstrings_draw_outlines() { + // A 500-unit square with a width: `w dx dy rmoveto dx hlineto dy vlineto dx hlineto endchar`. + let enc = |v: i32| -> Vec { + if (-107..=107).contains(&v) { + vec![(v + 139) as u8] + } else { + let mut o = vec![28]; + o.extend_from_slice(&(v as i16).to_be_bytes()); + o + } + }; + let mut square = vec![]; + for v in [600, 50, 0] { + square.extend(enc(v)); + } + square.push(21); + for v in [500, 500, -500] { + square.extend(enc(v)); + } + square.push(6); + square.push(14); + let font = write_test_cff(&[(".notdef", vec![14]), ("square", square)]); + let cff = Cff::parse(&font).unwrap(); + assert_eq!(cff.glyph_count(), 2); + let g = cff.gid_by_name("square").unwrap(); + assert_eq!(g, 1); + let (p, w) = cff.outline(g).unwrap(); + assert_eq!(w, 600.0); + assert!((p.area().abs() - 250_000.0).abs() < 1e-6, "{}", p.area()); + assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); + } +} diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs index b35e9f39f..256f5d2fd 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -1,5 +1,5 @@ //! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled, -//! exponential and stitching; PostScript calculator functions are not evaluated). +//! exponential, stitching and PostScript calculator functions). use crate::object::{Dict, File, Obj, decode_stream}; @@ -98,7 +98,13 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs { 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; + let d = a.get(1).map(|s| file.resolve(s)).and_then(Obj::dict); + // The profile's alternate space when it names one (profiles are not + // evaluated), else the device space with the same number of components. + if let Some(alt) = d.and_then(|d| file.get(d, "Alternate")) { + return color_space(file, alt, res); + } + let n = d.and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize; match n { 1 => Cs::Gray, 4 => Cs::Cmyk, @@ -163,9 +169,197 @@ pub enum Func { }, /// An array of one-output functions. Array(Vec), + /// Type 4: a PostScript calculator program (§7.10.5). + Calc { + domain: Vec, + range: Vec, + code: Vec, + }, Unsupported, } +/// A PostScript calculator token; procedures nest for `if` / `ifelse`. +#[derive(Clone, Debug, PartialEq)] +pub enum PsOp { + Num(f64), + Op(String), + Proc(Vec), +} + +fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec { + let mut out = vec![]; + while let Some(o) = lx.next() { + match o { + Obj::Num(n) => out.push(PsOp::Num(n)), + Obj::Bool(b) => out.push(PsOp::Num(if b { 1.0 } else { 0.0 })), + Obj::Op(op) if op == "{" => { + if depth < 32 { + out.push(PsOp::Proc(ps_parse(lx, depth + 1))); + } + } + Obj::Op(op) if op == "}" => return out, + Obj::Op(op) => out.push(PsOp::Op(op)), + _ => {} + } + } + out +} + +fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { + if depth > 32 { + return; + } + let mut procs: Vec<&[PsOp]> = vec![]; + for op in code { + match op { + PsOp::Num(n) => st.push(*n), + PsOp::Proc(p) => procs.push(p), + PsOp::Op(o) => { + let pop = |st: &mut Vec| st.pop().unwrap_or(0.0); + let b = |v: bool| if v { 1.0 } else { 0.0 }; + match o.as_str() { + "if" => { + let p = procs.pop(); + if pop(st) != 0.0 + && let Some(p) = p + { + ps_run(p, st, depth + 1); + } + } + "ifelse" => { + let (f, t) = (procs.pop(), procs.pop()); + let c = pop(st) != 0.0; + if let Some(p) = if c { t } else { f } { + ps_run(p, st, depth + 1); + } + } + "true" => st.push(1.0), + "false" => st.push(0.0), + "pop" => { + st.pop(); + } + "dup" => { + let v = st.last().copied().unwrap_or(0.0); + st.push(v); + } + "exch" => { + let n = st.len(); + if n >= 2 { + st.swap(n - 1, n - 2); + } + } + "copy" => { + let k = pop(st).max(0.0) as usize; + let n = st.len(); + if k <= n { + let tail = st[n - k..].to_vec(); + st.extend(tail); + } + } + "index" => { + let k = pop(st).max(0.0) as usize; + let v = st.len().checked_sub(k + 1).map(|i| st[i]).unwrap_or(0.0); + st.push(v); + } + "roll" => { + let j = pop(st) as i64; + let n = pop(st).max(0.0) as usize; + let len = st.len(); + if n > 0 && n <= len { + let s = &mut st[len - n..]; + let r = j.rem_euclid(n as i64) as usize; + s.rotate_right(r); + } + } + _ => { + // Unary and binary operators. + let unary: Option f64> = match o.as_str() { + "abs" => Some(f64::abs), + "neg" => Some(|x| -x), + "ceiling" => Some(f64::ceil), + "floor" => Some(f64::floor), + "round" => Some(|x: f64| (x + 0.5).floor()), + "truncate" | "cvi" => Some(f64::trunc), + "cvr" => Some(|x| x), + "sqrt" => Some(|x: f64| x.max(0.0).sqrt()), + "sin" => Some(|x: f64| x.to_radians().sin()), + "cos" => Some(|x: f64| x.to_radians().cos()), + "ln" => Some(|x: f64| x.max(1e-300).ln()), + "log" => Some(|x: f64| x.max(1e-300).log10()), + "not" => Some(|x: f64| { + if x == 0.0 { + 1.0 + } else if x == 1.0 { + 0.0 + } else { + !(x as i64) as f64 + } + }), + _ => None, + }; + if let Some(f) = unary { + let x = pop(st); + st.push(f(x)); + continue; + } + let y = pop(st); + let x = pop(st); + let v = match o.as_str() { + "add" => x + y, + "sub" => x - y, + "mul" => x * y, + "div" => { + if y != 0.0 { + x / y + } else { + 0.0 + } + } + "idiv" => { + if y as i64 != 0 { + ((x as i64) / (y as i64)) as f64 + } else { + 0.0 + } + } + "mod" => { + if y as i64 != 0 { + ((x as i64) % (y as i64)) as f64 + } else { + 0.0 + } + } + "exp" => x.powf(y), + "atan" => { + let a = x.atan2(y).to_degrees(); + if a < 0.0 { a + 360.0 } else { a } + } + "eq" => b(x == y), + "ne" => b(x != y), + "gt" => b(x > y), + "ge" => b(x >= y), + "lt" => b(x < y), + "le" => b(x <= y), + "and" => ((x as i64) & (y as i64)) as f64, + "or" => ((x as i64) | (y as i64)) as f64, + "xor" => ((x as i64) ^ (y as i64)) as f64, + "bitshift" => { + let (v, s) = (x as i64, y as i64); + (if s >= 0 { v << s.min(62) } else { v >> (-s).min(62) }) as f64 + } + _ => { + st.push(x); + y + } + }; + st.push(v); + } + } + } + } + } +} + impl Func { pub fn read(file: &File, o: &Obj) -> Func { let o = file.resolve(o); @@ -215,6 +409,17 @@ impl Func { } Func::Sampled { domain, size, outputs, encode, decode, samples } } + 4 => { + let Obj::Stream(sd, raw) = o else { return Func::Unsupported }; + let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported }; + let mut lx = crate::object::Lexer::new(&data, 0); + // The program is one procedure: `{ … }`. + let code = match ps_parse(&mut lx, 0).into_iter().next() { + Some(PsOp::Proc(p)) => p, + _ => return Func::Unsupported, + }; + Func::Calc { domain: dom, range: file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default(), code } + } _ => Func::Unsupported, } } @@ -222,6 +427,20 @@ impl Func { /// 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 { + if let Func::Calc { domain, range, code } = self { + let mut st: Vec = input + .iter() + .enumerate() + .map(|(i, v)| match (domain.get(2 * i), domain.get(2 * i + 1)) { + (Some(a), Some(b)) => v.clamp(a.min(*b), a.max(*b)), + _ => *v, + }) + .collect(); + ps_run(code, &mut st, 0); + let n = range.len() / 2; + let k = st.len().saturating_sub(n); + return st[k..].iter().enumerate().map(|(i, v)| v.clamp(range[2 * i], range[2 * i + 1])).collect(); + } let t = input.first().copied().unwrap_or(0.0); self.eval1(t) } @@ -262,6 +481,7 @@ impl Func { .collect() } Func::Array(fs) => fs.iter().map(|f| f.eval1(t).first().copied().unwrap_or(0.0)).collect(), + Func::Calc { .. } => self.eval(&[t]), Func::Unsupported => vec![0.5], } } @@ -286,3 +506,19 @@ pub fn text_string(b: &[u8]) -> String { } b.iter().map(|&c| c as char).collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calculator_functions() { + // Two inputs → three outputs: { 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } if 0 } + let code = b"{ 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } { 0.25 mul } ifelse 0 }"; + let mut lx = crate::object::Lexer::new(code, 0); + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0).into_iter().next() else { panic!() }; + let f = Func::Calc { domain: vec![0.0, 1.0, 0.0, 1.0], range: vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0], code }; + assert_eq!(f.eval(&[0.5, 0.4]), vec![0.2, 0.1125, 0.0]); + assert_eq!(f.eval(&[1.0, 0.8]), vec![0.8, 1.0, 0.0]); + } +} diff --git a/crates/pdf/src/encoding.rs b/crates/pdf/src/encoding.rs new file mode 100644 index 000000000..ebb63c321 --- /dev/null +++ b/crates/pdf/src/encoding.rs @@ -0,0 +1,389 @@ +//! Simple-font encodings (ISO 32000-1 §9.6.6 and Annex D): StandardEncoding, WinAnsiEncoding +//! and MacRomanEncoding as code → Unicode tables, and the Latin glyph names those encodings +//! use (with `uniXXXX` / `uXXXX[XX]` names for everything else). + +/// Glyph names of the Latin text character set (Annex D.2), with their Unicode values. +const NAMES: &[(&str, u32)] = &[ + ("space", 0x20), + ("exclam", 0x21), + ("quotedbl", 0x22), + ("numbersign", 0x23), + ("dollar", 0x24), + ("percent", 0x25), + ("ampersand", 0x26), + ("quotesingle", 0x27), + ("parenleft", 0x28), + ("parenright", 0x29), + ("asterisk", 0x2A), + ("plus", 0x2B), + ("comma", 0x2C), + ("hyphen", 0x2D), + ("period", 0x2E), + ("slash", 0x2F), + ("zero", 0x30), + ("one", 0x31), + ("two", 0x32), + ("three", 0x33), + ("four", 0x34), + ("five", 0x35), + ("six", 0x36), + ("seven", 0x37), + ("eight", 0x38), + ("nine", 0x39), + ("colon", 0x3A), + ("semicolon", 0x3B), + ("less", 0x3C), + ("equal", 0x3D), + ("greater", 0x3E), + ("question", 0x3F), + ("at", 0x40), + ("bracketleft", 0x5B), + ("backslash", 0x5C), + ("bracketright", 0x5D), + ("asciicircum", 0x5E), + ("underscore", 0x5F), + ("grave", 0x60), + ("braceleft", 0x7B), + ("bar", 0x7C), + ("braceright", 0x7D), + ("asciitilde", 0x7E), + ("exclamdown", 0xA1), + ("cent", 0xA2), + ("sterling", 0xA3), + ("currency", 0xA4), + ("yen", 0xA5), + ("brokenbar", 0xA6), + ("section", 0xA7), + ("dieresis", 0xA8), + ("copyright", 0xA9), + ("ordfeminine", 0xAA), + ("guillemotleft", 0xAB), + ("logicalnot", 0xAC), + ("registered", 0xAE), + ("macron", 0xAF), + ("degree", 0xB0), + ("plusminus", 0xB1), + ("twosuperior", 0xB2), + ("threesuperior", 0xB3), + ("acute", 0xB4), + ("mu", 0xB5), + ("paragraph", 0xB6), + ("periodcentered", 0xB7), + ("cedilla", 0xB8), + ("onesuperior", 0xB9), + ("ordmasculine", 0xBA), + ("guillemotright", 0xBB), + ("onequarter", 0xBC), + ("onehalf", 0xBD), + ("threequarters", 0xBE), + ("questiondown", 0xBF), + ("Agrave", 0xC0), + ("Aacute", 0xC1), + ("Acircumflex", 0xC2), + ("Atilde", 0xC3), + ("Adieresis", 0xC4), + ("Aring", 0xC5), + ("AE", 0xC6), + ("Ccedilla", 0xC7), + ("Egrave", 0xC8), + ("Eacute", 0xC9), + ("Ecircumflex", 0xCA), + ("Edieresis", 0xCB), + ("Igrave", 0xCC), + ("Iacute", 0xCD), + ("Icircumflex", 0xCE), + ("Idieresis", 0xCF), + ("Eth", 0xD0), + ("Ntilde", 0xD1), + ("Ograve", 0xD2), + ("Oacute", 0xD3), + ("Ocircumflex", 0xD4), + ("Otilde", 0xD5), + ("Odieresis", 0xD6), + ("multiply", 0xD7), + ("Oslash", 0xD8), + ("Ugrave", 0xD9), + ("Uacute", 0xDA), + ("Ucircumflex", 0xDB), + ("Udieresis", 0xDC), + ("Yacute", 0xDD), + ("Thorn", 0xDE), + ("germandbls", 0xDF), + ("agrave", 0xE0), + ("aacute", 0xE1), + ("acircumflex", 0xE2), + ("atilde", 0xE3), + ("adieresis", 0xE4), + ("aring", 0xE5), + ("ae", 0xE6), + ("ccedilla", 0xE7), + ("egrave", 0xE8), + ("eacute", 0xE9), + ("ecircumflex", 0xEA), + ("edieresis", 0xEB), + ("igrave", 0xEC), + ("iacute", 0xED), + ("icircumflex", 0xEE), + ("idieresis", 0xEF), + ("eth", 0xF0), + ("ntilde", 0xF1), + ("ograve", 0xF2), + ("oacute", 0xF3), + ("ocircumflex", 0xF4), + ("otilde", 0xF5), + ("odieresis", 0xF6), + ("divide", 0xF7), + ("oslash", 0xF8), + ("ugrave", 0xF9), + ("uacute", 0xFA), + ("ucircumflex", 0xFB), + ("udieresis", 0xFC), + ("yacute", 0xFD), + ("thorn", 0xFE), + ("ydieresis", 0xFF), + ("dotlessi", 0x131), + ("Lslash", 0x141), + ("lslash", 0x142), + ("OE", 0x152), + ("oe", 0x153), + ("Scaron", 0x160), + ("scaron", 0x161), + ("Ydieresis", 0x178), + ("Zcaron", 0x17D), + ("zcaron", 0x17E), + ("florin", 0x192), + ("circumflex", 0x2C6), + ("caron", 0x2C7), + ("breve", 0x2D8), + ("dotaccent", 0x2D9), + ("ring", 0x2DA), + ("ogonek", 0x2DB), + ("tilde", 0x2DC), + ("hungarumlaut", 0x2DD), + ("Omega", 0x3A9), + ("pi", 0x3C0), + ("endash", 0x2013), + ("emdash", 0x2014), + ("quoteleft", 0x2018), + ("quoteright", 0x2019), + ("quotesinglbase", 0x201A), + ("quotedblleft", 0x201C), + ("quotedblright", 0x201D), + ("quotedblbase", 0x201E), + ("dagger", 0x2020), + ("daggerdbl", 0x2021), + ("bullet", 0x2022), + ("ellipsis", 0x2026), + ("perthousand", 0x2030), + ("guilsinglleft", 0x2039), + ("guilsinglright", 0x203A), + ("fraction", 0x2044), + ("Euro", 0x20AC), + ("trademark", 0x2122), + ("partialdiff", 0x2202), + ("Delta", 0x2206), + ("product", 0x220F), + ("summation", 0x2211), + ("minus", 0x2212), + ("radical", 0x221A), + ("infinity", 0x221E), + ("integral", 0x222B), + ("approxequal", 0x2248), + ("notequal", 0x2260), + ("lessequal", 0x2264), + ("greaterequal", 0x2265), + ("lozenge", 0x25CA), + ("fi", 0xFB01), + ("fl", 0xFB02), + ("nbspace", 0xA0), + ("sfthyphen", 0xAD), +]; + +/// Unicode value of a glyph name (`A`, `eacute`, `uni00E9`, `u1F600`, `f_i`→first part). +pub fn name_to_unicode(name: &str) -> Option { + let base = name.split('.').next().unwrap_or(name); + if base.len() == 1 && base.as_bytes()[0].is_ascii_alphabetic() { + return base.chars().next(); + } + if let Some((_, u)) = NAMES.iter().find(|(n, _)| *n == base) { + return char::from_u32(*u); + } + if let Some(h) = base.strip_prefix("uni") + && h.len() >= 4 + { + return u32::from_str_radix(&h[..4], 16).ok().and_then(char::from_u32); + } + if let Some(h) = base.strip_prefix('u') + && (4..=6).contains(&h.len()) + { + return u32::from_str_radix(h, 16).ok().and_then(char::from_u32); + } + // Ligature names (`f_f_i`): the first component. + if let Some((a, _)) = base.split_once('_') { + return name_to_unicode(a); + } + None +} + +/// The glyph name of a Unicode value (Latin names, else `uniXXXX`). +pub fn unicode_to_name(c: char) -> String { + let u = c as u32; + if c.is_ascii_alphabetic() { + return c.to_string(); + } + match NAMES.iter().find(|(_, v)| *v == u) { + Some((n, _)) => (*n).to_string(), + None => format!("uni{u:04X}"), + } +} + +/// A simple font's base encoding. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Base { + Standard, + WinAnsi, + MacRoman, +} + +impl Base { + pub fn from_name(n: &str) -> Option { + match n { + "StandardEncoding" => Some(Base::Standard), + "WinAnsiEncoding" => Some(Base::WinAnsi), + "MacRomanEncoding" => Some(Base::MacRoman), + _ => None, + } + } + + /// The character of a code (`None` for unassigned codes). + pub fn unicode(self, code: u8) -> Option { + let u = match self { + Base::Standard => standard(code)?, + Base::WinAnsi => win_ansi(code)?, + Base::MacRoman => mac_roman(code)?, + }; + char::from_u32(u) + } + + /// The glyph name of a code. + pub fn name(self, code: u8) -> Option { + self.unicode(code).map(unicode_to_name) + } +} + +fn ascii(code: u8) -> Option { + (0x20..0x7F).contains(&code).then_some(code as u32) +} + +fn standard(code: u8) -> Option { + Some(match code { + 0x27 => 0x2019, + 0x60 => 0x2018, + 0x20..=0x7E => code as u32, + 0xA1 => 0xA1, + 0xA2 => 0xA2, + 0xA3 => 0xA3, + 0xA4 => 0x2044, + 0xA5 => 0xA5, + 0xA6 => 0x192, + 0xA7 => 0xA7, + 0xA8 => 0xA4, + 0xA9 => 0x27, + 0xAA => 0x201C, + 0xAB => 0xAB, + 0xAC => 0x2039, + 0xAD => 0x203A, + 0xAE => 0xFB01, + 0xAF => 0xFB02, + 0xB1 => 0x2013, + 0xB2 => 0x2020, + 0xB3 => 0x2021, + 0xB4 => 0xB7, + 0xB6 => 0xB6, + 0xB7 => 0x2022, + 0xB8 => 0x201A, + 0xB9 => 0x201E, + 0xBA => 0x201D, + 0xBB => 0xBB, + 0xBC => 0x2026, + 0xBD => 0x2030, + 0xBF => 0xBF, + 0xC1 => 0x60, + 0xC2 => 0xB4, + 0xC3 => 0x2C6, + 0xC4 => 0x2DC, + 0xC5 => 0xAF, + 0xC6 => 0x2D8, + 0xC7 => 0x2D9, + 0xC8 => 0xA8, + 0xCA => 0x2DA, + 0xCB => 0xB8, + 0xCD => 0x2DD, + 0xCE => 0x2DB, + 0xCF => 0x2C7, + 0xD0 => 0x2014, + 0xE1 => 0xC6, + 0xE3 => 0xAA, + 0xE8 => 0x141, + 0xE9 => 0xD8, + 0xEA => 0x152, + 0xEB => 0xBA, + 0xF1 => 0xE6, + 0xF5 => 0x131, + 0xF8 => 0x142, + 0xF9 => 0xF8, + 0xFA => 0x153, + 0xFB => 0xDF, + _ => return None, + }) +} + +/// Windows code page 1252 (WinAnsiEncoding; bullets for the unused codes, as Annex D notes). +fn win_ansi(code: u8) -> Option { + const HIGH: [u32; 32] = [ + 0x20AC, 0x2022, 0x201A, 0x192, 0x201E, 0x2026, 0x2020, 0x2021, 0x2C6, 0x2030, 0x160, 0x2039, 0x152, 0x2022, 0x17D, 0x2022, 0x2022, 0x2018, 0x2019, + 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x2DC, 0x2122, 0x161, 0x203A, 0x153, 0x2022, 0x17E, 0x178, + ]; + match code { + 0x80..=0x9F => Some(HIGH[(code - 0x80) as usize]), + 0xA0 => Some(0x20), + 0xA1..=0xFF => Some(code as u32), + _ => ascii(code), + } +} + +fn mac_roman(code: u8) -> Option { + const HIGH: [u32; 128] = [ + 0xC4, 0xC5, 0xC7, 0xC9, 0xD1, 0xD6, 0xDC, 0xE1, 0xE0, 0xE2, 0xE4, 0xE3, 0xE5, 0xE7, 0xE9, 0xE8, 0xEA, 0xEB, 0xED, 0xEC, 0xEE, 0xEF, 0xF1, 0xF3, 0xF2, + 0xF4, 0xF6, 0xF5, 0xFA, 0xF9, 0xFB, 0xFC, 0x2020, 0xB0, 0xA2, 0xA3, 0xA7, 0x2022, 0xB6, 0xDF, 0xAE, 0xA9, 0x2122, 0xB4, 0xA8, 0x2260, 0xC6, 0xD8, + 0x221E, 0xB1, 0x2264, 0x2265, 0xA5, 0xB5, 0x2202, 0x2211, 0x220F, 0x3C0, 0x222B, 0xAA, 0xBA, 0x3A9, 0xE6, 0xF8, 0xBF, 0xA1, 0xAC, 0x221A, 0x192, + 0x2248, 0x2206, 0xAB, 0xBB, 0x2026, 0x20, 0xC0, 0xC3, 0xD5, 0x152, 0x153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0xF7, 0x25CA, 0xFF, 0x178, + 0x2044, 0xA4, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0xB7, 0x201A, 0x201E, 0x2030, 0xC2, 0xCA, 0xC1, 0xCB, 0xC8, 0xCD, 0xCE, 0xCF, 0xCC, 0xD3, 0xD4, + 0xF8FF, 0xD2, 0xDA, 0xDB, 0xD9, 0x131, 0x2C6, 0x2DC, 0xAF, 0x2D8, 0x2D9, 0x2DA, 0xB8, 0x2DD, 0x2DB, 0x2C7, + ]; + match code { + 0x80..=0xFF => Some(HIGH[(code - 0x80) as usize]), + 0x27 => Some(0x27), + _ => ascii(code), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodings_and_names() { + assert_eq!(Base::WinAnsi.unicode(0x80), Some('€')); + assert_eq!(Base::WinAnsi.name(0xE9).as_deref(), Some("eacute")); + assert_eq!(Base::Standard.name(0x27).as_deref(), Some("quoteright")); + assert_eq!(Base::Standard.name(0xAE).as_deref(), Some("fi")); + assert_eq!(Base::MacRoman.unicode(0x8E), Some('é')); + assert_eq!(name_to_unicode("uni20AC"), Some('€')); + assert_eq!(name_to_unicode("u1F600"), Some('😀')); + assert_eq!(name_to_unicode("a.sc"), Some('a')); + assert_eq!(name_to_unicode("f_i"), Some('f')); + assert_eq!(unicode_to_name('Ω'), "Omega"); + assert_eq!(unicode_to_name('中'), "uni4E2D"); + } +} diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs new file mode 100644 index 000000000..480873b0e --- /dev/null +++ b/crates/pdf/src/font.rs @@ -0,0 +1,636 @@ +//! PDF fonts (ISO 32000-1 §9.5–9.10): simple fonts (Type 1, TrueType, Type 3) and composite +//! (Type 0) fonts with their encodings, widths and embedded programs, turned into glyph +//! outlines in text space. +//! +//! Embedded programs: TrueType (`FontFile2`, through skrifa), CFF (`FontFile3` `/Type1C`, +//! `/CIDFontType0C` and OpenType, [`crate::cff`]), Type 1 (`FontFile`, [`crate::type1`]) and +//! Type 3 glyph procedures (drawn by the content interpreter). Fonts that are not embedded +//! (the standard 14 and others) are drawn with the bundled OFL fonts: Inter for sans serif, +//! Noto Serif for serif and JetBrains Mono for monospaced families. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::Arc; + +use kurbo::{Affine, BezPath}; +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::cmap::PlatformId; +use skrifa::{FontRef, GlyphId, MetadataProvider}; + +use crate::cff::Cff; +use crate::encoding::{Base, name_to_unicode}; +use crate::object::{Dict, File, Lexer, Obj, decode_stream}; +use crate::type1::Type1; + +pub(crate) enum Program { + TrueType(Arc>), + Cff(Box), + Type1(Box), + /// A bundled stand-in (not embedded). + Fallback(&'static [u8]), + /// Glyph procedures: (name → content stream, resources). + Type3 { + procs: HashMap>, + resources: Option, + }, + None, +} + +pub(crate) struct Font { + /// Two-byte (or CMap-coded) codes and CIDs. + pub composite: bool, + pub program: Program, + /// Glyph space → text space. + pub matrix: Affine, + first_char: u32, + widths: Vec, + cid_widths: HashMap, + default_width: f64, + /// Simple fonts: code → glyph name. + names: Vec>, + symbolic: bool, + to_unicode: HashMap, + cid_to_gid: Option>, + /// Composite fonts: code space ranges (bytes, lo, hi) and code → CID ranges. + codespace: Vec<(usize, u32, u32)>, + cid_ranges: Vec<(u32, u32, u32)>, + pub vertical: bool, + cache: RefCell>>>, + /// Program glyph advances (font units → text space) for fonts without `/Widths`. + has_widths: bool, + pub base_name: String, +} + +struct Pen(BezPath); + +impl OutlinePen for Pen { + fn move_to(&mut self, x: f32, y: f32) { + self.0.move_to((x as f64, y as f64)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.line_to((x as f64, y as f64)); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.0.quad_to((cx0 as f64, cy0 as f64), (x as f64, y as f64)); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.0.curve_to((cx0 as f64, cy0 as f64), (cx1 as f64, cy1 as f64), (x as f64, y as f64)); + } + fn close(&mut self) { + self.0.close_path(); + } +} + +/// A bundled stand-in for a font that is not embedded, by its PostScript name. +fn fallback(base: &str) -> &'static [u8] { + use effectcraft_text::fonts::*; + let b = base.to_ascii_lowercase(); + if b.contains("courier") || b.contains("mono") || b.contains("consol") { + return JETBRAINS_MONO_REGULAR; + } + if b.contains("times") + || b.contains("serif") && !b.contains("sans") + || b.contains("georgia") + || b.contains("garamond") + || b.contains("minion") + || b.contains("roman") + { + return NOTO_SERIF_REGULAR; + } + if b.contains("bold") || b.contains("black") || b.contains("heavy") { + return INTER_BOLD; + } + if b.contains("semibold") || b.contains("demi") { + return INTER_SEMIBOLD; + } + if b.contains("italic") || b.contains("oblique") { + return INTER_ITALIC; + } + INTER_REGULAR +} + +fn sfnt_upem(data: &[u8]) -> f64 { + FontRef::new(data).ok().and_then(|f| f.head().ok().map(|h| h.units_per_em() as f64)).filter(|u| *u > 0.0).unwrap_or(1000.0) +} + +/// A TrueType / OpenType glyph's outline in font units. +fn sfnt_outline(data: &[u8], gid: u16) -> Option { + let font = FontRef::new(data).ok()?; + let g = font.outline_glyphs().get(GlyphId::new(gid as u32))?; + let mut pen = Pen(BezPath::new()); + g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).ok()?; + Some(pen.0) +} + +fn sfnt_advance(data: &[u8], gid: u16) -> Option { + let font = FontRef::new(data).ok()?; + font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid as u32)).map(|a| a as f64) +} + +/// A glyph of a TrueType font for a simple-font code (§9.6.6.4): the (3,1) subtable through +/// the glyph's Unicode value, the (3,0) symbol subtable at `0xF000 + code`, the (1,0) Macintosh +/// subtable by code, then `post` glyph names. +fn sfnt_glyph(data: &[u8], code: u8, name: Option<&str>, symbolic: bool) -> Option { + let font = FontRef::new(data).ok()?; + let cmap = font.cmap().ok(); + let sub = |pid: PlatformId, eid: u16| { + let cmap = cmap.as_ref()?; + cmap.encoding_records().iter().find(|r| r.platform_id() == pid && r.encoding_id() == eid).and_then(|r| r.subtable(cmap.offset_data()).ok()) + }; + let uni = name.and_then(name_to_unicode); + let ok = |g: Option| g.map(|g| g.to_u32() as u16).filter(|g| *g != 0); + if !symbolic + && let Some(u) = uni + && let Some(g) = ok(sub(PlatformId::Windows, 1).and_then(|s| s.map_codepoint(u as u32))).or_else(|| ok(font.charmap().map(u))) + { + return Some(g); + } + if let Some(s) = sub(PlatformId::Windows, 0) { + for c in [0xF000 + code as u32, 0xF100 + code as u32, 0xF200 + code as u32, code as u32] { + if let Some(g) = ok(s.map_codepoint(c)) { + return Some(g); + } + } + } + if let Some(g) = ok(sub(PlatformId::Macintosh, 0).and_then(|s| s.map_codepoint(code as u32))) { + return Some(g); + } + if let Some(n) = name + && let Some((g, _)) = font.glyph_names().iter().find(|(_, gn)| gn.as_str() == n) + { + return Some(g.to_u32() as u16); + } + if let Some(u) = uni { + return ok(font.charmap().map(u)); + } + // Symbolic fonts without a usable cmap: the code as the glyph index. + (symbolic && code != 0).then_some(code as u16) +} + +/// `/Differences [code /name /name … code /name …]`. +fn differences(file: &File, enc: &Dict, names: &mut [Option]) { + let Some(diffs) = file.get(enc, "Differences").and_then(Obj::array) else { return }; + let mut code = 0usize; + for d in diffs { + match file.resolve(d) { + Obj::Num(n) => code = *n as usize, + Obj::Name(n) => { + if let Some(slot) = names.get_mut(code) { + *slot = Some(n.clone()); + } + code += 1; + } + _ => {} + } + } +} + +/// A ToUnicode or encoding CMap: `bfchar` / `bfrange` → strings, `cidchar` / `cidrange` → +/// CIDs, `codespacerange` → code lengths. +#[derive(Default)] +struct CMap { + codespace: Vec<(usize, u32, u32)>, + unicode: HashMap, + cids: Vec<(u32, u32, u32)>, +} + +fn be(b: &[u8]) -> u32 { + b.iter().fold(0u32, |a, &c| (a << 8) | c as u32) +} + +fn utf16(b: &[u8]) -> String { + let u: Vec = b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect(); + String::from_utf16_lossy(&u) +} + +fn parse_cmap(data: &[u8]) -> CMap { + let mut m = CMap::default(); + let mut lx = Lexer::new(data, 0); + let mut args: Vec = vec![]; + let mut mode = ""; + while let Some(o) = lx.next() { + let Obj::Op(op) = o else { + args.push(o); + continue; + }; + match op.as_str() { + "begincodespacerange" => mode = "cs", + "beginbfchar" => mode = "bfchar", + "beginbfrange" => mode = "bfrange", + "begincidchar" => mode = "cidchar", + "begincidrange" => mode = "cidrange", + "endcodespacerange" | "endbfchar" | "endbfrange" | "endcidchar" | "endcidrange" => { + let a = std::mem::take(&mut args); + match mode { + "cs" => { + for p in a.chunks_exact(2) { + if let (Obj::Str(lo), Obj::Str(hi)) = (&p[0], &p[1]) { + m.codespace.push((lo.len().max(1), be(lo), be(hi))); + } + } + } + "bfchar" => { + for p in a.chunks_exact(2) { + if let (Obj::Str(src), Obj::Str(dst)) = (&p[0], &p[1]) { + m.unicode.insert(be(src), utf16(dst)); + } + } + } + "bfrange" => { + for p in a.chunks_exact(3) { + let (Obj::Str(lo), Obj::Str(hi)) = (&p[0], &p[1]) else { continue }; + let (lo, hi) = (be(lo), be(hi)); + if hi < lo || hi - lo > 65535 { + continue; + } + match &p[2] { + Obj::Str(dst) => { + let mut d = dst.clone(); + for c in lo..=hi { + m.unicode.insert(c, utf16(&d)); + if let Some(last) = d.last_mut() { + *last = last.wrapping_add(1); + } + } + } + Obj::Array(list) => { + for (k, s) in list.iter().enumerate() { + if let Obj::Str(s) = s { + m.unicode.insert(lo + k as u32, utf16(s)); + } + } + } + _ => {} + } + } + } + "cidchar" => { + for p in a.chunks_exact(2) { + if let (Obj::Str(src), Some(cid)) = (&p[0], p[1].num()) { + let c = be(src); + m.cids.push((c, c, cid as u32)); + } + } + } + "cidrange" => { + for p in a.chunks_exact(3) { + if let (Obj::Str(lo), Obj::Str(hi), Some(cid)) = (&p[0], &p[1], p[2].num()) { + m.cids.push((be(lo), be(hi), cid as u32)); + } + } + } + _ => {} + } + mode = ""; + } + _ => { + if mode.is_empty() { + args.clear(); + } + } + } + } + m +} + +impl Font { + /// Read a font dictionary. `skipped` collects what could not be read. + pub fn load(file: &File, d: &Dict, skipped: &mut Vec) -> Font { + let subtype = file.get(d, "Subtype").and_then(Obj::name).unwrap_or("Type1").to_string(); + let base_name = file.get(d, "BaseFont").and_then(Obj::name).unwrap_or("").to_string(); + let mut f = Font { + composite: subtype == "Type0", + program: Program::None, + matrix: Affine::scale(0.001), + first_char: 0, + widths: vec![], + cid_widths: HashMap::new(), + default_width: 0.0, + names: vec![None; 256], + symbolic: false, + to_unicode: HashMap::new(), + cid_to_gid: None, + codespace: vec![], + cid_ranges: vec![], + vertical: false, + cache: RefCell::new(HashMap::new()), + has_widths: false, + base_name: base_name.clone(), + }; + if let Some(Obj::Stream(sd, raw)) = file.get(d, "ToUnicode") + && let Some(data) = decode_stream(file, sd, raw) + { + f.to_unicode = parse_cmap(&data).unicode; + } + let (desc_dict, fd) = if f.composite { + let desc = file + .get(d, "DescendantFonts") + .and_then(Obj::array) + .and_then(|a| a.first()) + .map(|x| file.resolve(x)) + .and_then(Obj::dict) + .cloned() + .unwrap_or_default(); + let fd = file.get_dict(&desc, "FontDescriptor").cloned(); + (desc, fd) + } else { + (d.clone(), file.get_dict(d, "FontDescriptor").cloned()) + }; + if let Some(fd) = &fd { + f.symbolic = file.get_num(fd, "Flags").is_some_and(|fl| (fl as u32) & 4 != 0); + } + // Widths. + if f.composite { + f.default_width = file.get_num(&desc_dict, "DW").unwrap_or(1000.0) / 1000.0; + if let Some(w) = file.get(&desc_dict, "W").and_then(Obj::array) { + let mut i = 0; + while i < w.len() { + let first = file.resolve(&w[i]).num().unwrap_or(0.0) as u32; + match w.get(i + 1).map(|x| file.resolve(x)) { + Some(Obj::Array(list)) => { + for (k, v) in list.iter().enumerate() { + f.cid_widths.insert(first + k as u32, file.resolve(v).num().unwrap_or(0.0) / 1000.0); + } + i += 2; + } + Some(Obj::Num(last)) => { + let wv = w.get(i + 2).and_then(|x| file.resolve(x).num()).unwrap_or(0.0) / 1000.0; + for c in first..=(*last as u32).min(first + 65535) { + f.cid_widths.insert(c, wv); + } + i += 3; + } + _ => break, + } + } + } + f.has_widths = true; + // Encoding CMap. + match file.get(d, "Encoding") { + Some(Obj::Name(n)) => { + f.vertical = n.ends_with("-V"); + if !n.starts_with("Identity") { + skipped.push(format!("CMap {n}")); + } + } + Some(Obj::Stream(sd, raw)) => { + if let Some(data) = decode_stream(file, sd, raw) { + let cm = parse_cmap(&data); + f.codespace = cm.codespace; + f.cid_ranges = cm.cids; + } + } + _ => {} + } + match file.get(&desc_dict, "CIDToGIDMap") { + Some(Obj::Stream(sd, raw)) => { + f.cid_to_gid = decode_stream(file, sd, raw).map(|b| b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()); + } + _ => f.cid_to_gid = None, + } + } else { + f.first_char = file.get_num(d, "FirstChar").unwrap_or(0.0) as u32; + if let Some(w) = file.get(d, "Widths") { + f.widths = file.nums(w); + f.has_widths = !f.widths.is_empty(); + } + if let Some(fd) = &fd { + f.default_width = file.get_num(fd, "MissingWidth").unwrap_or(0.0); + } + } + // The program. + let font_file = |key: &str| -> Option<(Dict, Vec)> { + let fd = fd.as_ref()?; + match file.get(fd, key) { + Some(Obj::Stream(sd, raw)) => Some((sd.clone(), decode_stream(file, sd, raw)?)), + _ => None, + } + }; + if subtype == "Type3" { + f.matrix = file + .get(d, "FontMatrix") + .map(|m| file.nums(m)) + .filter(|m| m.len() == 6) + .map(|m| Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]])) + .unwrap_or(Affine::scale(0.001)); + let mut procs = HashMap::new(); + if let Some(cp) = file.get_dict(d, "CharProcs") { + for (k, v) in cp { + if let Obj::Stream(sd, raw) = file.resolve(v) + && let Some(data) = decode_stream(file, sd, raw) + { + procs.insert(k.clone(), data); + } + } + } + f.program = Program::Type3 { procs, resources: file.get_dict(d, "Resources").cloned() }; + // Type 3 widths are in glyph space. + let m = f.matrix.as_coeffs()[0]; + for w in &mut f.widths { + *w *= m * 1000.0; + } + } else if let Some((_, data)) = font_file("FontFile2") { + f.program = Program::TrueType(Arc::new(data)); + } else if let Some((sd, data)) = font_file("FontFile3") { + match file.get(&sd, "Subtype").and_then(Obj::name) { + Some("OpenType") => { + // An OpenType wrapper: TrueType outlines through skrifa, CFF through ours. + let cff = FontRef::new(&data).ok().and_then(|fr| fr.table_data(skrifa::Tag::new(b"CFF ")).map(|t| t.as_bytes().to_vec())); + match cff.and_then(|c| Cff::parse(&c)) { + Some(c) => f.program = Program::Cff(Box::new(c)), + None => f.program = Program::TrueType(Arc::new(data)), + } + } + _ => match Cff::parse(&data) { + Some(c) => f.program = Program::Cff(Box::new(c)), + None => skipped.push("font program (CFF)".into()), + }, + } + } else if let Some((_, data)) = font_file("FontFile") { + match Type1::parse(&data) { + Some(t) => f.program = Program::Type1(Box::new(t)), + None => skipped.push("font program (Type 1)".into()), + } + } else { + f.program = Program::Fallback(fallback(&base_name)); + } + if let Program::Cff(c) = &f.program { + let m = c.font_matrix; + f.matrix = Affine::new(m); + } + if let Program::Type1(t) = &f.program + && let Some(m) = t.font_matrix + { + f.matrix = Affine::new(m); + } + // Simple-font encoding: base encoding, built-in encoding, Differences. + if !f.composite { + let enc = file.get(d, "Encoding").map(|e| file.resolve(e)); + let base = match enc { + Some(Obj::Name(n)) => Base::from_name(n), + Some(Obj::Dict(e)) => file.get(e, "BaseEncoding").and_then(Obj::name).and_then(Base::from_name), + _ => None, + }; + let builtin: Option>> = match &f.program { + Program::Type1(t) if !t.encoding.is_empty() => Some((0..256).map(|c| t.encoding.get(&(c as u8)).cloned()).collect()), + _ => None, + }; + let fallback_base = if f.symbolic { None } else { Some(Base::Standard) }; + for c in 0..256usize { + f.names[c] = match (base, &builtin) { + (Some(b), _) => b.name(c as u8), + (None, Some(bi)) => bi[c].clone(), + (None, None) => match &f.program { + // TrueType symbolic fonts map codes directly; others use Standard. + Program::TrueType(_) if f.symbolic => None, + Program::Fallback(_) if base_name.contains("Symbol") || base_name.contains("Dingbats") => None, + _ => fallback_base.or(Some(Base::Standard)).and_then(|b| b.name(c as u8)), + }, + }; + } + if let Some(Obj::Dict(e)) = enc { + differences(file, e, &mut f.names); + } + } + f + } + + /// Split a string into (code, byte length) pairs. + pub fn codes(&self, s: &[u8]) -> Vec<(u32, usize)> { + if !self.composite { + return s.iter().map(|&b| (b as u32, 1)).collect(); + } + let mut out = vec![]; + let mut i = 0; + while i < s.len() { + let mut len = 2; + if !self.codespace.is_empty() { + len = (1..=4) + .find(|&n| { + i + n <= s.len() && { + let v = be(&s[i..i + n]); + self.codespace.iter().any(|(l, lo, hi)| *l == n && v >= *lo && v <= *hi) + } + }) + .unwrap_or(1); + } + let end = (i + len).min(s.len()); + out.push((be(&s[i..end]), end - i)); + i = end; + } + out + } + + fn cid(&self, code: u32) -> u32 { + if self.cid_ranges.is_empty() { + return code; + } + self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map(|(lo, _, c)| c + (code - lo)).unwrap_or(0) + } + + /// The advance of a code in text space (before the font size). + pub fn width(&self, code: u32) -> f64 { + if self.composite { + return self.cid_widths.get(&self.cid(code)).copied().unwrap_or(self.default_width); + } + if self.has_widths { + return match code.checked_sub(self.first_char).and_then(|i| self.widths.get(i as usize)) { + Some(w) => w / 1000.0, + None => self.default_width / 1000.0, + }; + } + // No /Widths (standard 14 fonts): the program's own advance. + match &self.program { + Program::Fallback(data) => { + self.sfnt_gid(data, code).and_then(|g| sfnt_advance(data, g)).map(|a| a / sfnt_upem(data)).unwrap_or(if code == 32 { 0.25 } else { 0.5 }) + } + Program::TrueType(data) => self.sfnt_gid(data, code).and_then(|g| sfnt_advance(data, g)).map(|a| a / sfnt_upem(data)).unwrap_or(0.5), + Program::Type1(t) => self.name(code).and_then(|n| t.outline(n)).map(|(_, w)| self.matrix.as_coeffs()[0] * w).unwrap_or(0.5), + Program::Cff(c) => { + self.name(code).and_then(|n| c.gid_by_name(n)).and_then(|g| c.outline(g)).map(|(_, w)| self.matrix.as_coeffs()[0] * w).unwrap_or(0.5) + } + _ => 0.5, + } + } + + fn name(&self, code: u32) -> Option<&str> { + self.names.get(code as usize).and_then(|n| n.as_deref()) + } + + fn sfnt_gid(&self, data: &[u8], code: u32) -> Option { + if self.composite { + let cid = self.cid(code); + return Some(match &self.cid_to_gid { + Some(map) => *map.get(cid as usize)?, + None => cid as u16, + }); + } + // Stand-ins are looked up by Unicode (ToUnicode first). + if let Program::Fallback(_) = self.program + && let Some(u) = self.to_unicode.get(&code).and_then(|s| s.chars().next()) + { + let font = FontRef::new(data).ok()?; + return font.charmap().map(u).map(|g| g.to_u32() as u16); + } + sfnt_glyph(data, code as u8, self.name(code), self.symbolic && !matches!(self.program, Program::Fallback(_))) + } + + /// The Type 3 glyph procedure of a code. + pub fn type3_proc(&self, code: u32) -> Option<(&[u8], Option<&Dict>)> { + let Program::Type3 { procs, resources } = &self.program else { return None }; + let n = self.name(code)?; + procs.get(n).map(|p| (p.as_slice(), resources.as_ref())) + } + + /// A code's outline in text space (font size 1), cached. + pub fn glyph(&self, code: u32) -> Option> { + if let Some(v) = self.cache.borrow().get(&code) { + return v.clone(); + } + let path = self.glyph_uncached(code).map(Arc::new); + self.cache.borrow_mut().insert(code, path.clone()); + path + } + + fn glyph_uncached(&self, code: u32) -> Option { + match &self.program { + Program::TrueType(data) => self.sfnt_path(data, code), + Program::Fallback(data) => self.sfnt_path(data, code), + Program::Cff(c) => { + let g = if self.composite { + c.gid_by_cid(self.cid(code) as u16)? + } else { + match self.name(code).and_then(|n| c.gid_by_name(n)) { + Some(g) => g, + None => Some(c.encoding[(code & 255) as usize]).filter(|g| *g != 0)?, + } + }; + c.outline(g).map(|(p, _)| self.matrix * p) + } + Program::Type1(t) => { + let name = self.name(code).filter(|n| t.has_glyph(n)).or_else(|| t.encoding.get(&(code as u8)).map(String::as_str))?; + t.outline(name).map(|(p, _)| self.matrix * p) + } + Program::Type3 { .. } | Program::None => None, + } + } + + fn sfnt_path(&self, data: &[u8], code: u32) -> Option { + let g = self.sfnt_gid(data, code)?; + let p = sfnt_outline(data, g)?; + Some(Affine::scale(1.0 / sfnt_upem(data)) * p) + } + + /// The text of a code (ToUnicode, else the glyph name), for naming text shapes. + pub fn unicode(&self, code: u32) -> String { + if let Some(s) = self.to_unicode.get(&code) { + return s.clone(); + } + if self.composite { + return String::new(); + } + self.name(code).and_then(name_to_unicode).map(String::from).unwrap_or_default() + } +} diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs new file mode 100644 index 000000000..0f0a481dc --- /dev/null +++ b/crates/pdf/src/image.rs @@ -0,0 +1,310 @@ +//! Image XObjects and inline images (ISO 32000-1 §8.9): sample decoding (1–16 bits per +//! component, `/Decode` arrays), colour spaces (through [`crate::color`]: Indexed lookups and +//! ICCBased profiles by their `/Alternate` space), DCT (JPEG, through the pure-Rust `zune-jpeg`) +//! and the general filters, stencil masks (`/ImageMask`), soft masks (`/SMask`) and colour-key +//! and explicit masks (`/Mask`). The result is straight RGBA8. + +use crate::color::{Cs, color_space}; +use crate::object::{Dict, File, Obj, decode_stream}; + +pub(crate) struct Decoded { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// The longest side of a decoded image (larger images are refused rather than exhausting +/// memory). +const MAX_SIDE: usize = 16384; + +fn get<'a>(file: &'a File, d: &'a Dict, long: &str, short: &str) -> Option<&'a Obj> { + file.get(d, long).or_else(|| file.get(d, short)) +} + +fn filters(file: &File, d: &Dict) -> Vec { + match get(file, d, "Filter", "F").map(|f| file.resolve(f)) { + 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![], + } +} + +/// Inline-image abbreviations (§8.9.7) expanded to the full keys and names. +pub(crate) fn expand_inline(d: &Dict) -> Dict { + let mut out = Dict::new(); + for (k, v) in d { + let key = match k.as_str() { + "BPC" => "BitsPerComponent", + "CS" => "ColorSpace", + "D" => "Decode", + "DP" => "DecodeParms", + "F" => "Filter", + "H" => "Height", + "IM" => "ImageMask", + "I" => "Interpolate", + "W" => "Width", + other => other, + }; + let name = |n: &str| -> String { + match n { + "G" => "DeviceGray", + "RGB" => "DeviceRGB", + "CMYK" => "DeviceCMYK", + "I" => "Indexed", + "AHx" => "ASCIIHexDecode", + "A85" => "ASCII85Decode", + "LZW" => "LZWDecode", + "Fl" => "FlateDecode", + "RL" => "RunLengthDecode", + "CCF" => "CCITTFaxDecode", + "DCT" => "DCTDecode", + other => other, + } + .to_string() + }; + let v = match v { + Obj::Name(n) => Obj::Name(name(n)), + Obj::Array(a) => Obj::Array(a.iter().map(|x| if let Obj::Name(n) = x { Obj::Name(name(n)) } else { x.clone() }).collect()), + other => other.clone(), + }; + out.insert(key.to_string(), v); + } + out +} + +/// The number of bytes an unfiltered inline image occupies. +pub(crate) fn inline_len(file: &File, d: &Dict, res: Option<&Dict>) -> Option { + if !filters(file, d).is_empty() { + return None; + } + let w = file.get_num(d, "Width")? as usize; + let h = file.get_num(d, "Height")? as usize; + let mask = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true))); + let bpc = if mask { 1 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize }; + let n = if mask { 1 } else { file.get(d, "ColorSpace").map(|c| color_space(file, c, res).components()).unwrap_or(1) }; + Some((w * n * bpc).div_ceil(8) * h) +} + +/// Decode an image's samples (all filters, including DCT). Returns (bytes, components from +/// a JPEG when it decided them itself). +fn samples(file: &File, d: &Dict, raw: &[u8]) -> Result<(Vec, Option), String> { + let fs = filters(file, d); + match fs.last().map(String::as_str) { + Some("DCTDecode" | "DCT") => { + let mut d2 = d.clone(); + d2.remove("F"); + let rest: Vec = fs[..fs.len() - 1].iter().map(|n| Obj::Name(n.clone())).collect(); + d2.insert("Filter".into(), Obj::Array(rest)); + d2.remove("DecodeParms"); + d2.remove("DP"); + let jpeg = decode_stream(file, &d2, raw).ok_or("image (filters)")?; + jpeg_decode(&jpeg).map(|(b, n)| (b, Some(n))) + } + Some(f @ ("JPXDecode" | "CCITTFaxDecode" | "JBIG2Decode" | "CCF")) => Err(format!("image ({f})")), + _ => decode_stream(file, d, raw).map(|b| (b, None)).ok_or_else(|| "image (filters)".to_string()), + } +} + +fn jpeg_decode(data: &[u8]) -> Result<(Vec, usize), String> { + use zune_jpeg::JpegDecoder; + use zune_jpeg::zune_core::colorspace::ColorSpace; + use zune_jpeg::zune_core::options::DecoderOptions; + let mut probe = JpegDecoder::new(std::io::Cursor::new(data)); + probe.decode_headers().map_err(|_| "image (JPEG)".to_string())?; + let n = probe.input_colorspace().map(|c| c.num_components()).unwrap_or(3); + let out = match n { + 1 => ColorSpace::Luma, + 4 => ColorSpace::CMYK, + _ => ColorSpace::RGB, + }; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(out).set_max_width(MAX_SIDE).set_max_height(MAX_SIDE); + let mut dec = JpegDecoder::new_with_options(std::io::Cursor::new(data), opts); + let mut px = dec.decode().map_err(|_| "image (JPEG)".to_string())?; + // Adobe CMYK JPEGs (an APP14 "Adobe" marker) store inverted values. + if n == 4 && data.windows(5).take(4096).any(|w| w == b"Adobe") { + for v in &mut px { + *v = 255 - *v; + } + } + Ok((px, n.max(1))) +} + +/// Unpack `w`×`h` samples of `n` components at `bpc` bits into 0..=max integers. +fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec { + let row_bytes = (w * n * bpc).div_ceil(8); + let mut out = Vec::with_capacity(w * h * n); + for y in 0..h { + let row = data.get(y * row_bytes..((y + 1) * row_bytes).min(data.len())).unwrap_or(&[]); + for i in 0..w * n { + let v = match bpc { + 8 => row.get(i).copied().unwrap_or(0) as u16, + 16 => u16::from_be_bytes([row.get(2 * i).copied().unwrap_or(0), row.get(2 * i + 1).copied().unwrap_or(0)]), + 1 | 2 | 4 => { + let bit = i * bpc; + let byte = row.get(bit / 8).copied().unwrap_or(0); + ((byte >> (8 - bpc - bit % 8)) & ((1u8 << bpc) - 1)) as u16 + } + _ => 0, + }; + out.push(v); + } + } + out +} + +/// Nearest-neighbour resample of a single-channel 8-bit mask to `w`×`h`. +fn resample(m: &[u8], mw: usize, mh: usize, w: usize, h: usize) -> Vec { + if mw == w && mh == h { + return m.to_vec(); + } + let mut out = Vec::with_capacity(w * h); + for y in 0..h { + let sy = (y * mh / h.max(1)).min(mh.saturating_sub(1)); + for x in 0..w { + let sx = (x * mw / w.max(1)).min(mw.saturating_sub(1)); + out.push(m.get(sy * mw + sx).copied().unwrap_or(255)); + } + } + out +} + +/// A stencil or soft mask stream as 8-bit coverage. +fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize)> { + let Obj::Stream(d, raw) = file.resolve(o) else { return None }; + let w = file.get_num(d, "Width")? as usize; + let h = file.get_num(d, "Height")? as usize; + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { + return None; + } + let (data, jpeg_n) = samples(file, d, raw).ok()?; + if jpeg_n.is_some() { + return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h)); + } + let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 }; + let max = ((1u32 << bpc) - 1).max(1) as f64; + let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2); + let v = unpack(&data, w, h, 1, bpc); + let out = v + .into_iter() + .map(|s| { + let t = s as f64 / max; + let t = match &dec { + Some(dd) => dd[0] + t * (dd[1] - dd[0]), + None => t, + }; + // Explicit (stencil) masks: sample 1 masks out with the default Decode. + let a = if soft { t } else { 1.0 - t }; + (a.clamp(0.0, 1.0) * 255.0).round() as u8 + }) + .collect(); + Some((out, w, h)) +} + +/// Decode an image. `fill` is the current fill colour (stencil masks paint with it). +pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill: [f64; 3]) -> Result { + let w = file.get_num(d, "Width").unwrap_or(0.0) as usize; + let h = file.get_num(d, "Height").unwrap_or(0.0) as usize; + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w * h > 64 << 20 { + return Err("image (size)".into()); + } + let stencil = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true))); + let (data, jpeg_n) = samples(file, d, raw)?; + let decode_arr = file.get(d, "Decode").map(|x| file.nums(x)).unwrap_or_default(); + let mut rgba = vec![0u8; w * h * 4]; + if stencil { + let v = unpack(&data, w, h, 1, 1); + let paint_on = if decode_arr.first().copied().unwrap_or(0.0) > 0.5 { 1 } else { 0 }; + let c = fill.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8); + for (i, s) in v.into_iter().enumerate() { + rgba[i * 4..i * 4 + 3].copy_from_slice(&c); + rgba[i * 4 + 3] = if s == paint_on { 255 } else { 0 }; + } + return Ok(Decoded { width: w as u32, height: h as u32, rgba }); + } + let cs = match file.get(d, "ColorSpace") { + Some(c) => color_space(file, c, res), + None if jpeg_n == Some(4) => Cs::Cmyk, + None if jpeg_n == Some(1) => Cs::Gray, + None => Cs::Rgb, + }; + let n = match jpeg_n { + Some(n) => n, + None => cs.components().max(1), + }; + let bpc = if jpeg_n.is_some() { 8 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize }; + if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { + return Err(format!("image ({bpc} bits per component)")); + } + let max = ((1u32 << bpc) - 1) as f64; + let raw_samples = unpack(&data, w, h, n, bpc); + // Decode ranges per component. + let indexed = matches!(cs, Cs::Indexed { .. }); + let range = |k: usize| -> (f64, f64) { + match (decode_arr.get(2 * k), decode_arr.get(2 * k + 1)) { + (Some(a), Some(b)) => (*a, *b), + _ if indexed => (0.0, max), + _ => (0.0, 1.0), + } + }; + let ranges: Vec<(f64, f64)> = (0..n).map(range).collect(); + // Fast paths: a lookup per 8-bit value for Gray / RGB / CMYK / Indexed. + let mut comp = vec![0.0f64; n]; + // Indexed images: one colour per palette entry. + let palette: Option> = match &cs { + Cs::Indexed { hival, .. } if n == 1 => Some((0..=*hival).map(|i| cs.to_rgb(&[i as f64])).collect()), + _ => None, + }; + for i in 0..w * h { + if let Some(pal) = &palette { + let s = raw_samples[i] as f64 / max; + let (a, b) = ranges[0]; + let idx = (a + s * (b - a)).round().clamp(0.0, (pal.len() - 1) as f64) as usize; + let c = pal[idx]; + rgba[i * 4..i * 4 + 4].copy_from_slice(&[(c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8, 255]); + continue; + } + for k in 0..n { + let s = raw_samples[i * n + k] as f64 / max; + let (a, b) = ranges[k]; + comp[k] = a + s * (b - a); + } + let c = cs.to_rgb(&comp); + let o = i * 4; + rgba[o] = (c[0] * 255.0).round() as u8; + rgba[o + 1] = (c[1] * 255.0).round() as u8; + rgba[o + 2] = (c[2] * 255.0).round() as u8; + rgba[o + 3] = 255; + } + // Colour-key masking: samples inside every [min max] pair are transparent. + match file.get(d, "Mask").map(|m| file.resolve(m)) { + Some(Obj::Array(a)) => { + let keys: Vec = a.iter().filter_map(|x| file.resolve(x).num()).collect(); + if keys.len() >= 2 * n { + for i in 0..w * h { + if (0..n).all(|k| { + let v = raw_samples[i * n + k] as f64; + v >= keys[2 * k] && v <= keys[2 * k + 1] + }) { + rgba[i * 4 + 3] = 0; + } + } + } + } + Some(m @ Obj::Stream(..)) => { + if let Some((m, mw, mh)) = mask_image(file, m, false) { + for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() { + rgba[i * 4 + 3] = a; + } + } + } + _ => {} + } + if let Some(sm) = file.get(d, "SMask") + && let Some((m, mw, mh)) = mask_image(file, sm, true) + { + for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() { + rgba[i * 4 + 3] = ((rgba[i * 4 + 3] as u32 * a as u32) / 255) as u8; + } + } + Ok(Decoded { width: w as u32, height: h as u32, rgba }) +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index cd835113e..db443a418 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -8,10 +8,15 @@ //! (Illustrator layers), with content outside them gathered into groups of its own. mod build; +mod cff; mod color; +mod encoding; +mod font; +mod image; mod object; mod page; mod ps; +mod type1; pub mod write; pub use effectcraft_svg::Doc; @@ -124,7 +129,10 @@ fn finish(nodes: Vec, flags: Vec, skipped: Vec, width: f64, 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 } + let mut root = Group::new("page"); + root.transform = to_doc; + root.children = layers; + Doc { width, height, root, skipped } } fn parse_pdf(bytes: &[u8], index: usize) -> Result { @@ -170,6 +178,7 @@ pub fn layer_names(doc: &Doc) -> Vec { .map(|n| match n { Node::Group(g) => g.name.clone(), Node::Shape(s) => s.name.clone(), + Node::Image(i) => i.name.clone(), }) .collect() } diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 9a67f85c4..cbfaad01b 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -1,13 +1,20 @@ //! 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`]). +//! and optional-content marked sequences, text (§9: text state, text objects, glyph outlines +//! from [`crate::font`]), images ([`crate::image`]), tiling patterns, blend modes and soft +//! masks. What is not drawn is listed in [`effectcraft_svg::Doc::skipped`]. -use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Gradient, GradientKind, Join, Paint, Spread, Stroke}; -use kurbo::Point; +use std::collections::HashMap; +use std::rc::Rc; + +use effectcraft_svg::{ + Affine, BezPath, BlendMode, Cap, FillRule, Gradient, GradientKind, Group, Image as SvgImage, Join, Node, Paint, SoftMask, Spread, Stroke, +}; +use kurbo::{Point, Shape as _}; use crate::build::Builder; use crate::color::{Cs, Func, color_space, text_string}; +use crate::font::Font; use crate::object::{Dict, File, Lexer, Obj, decode_stream}; /// A page: its dictionary with inherited attributes resolved. @@ -70,10 +77,24 @@ pub fn page_content(file: &File, page: &Page) -> Vec { out } +/// A tiling pattern (§8.7.3.1): its cell content, drawn in pattern space at every step. +pub(crate) struct Tiling { + /// Pattern space → page (default user space of the stream that set it). + space: Affine, + bbox: [f64; 4], + xstep: f64, + ystep: f64, + content: Vec, + res: Dict, + /// PaintType 2: the cell is painted with this colour. + uncolored: Option<[f64; 3]>, +} + #[derive(Clone)] enum Fill { Color([f64; 3]), Paint(Paint), + Tiling(Rc), None, } @@ -91,6 +112,20 @@ struct GState { dash: Option<(Vec, f64)>, fill_alpha: f64, stroke_alpha: f64, + // Text state (§9.3). + font: Option>, + font_size: f64, + char_spacing: f64, + word_spacing: f64, + h_scale: f64, + leading: f64, + rise: f64, + render_mode: u8, + /// Builder depths of the open blend-mode and soft-mask groups. + blend_depth: Option, + mask_depth: Option, + /// Colour operators are ignored (uncoloured tiling patterns, `d1` glyphs). + lock_color: bool, } impl Default for GState { @@ -108,6 +143,17 @@ impl Default for GState { dash: None, fill_alpha: 1.0, stroke_alpha: 1.0, + font: None, + font_size: 0.0, + char_spacing: 0.0, + word_spacing: 0.0, + h_scale: 1.0, + leading: 0.0, + rise: 0.0, + render_mode: 0, + blend_depth: None, + mask_depth: None, + lock_color: false, } } } @@ -125,6 +171,12 @@ pub(crate) struct Interp<'a> { /// The page box in default user space (for `sh`). page_box: [f64; 4], budget: usize, + // Text object state. + tm: Affine, + tlm: Affine, + /// Glyph outlines collected for a clipping text render mode (page space). + text_clip: Option, + fonts: HashMap>, } fn affine(v: &[f64]) -> Affine { @@ -134,6 +186,15 @@ fn affine(v: &[f64]) -> Affine { Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]) } +/// The most tiles one tiling-pattern fill draws. +const MAX_TILES: usize = 4096; + +/// A `TJ` element. +enum TextItem { + Str(Vec), + Adjust(f64), +} + impl<'a> Interp<'a> { pub fn new(file: &'a File, page_box: [f64; 4]) -> Self { Interp { @@ -148,6 +209,21 @@ impl<'a> Interp<'a> { marked: vec![], page_box, budget: 20_000_000, + tm: Affine::IDENTITY, + tlm: Affine::IDENTITY, + text_clip: None, + fonts: HashMap::new(), + } + } + + /// Close builder groups down to `depth` and forget graphics-state groups above it. + fn close_to(&mut self, depth: usize) { + self.b.close_to(depth); + if self.gs.blend_depth.is_some_and(|d| d >= depth) { + self.gs.blend_depth = None; + } + if self.gs.mask_depth.is_some_and(|d| d >= depth) { + self.gs.mask_depth = None; } } @@ -156,7 +232,6 @@ impl<'a> Interp<'a> { pub fn run(&mut self, content: &[u8], res: &Dict, pattern_base: Affine, depth: usize) { let mut lx = Lexer::new(content, 0); let mut args: Vec = Vec::with_capacity(8); - let mut in_text = false; while let Some(o) = lx.next() { let Obj::Op(op) = o else { if args.len() < 64 { @@ -169,21 +244,15 @@ impl<'a> Interp<'a> { 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::>(); + let locked = self.gs.lock_color; 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); + self.close_to(d); } } "cm" => self.gs.ctm *= affine(&nums()), @@ -199,7 +268,8 @@ impl<'a> Interp<'a> { 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); + let g = g.clone(); + self.ext_gstate(&g, res, depth); } } // Path construction. @@ -251,27 +321,28 @@ impl<'a> Interp<'a> { self.start = self.cur; } // Painting. - "S" => self.paint(false, None, true), + "S" => self.paint(false, None, true, depth), "s" => { self.path.close_path(); - self.paint(false, None, true) + self.paint(false, None, true, depth) } - "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), + "f" | "F" => self.paint(true, Some(FillRule::NonZero), false, depth), + "f*" => self.paint(true, Some(FillRule::EvenOdd), false, depth), + "B" => self.paint(true, Some(FillRule::NonZero), true, depth), + "B*" => self.paint(true, Some(FillRule::EvenOdd), true, depth), "b" => { self.path.close_path(); - self.paint(true, Some(FillRule::NonZero), true) + self.paint(true, Some(FillRule::NonZero), true, depth) } "b*" => { self.path.close_path(); - self.paint(true, Some(FillRule::EvenOdd), true) + self.paint(true, Some(FillRule::EvenOdd), true, depth) } - "n" => self.paint(false, None, false), + "n" => self.paint(false, None, false, depth), "W" => self.pending_clip = Some(FillRule::NonZero), "W*" => self.pending_clip = Some(FillRule::EvenOdd), - // Colour. + // Colour (ignored inside uncoloured patterns and `d1` glyphs). + "g" | "G" | "rg" | "RG" | "k" | "K" | "cs" | "CS" | "sc" | "scn" | "SC" | "SCN" if locked => {} "g" => self.gs.fill_cs = Cs::Gray, "G" => self.gs.stroke_cs = Cs::Gray, "rg" => self.gs.fill_cs = Cs::Rgb, @@ -293,7 +364,7 @@ impl<'a> Interp<'a> { 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) + self.pattern(res, name, pattern_base, &nums()) } else { Fill::Color(cs.to_rgb(&nums())) }; @@ -313,25 +384,65 @@ impl<'a> Interp<'a> { self.xobject(res, name, depth); } } + // Text objects (§9.4). "BT" => { - in_text = true; - self.b.skip("text"); + self.tm = Affine::IDENTITY; + self.tlm = Affine::IDENTITY; + self.text_clip = None; } - "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; + "ET" => { + if let Some(clip) = self.text_clip.take() { + self.b.clip(clip, FillRule::NonZero); } - 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"); } + "Tc" => self.gs.char_spacing = n(0), + "Tw" => self.gs.word_spacing = n(0), + "Tz" => self.gs.h_scale = n(0) / 100.0, + "TL" => self.gs.leading = n(0), + "Ts" => self.gs.rise = n(0), + "Tr" => self.gs.render_mode = (n(0) as i64).clamp(0, 7) as u8, + "Tf" => { + self.gs.font = args.first().and_then(Obj::name).map(str::to_string).and_then(|name| self.font(res, &name)); + self.gs.font_size = n(1); + } + "Td" => self.text_move(n(0), n(1)), + "TD" => { + self.gs.leading = -n(1); + self.text_move(n(0), n(1)); + } + "Tm" => { + self.tm = affine(&nums()); + self.tlm = self.tm; + } + "T*" => self.text_move(0.0, -self.gs.leading), + "Tj" | "'" | "\"" => { + if op == "\"" { + self.gs.word_spacing = n(0); + self.gs.char_spacing = n(1); + } + if op != "Tj" { + self.text_move(0.0, -self.gs.leading); + } + if let Some(Obj::Str(s)) = args.iter().rev().find(|a| matches!(a, Obj::Str(_))) { + let s = s.clone(); + self.show(&[TextItem::Str(s)], res, pattern_base, depth); + } + } + "TJ" => { + if let Some(Obj::Array(a)) = args.first() { + let items: Vec = a + .iter() + .filter_map(|x| match x { + Obj::Str(s) => Some(TextItem::Str(s.clone())), + Obj::Num(v) => Some(TextItem::Adjust(*v)), + _ => None, + }) + .collect(); + self.show(&items, res, pattern_base, depth); + } + } + "d1" => self.gs.lock_color = true, + "BI" => self.inline_image(&mut lx, res), "BMC" => self.marked.push(None), "BDC" => { let layer = match (args.first().and_then(Obj::name), args.get(1)) { @@ -353,21 +464,69 @@ impl<'a> Interp<'a> { } "EMC" => { if let Some(Some(d)) = self.marked.pop() { - self.b.close_to(d); + self.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())), - _ => {} + if !locked { + 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(); } } + /// An inline image: `BI key value … ID data EI` (the lexer is just past `BI`). + fn inline_image(&mut self, lx: &mut Lexer, res: &Dict) { + let mut d = Dict::new(); + let mut key: Option = None; + while let Some(o) = lx.next() { + match o { + Obj::Op(ref x) if x == "ID" => break, + Obj::Name(k) if key.is_none() => key = Some(k), + v => { + if let Some(k) = key.take() { + d.insert(k, v); + } + } + } + } + let d = crate::image::expand_inline(&d); + let data = lx.data; + let start = (lx.pos + 1).min(data.len()); + let is_ei = |p: usize| { + p >= 1 + && p + 1 < data.len() + && crate::object::is_white(data[p - 1]) + && data[p] == b'E' + && data[p + 1] == b'I' + && (p + 2 == data.len() || crate::object::is_white(data[p + 2])) + }; + let end = match crate::image::inline_len(self.file, &d, Some(res)) { + Some(len) => (start + len).min(data.len()), + None => { + let mut p = start; + while p + 1 < data.len() && !is_ei(p) { + p += 1; + } + // Data ends before the whitespace preceding `EI`. + p.saturating_sub(1).max(start).min(data.len()) + } + }; + let raw = data[start..end].to_vec(); + let mut p = end; + while p + 1 < data.len() && !(data[p] == b'E' && data[p + 1] == b'I') { + p += 1; + } + lx.pos = (p + 2).min(data.len()); + self.image(&d, &raw, res); + } + fn ocg_name(&self, ocg: &Dict) -> Option { // An optional-content membership dictionary names its groups in /OCGs. let d = match self.file.get(ocg, "OCGs") { @@ -389,7 +548,7 @@ impl<'a> Interp<'a> { } } - fn ext_gstate(&mut self, g: &Dict) { + fn ext_gstate(&mut self, g: &Dict, res: &Dict, depth: usize) { let f = self.file; if let Some(v) = f.get_num(g, "LW") { self.gs.lw = v; @@ -414,27 +573,120 @@ impl<'a> Interp<'a> { 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"); + if let Some(Obj::Array(fa)) = f.get(g, "Font") + && let (Some(Obj::Dict(fd)), Some(size)) = (fa.first().map(|x| f.resolve(x)), fa.get(1).and_then(|x| f.resolve(x).num())) + { + let mut sk = vec![]; + self.gs.font = Some(Rc::new(Font::load(f, fd, &mut sk))); + self.gs.font_size = size; + for s in sk { + self.b.skip(&s); + } + } + // Blend mode: a group composited with it, open until the mode changes or `Q`. + let bm = match f.get(g, "BM") { + Some(Obj::Name(n)) => Some(n.clone()), + Some(Obj::Array(a)) => a.first().and_then(|x| f.resolve(x).name()).map(str::to_string), + _ => None, + }; + if let Some(bm) = bm { + let mode = BlendMode::from_pdf_name(&bm); + if let Some(d) = self.gs.blend_depth { + self.close_to(d); + } + if mode != BlendMode::Normal { + self.gs.blend_depth = Some(self.b.depth()); + let mut grp = Group::new(&bm); + grp.blend = mode; + self.b.open_group(grp); + } + } + // Soft mask: the mask group drawn now, applied to what is painted while it is set. + match f.get(g, "SMask") { + None => {} + Some(Obj::Name(n)) if n == "None" => { + if let Some(d) = self.gs.mask_depth { + self.close_to(d); + } + } + Some(sm) => { + let Some(sm) = sm.dict().cloned() else { return }; + let luminosity = f.get(&sm, "S").and_then(Obj::name) != Some("Alpha"); + let Some(Obj::Stream(gd, graw)) = f.get(&sm, "G").cloned() else { return }; + // Backdrop: in the group's colour space (black by default). + let bc = f.get(&sm, "BC").map(|b| f.nums(b)).unwrap_or_default(); + let gcs = f.get_dict(&gd, "Group").and_then(|gr| f.get(gr, "CS")).map(|c| color_space(f, c, Some(res))).unwrap_or(Cs::Rgb); + let backdrop = if bc.is_empty() { [0.0; 3] } else { gcs.to_rgb(&bc) }; + let saved_b = std::mem::replace(&mut self.b, Builder::new()); + let ctm = self.gs.ctm; + let saved_gs = std::mem::replace(&mut self.gs, GState { ctm, ..GState::default() }); + let saved_stack = std::mem::take(&mut self.saved); + self.form(&gd, &graw, res, depth); + let mask_b = std::mem::replace(&mut self.b, saved_b); + self.gs = saved_gs; + self.saved = saved_stack; + let (nodes, _, sk) = mask_b.finish(); + for s in sk { + self.b.skip(&s); + } + if let Some(d) = self.gs.mask_depth { + self.close_to(d); + } + let mut content = Group::new("Soft Mask"); + content.children = nodes; + let mut grp = Group::new("Soft Mask Group"); + grp.mask = Some(Box::new(SoftMask { content, luminosity, backdrop })); + self.gs.mask_depth = Some(self.b.depth()); + self.b.open_group(grp); + } } } - fn paint_of(f: &Fill) -> Option { + fn paint_of(&mut self, f: &Fill) -> Option { match f { Fill::Color(c) => Some(Paint::Color(*c)), Fill::Paint(p) => Some(p.clone()), + Fill::Tiling(t) => { + self.b.skip("tiling pattern stroke"); + Some(Paint::Color(t.uncolored.unwrap_or([0.5; 3]))) + } Fill::None => None, } } - fn paint(&mut self, fill: bool, rule: Option, stroke: bool) { + fn paint(&mut self, fill: bool, rule: Option, stroke: bool, depth: usize) { let path = std::mem::take(&mut self.path); self.cur = None; self.start = None; + self.paint_path(path.clone(), fill, rule.unwrap_or(FillRule::NonZero), stroke, "Path", depth); + if let Some(r) = self.pending_clip.take() { + self.b.clip(self.gs.ctm * path, r); + } + } + + /// Fill and / or stroke `path` (user space) with the current graphics state. + fn paint_path(&mut self, path: BezPath, fill: bool, rule: FillRule, stroke: bool, name: &str, depth: usize) { + if path.elements().is_empty() { + return; + } 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 mut fill_paint = None; + if fill { + match self.gs.fill.clone() { + Fill::Tiling(t) => { + let page = ctm * path.clone(); + let d = self.b.depth(); + let area = page.bounding_box(); + self.b.clip(page, rule); + self.tile(&t, area, depth); + self.close_to(d); + } + f => fill_paint = self.paint_of(&f).map(|p| (p, self.gs.fill_alpha, rule)), + } + } let stroke = if stroke { - Self::paint_of(&self.gs.stroke).map(|p| { + let sf = self.gs.stroke.clone(); + self.paint_of(&sf).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 }; @@ -451,12 +703,67 @@ impl<'a> Interp<'a> { } else { None }; - if fill.is_some() || stroke.is_some() { - self.b.fill_stroke(path.clone(), ctm, fill, stroke); + if fill_paint.is_some() || stroke.is_some() { + self.b.fill_stroke_named(name, path, ctm, fill_paint, stroke); } - if let Some(r) = self.pending_clip.take() { - self.b.clip(ctm * path, r); + } + + /// Draw a tiling pattern's cells over `area` (page space; the caller clips). + fn tile(&mut self, t: &Tiling, area: kurbo::Rect, depth: usize) { + if depth > 8 { + return; } + // The cell, drawn once in pattern space. + let saved_b = std::mem::replace(&mut self.b, Builder::new()); + let saved_gs = std::mem::take(&mut self.gs); + let saved_stack = std::mem::take(&mut self.saved); + let saved_path = std::mem::take(&mut self.path); + if let Some(c) = t.uncolored { + self.gs.fill = Fill::Color(c); + self.gs.stroke = Fill::Color(c); + self.gs.lock_color = true; + } + self.run(&t.content, &t.res, Affine::IDENTITY, depth + 1); + let cell_b = std::mem::replace(&mut self.b, saved_b); + self.gs = saved_gs; + self.saved = saved_stack; + self.path = saved_path; + let (cell, _, sk) = cell_b.finish(); + for s in sk { + self.b.skip(&s); + } + if cell.is_empty() { + return; + } + let (xs, ys) = (t.xstep.abs().max(1e-6), t.ystep.abs().max(1e-6)); + let inv = t.space.inverse(); + let corners = [(area.x0, area.y0), (area.x1, area.y0), (area.x1, area.y1), (area.x0, area.y1)].map(|(x, y)| inv * Point::new(x, y)); + let (mut lo, mut hi) = (Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)); + for c in corners { + lo = Point::new(lo.x.min(c.x), lo.y.min(c.y)); + hi = Point::new(hi.x.max(c.x), hi.y.max(c.y)); + } + let [bx0, by0, bx1, by1] = t.bbox; + let i0 = ((lo.x - bx1) / xs).floor(); + let i1 = ((hi.x - bx0) / xs).ceil(); + let j0 = ((lo.y - by1) / ys).floor(); + let j1 = ((hi.y - by0) / ys).ceil(); + if !(i0.is_finite() && i1.is_finite() && j0.is_finite() && j1.is_finite()) || (i1 - i0 + 1.0) * (j1 - j0 + 1.0) > MAX_TILES as f64 { + self.b.skip("tiling pattern (too many tiles)"); + return; + } + let cell_clip = kurbo::Rect::new(bx0, by0, bx1, by1).to_path(0.1); + let mut tiles = Group::new("Pattern"); + for j in j0 as i64..=j1 as i64 { + for i in i0 as i64..=i1 as i64 { + let mut g = Group::new("Tile"); + g.transform = t.space * Affine::translate((i as f64 * xs, j as f64 * ys)); + g.clip.push((cell_clip.clone(), FillRule::NonZero)); + g.children = cell.clone(); + tiles.children.push(Node::Group(g)); + } + } + self.b.push(Node::Group(tiles)); } /// A shading dictionary → gradient paint in shading space. @@ -490,19 +797,20 @@ impl<'a> Interp<'a> { let stops = ts .into_iter() .map(|t| { - let c = cs.to_rgb(&func.eval1(dom[0] + t * (dom[1] - dom[0]))); + let c = cs.to_rgb(&func.eval(&[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 { + fn pattern(&mut self, res: &Dict, name: &str, base: Affine, comps: &[f64]) -> 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 }; + let Some(po) = f.get_dict(res, "Pattern").and_then(|pp| pp.get(name)).map(|x| f.resolve(x)) else { return Fill::None }; + let Some(p) = po.dict() else { return Fill::None }; + let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); 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) { @@ -514,10 +822,27 @@ impl<'a> Interp<'a> { None => Fill::None, } } - _ => { - self.b.skip("tiling pattern"); - Fill::Color([0.5; 3]) + 1 => { + let Obj::Stream(d, raw) = po else { return Fill::None }; + let Some(content) = decode_stream(f, d, raw) else { return Fill::None }; + let bb = f.get(d, "BBox").map(|b| f.nums(b)).filter(|b| b.len() == 4).unwrap_or_else(|| vec![0.0, 0.0, 1.0, 1.0]); + let uncolored = (f.get_num(d, "PaintType").unwrap_or(1.0) as i32 == 2).then(|| match comps.len() { + 1 => Cs::Gray.to_rgb(comps), + 4 => Cs::Cmyk.to_rgb(comps), + 3 => Cs::Rgb.to_rgb(comps), + _ => [0.0; 3], + }); + Fill::Tiling(Rc::new(Tiling { + space: base * m, + bbox: [bb[0].min(bb[2]), bb[1].min(bb[3]), bb[0].max(bb[2]), bb[1].max(bb[3])], + xstep: f.get_num(d, "XStep").unwrap_or(bb[2] - bb[0]), + ystep: f.get_num(d, "YStep").unwrap_or(bb[3] - bb[1]), + content, + res: f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone()), + uncolored, + })) } + _ => Fill::None, } } @@ -541,33 +866,177 @@ impl<'a> Interp<'a> { self.b.fill_stroke(path, self.gs.ctm, Some((Paint::Gradient(g), self.gs.fill_alpha, FillRule::NonZero)), None); } + /// Run a form XObject (matrix, bounding-box clip, its own resources) in a saved state. + fn form(&mut self, d: &Dict, raw: &[u8], res: &Dict, depth: usize) { + if depth >= 12 { + return; + } + let f = self.file; + 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 * r.to_path(0.1), FillRule::NonZero); + } + let base = self.gs.ctm; + let path = std::mem::take(&mut self.path); + let (tm, tlm) = (self.tm, self.tlm); + self.run(&content, &form_res, base, depth + 1); + self.path = path; + self.tm = tm; + self.tlm = tlm; + if let Some((g, dd)) = self.saved.pop() { + self.gs = g; + self.close_to(dd); + } + } + 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"), + Some("Form") => self.form(d, raw, res, depth), + Some("Image") => self.image(d, raw, res), _ => {} } } + + /// Paint an image: the unit square of user space, pixel (0, 0) at its top-left corner. + fn image(&mut self, d: &Dict, raw: &[u8], res: &Dict) { + let fill = match &self.gs.fill { + Fill::Color(c) => *c, + _ => [0.0; 3], + }; + match crate::image::decode(self.file, d, raw, Some(res), fill) { + Ok(img) => { + let (w, h) = (img.width as f64, img.height as f64); + let transform = self.gs.ctm * Affine::new([1.0 / w, 0.0, 0.0, -1.0 / h, 0.0, 1.0]); + self.b.push(Node::Image(SvgImage { + name: "Image".into(), + transform, + width: img.width, + height: img.height, + rgba: std::sync::Arc::new(img.rgba), + opacity: self.gs.fill_alpha, + })); + } + Err(what) => self.b.skip(&what), + } + } + + /// The font named `name` in the resources (cached by object). + fn font(&mut self, res: &Dict, name: &str) -> Option> { + let f = self.file; + let entry = f.get_dict(res, "Font")?.get(name)?; + let key = match entry { + Obj::Ref(n, g) => Some(format!("{n} {g}")), + _ => None, + }; + if let Some(k) = &key + && let Some(font) = self.fonts.get(k) + { + return Some(font.clone()); + } + let d = f.resolve(entry).dict()?; + let mut sk = vec![]; + let font = Rc::new(Font::load(f, d, &mut sk)); + for s in sk { + self.b.skip(&s); + } + if let Some(k) = key { + self.fonts.insert(k, font.clone()); + } + Some(font) + } + + fn text_move(&mut self, tx: f64, ty: f64) { + self.tlm *= Affine::translate((tx, ty)); + self.tm = self.tlm; + } + + /// Show text (`Tj`, `TJ`, `'`, `"`): glyph outlines placed by the text matrix, painted by + /// the text render mode as one compound shape. + fn show(&mut self, items: &[TextItem], res: &Dict, pattern_base: Affine, depth: usize) { + let Some(font) = self.gs.font.clone() else { + self.b.skip("text (no font)"); + return; + }; + let (fs, th, rise) = (self.gs.font_size, self.gs.h_scale, self.gs.rise); + let mut path = BezPath::new(); + let mut text = String::new(); + let mut missing = false; + for it in items { + match it { + TextItem::Adjust(v) => { + let d = -v / 1000.0 * fs; + self.tm *= if font.vertical { Affine::translate((0.0, d)) } else { Affine::translate((d * th, 0.0)) }; + } + TextItem::Str(s) => { + for (code, nbytes) in font.codes(s) { + let w0 = font.width(code); + let trm = self.tm * Affine::new([fs * th, 0.0, 0.0, fs, 0.0, rise]); + let trm = if font.vertical { trm * Affine::translate((-w0 / 2.0, -0.88)) } else { trm }; + if let Some((proc_, t3res)) = font.type3_proc(code) { + let proc_ = proc_.to_vec(); + let r = t3res.cloned().unwrap_or_else(|| res.clone()); + self.type3_glyph(&proc_, &r, trm * font.matrix, pattern_base, depth); + } else if let Some(g) = font.glyph(code) { + path.extend(trm * (*g).clone()); + } else if code != 32 { + missing = true; + } + text.push_str(&font.unicode(code)); + let tw = if nbytes == 1 && code == 32 { self.gs.word_spacing } else { 0.0 }; + if font.vertical { + self.tm *= Affine::translate((0.0, -(fs + self.gs.char_spacing + tw))); + } else { + self.tm *= Affine::translate(((w0 * fs + self.gs.char_spacing + tw) * th, 0.0)); + } + } + } + } + } + if missing && !matches!(font.program, crate::font::Program::Type3 { .. }) { + self.b.skip(&format!("glyphs missing from font {}", font.base_name)); + } + if path.elements().is_empty() { + return; + } + let mode = self.gs.render_mode; + let fill = matches!(mode, 0 | 2 | 4 | 6); + let stroke = matches!(mode, 1 | 2 | 5 | 6); + let label: String = text.chars().filter(|c| !c.is_control()).take(40).collect(); + let name = if label.trim().is_empty() { "Text".to_string() } else { format!("Text: {}", label.trim()) }; + if mode >= 4 { + let clip = self.text_clip.get_or_insert_with(BezPath::new); + clip.extend(self.gs.ctm * path.clone()); + } + if fill || stroke { + self.paint_path(path, fill, FillRule::NonZero, stroke, &name, depth); + } + } + + /// Run a Type 3 glyph procedure with `m` (glyph space → user space). + fn type3_glyph(&mut self, content: &[u8], res: &Dict, m: Affine, pattern_base: Affine, depth: usize) { + if depth >= 8 { + return; + } + self.saved.push((self.gs.clone(), self.b.depth())); + self.gs.ctm *= m; + let path = std::mem::take(&mut self.path); + let (tm, tlm) = (self.tm, self.tlm); + self.run(content, res, pattern_base, depth + 1); + self.path = path; + self.tm = tm; + self.tlm = tlm; + if let Some((g, d)) = self.saved.pop() { + self.gs = g; + self.close_to(d); + } + } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b4e91f2bd..39c82b6e1 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -49,7 +49,7 @@ fn pdf_paths_clip_gradient_and_layers() { 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())); + assert!(doc.skipped.contains(&"text (no font)".to_string()), "{:?}", doc.skipped); 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); @@ -148,3 +148,362 @@ fn filters_decode() { let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6); assert_eq!(object::inflate(&z).unwrap(), b"abc"); } + +// ------------------------------------------------------------------ M13.6: text, images, +// patterns, soft masks, blend modes, pages + +type Objs = Vec<(u32, String, Option>)>; + +/// A one-page 200×100 PDF: `resources` is the page's resource dictionary body, `extra` more +/// objects (numbered from 10). +fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec { + let mut objs: Objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << {resources} >> /Contents 4 0 R >>"), None), + (4, "<< >>".into(), Some(content.as_bytes().to_vec())), + ]; + objs.extend(extra); + pdf(&objs, 1) +} + +fn render(doc: &Doc) -> effectcraft_raster::Image { + let (w, h) = doc.pixel_size(); + effectcraft_svg::rasterize(doc, w, h, 1.0) +} + +fn coverage(img: &effectcraft_raster::Image, x0: i64, y0: i64, x1: i64, y1: i64) -> f32 { + let mut s = 0.0; + for y in y0..y1 { + for x in x0..x1 { + s += img.get(x, y)[3]; + } + } + s +} + +fn shape_names(doc: &Doc) -> Vec { + fn walk(g: &effectcraft_svg::Group, out: &mut Vec) { + for c in &g.children { + match c { + Node::Group(s) => walk(s, out), + Node::Shape(s) => out.push(s.name.clone()), + Node::Image(i) => out.push(i.name.clone()), + } + } + } + let mut out = vec![]; + walk(&doc.root, &mut out); + out +} + +/// Type 2 charstring operands. +fn t2(v: &[i32]) -> Vec { + let mut o = vec![]; + for &x in v { + if (-107..=107).contains(&x) { + o.push((x + 139) as u8); + } else { + o.push(28); + o.extend_from_slice(&(x as i16).to_be_bytes()); + } + } + o +} + +/// A CFF font with a 500-unit square glyph `square` (x 50..550, y 0..500, width 600). +fn square_cff() -> Vec { + let mut sq = t2(&[600, 50, 0]); + sq.push(21); + sq.extend(t2(&[500, 500, -500])); + sq.push(6); + sq.push(14); + crate::cff::write_test_cff(&[(".notdef", vec![14]), ("square", sq)]) +} + +const SQUARE_FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Square /FirstChar 65 /LastChar 65 /Widths [600] /Encoding << /Differences [65 /square] >> /FontDescriptor 11 0 R >>"; + +fn square_font_objs() -> Objs { + vec![ + (10, SQUARE_FONT.into(), None), + (11, "<< /Type /FontDescriptor /FontName /Square /Flags 32 /FontFile3 12 0 R >>".into(), None), + (12, "<< /Subtype /Type1C >>".into(), Some(square_cff())), + ] +} + +#[test] +fn text_with_an_embedded_cff_font_spacing_and_tj() { + // Squares 10 pt per 100 units: A at 0..60 (square 5..55), Tc 10 and a TJ kern of −400 + // (+40) put the second at 110 (square 115..165). + let bytes = page_pdf("BT /F1 100 Tf 0 0 Td 10 Tc [(A) -400 (A)] TJ ET", "/Font << /F1 10 0 R >>", square_font_objs()); + let doc = parse(&bytes).unwrap(); + assert!(!doc.skipped.iter().any(|s| s.contains("text")), "{:?}", doc.skipped); + assert!(shape_names(&doc).iter().any(|n| n.starts_with("Text")), "{:?}", shape_names(&doc)); + let img = render(&doc); + for (x, on) in [(30, true), (60, false), (105, false), (140, true), (170, false)] { + assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}: {:?}", img.get(x, 75)); + } + // Squares are 50 pt tall: rows 50..100. + assert_eq!(img.get(30, 45)[3], 0.0); +} + +#[test] +fn text_render_mode_clip() { + let bytes = page_pdf("BT /F1 100 Tf 7 Tr 0 0 Td (A) Tj ET 1 0 0 rg 0 0 200 100 re f", "/Font << /F1 10 0 R >>", square_font_objs()); + let img = render(&parse(&bytes).unwrap()); + assert!(img.get(30, 75)[0] > 0.99 && img.get(30, 75)[3] > 0.99); + assert_eq!(img.get(100, 75)[3], 0.0, "clipped to the glyph"); + assert_eq!(img.get(30, 25)[3], 0.0); +} + +#[test] +fn text_with_an_embedded_type1_font() { + // hsbw 50 600; 0 0 rmoveto; 500 hlineto 500 vlineto -500 hlineto closepath endchar + let cs = vec![50 + 139, 248, 236, 13, 139, 139, 21, 248, 136, 6, 248, 136, 7, 252, 136, 6, 9, 14]; + let font = crate::type1::write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(66, "box")]); + let extra: Objs = vec![ + (10, "<< /Type /Font /Subtype /Type1 /BaseFont /Box /FirstChar 66 /LastChar 66 /Widths [600] /FontDescriptor 11 0 R >>".into(), None), + (11, "<< /Type /FontDescriptor /FontName /Box /Flags 4 /FontFile 12 0 R >>".into(), None), + (12, "<< /Length1 0 /Length2 0 /Length3 0 >>".into(), Some(font)), + ]; + // The font's built-in encoding maps B (66) to `box`. + let img = render(&parse(&page_pdf("BT /F1 100 Tf 0 0 Td (BB) Tj ET", "/Font << /F1 10 0 R >>", extra)).unwrap()); + for (x, on) in [(30, true), (60, false), (90, true), (120, false)] { + assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}"); + } +} + +#[test] +fn text_with_a_type3_font() { + let extra: Objs = vec![ + ( + 10, + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /sq 11 0 R >> /Encoding << /Type /Encoding /Differences [65 /sq] >> /FirstChar 65 /LastChar 65 /Widths [100] >>".into(), + None, + ), + (11, "<< >>".into(), Some(b"100 0 d0 0 0 100 100 re f".to_vec())), + ]; + let img = render(&parse(&page_pdf("BT 0 0 1 rg /F1 20 Tf 10 10 Td (AA) Tj ET", "/Font << /F1 10 0 R >>", extra)).unwrap()); + // 20 pt squares from x = 10, advancing 20: user 10..30 and 30..50, y 10..30 (rows 70..90). + assert!(img.get(20, 80)[2] > 0.99 && img.get(20, 80)[3] > 0.99, "{:?}", img.get(20, 80)); + assert!(img.get(40, 80)[3] > 0.99); + assert_eq!(img.get(55, 80)[3], 0.0); + assert_eq!(img.get(20, 60)[3], 0.0); +} + +#[test] +fn text_with_standard_fonts_embedded_truetype_and_cid_fonts() { + use skrifa::MetadataProvider; + let inter = effectcraft_text::fonts::INTER_REGULAR.to_vec(); + // Not embedded: Helvetica drawn with the bundled sans serif. + let std14 = page_pdf("BT /F1 60 Tf 10 30 Td (Hi) Tj ET", "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>", vec![]); + let doc = parse(&std14).unwrap(); + assert!(shape_names(&doc).contains(&"Text: Hi".to_string()), "{:?}", shape_names(&doc)); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let a = render(&doc); + let ink = coverage(&a, 0, 0, 200, 100); + assert!(ink > 300.0, "{ink}"); + // Ink only on the text line: baseline at row 70, cap height ≈ 44 px above it. + assert!(coverage(&a, 0, 72, 200, 100) < 1.0 && coverage(&a, 0, 0, 200, 20) < 1.0); + // The same face embedded (a TrueType font program, WinAnsiEncoding): the same outlines. + let tt = page_pdf( + "BT /F1 60 Tf 10 30 Td (Hi) Tj ET", + "/Font << /F1 10 0 R >>", + vec![ + (10, "<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+Inter /Encoding /WinAnsiEncoding /FontDescriptor 11 0 R >>".into(), None), + (11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None), + (12, "<< >>".into(), Some(inter.clone())), + ], + ); + let b = render(&parse(&tt).unwrap()); + assert!((coverage(&b, 0, 0, 200, 100) - ink).abs() < 1.0, "{} vs {ink}", coverage(&b, 0, 0, 200, 100)); + // A composite font: Identity-H codes are glyph ids of a CIDFontType2. + let font = skrifa::FontRef::new(&inter).unwrap(); + let gid = |c: char| font.charmap().map(c).unwrap().to_u32() as u16; + let (h, i) = (gid('H'), gid('i')); + let loc = skrifa::instance::LocationRef::default(); + let upem = font.metrics(skrifa::instance::Size::unscaled(), loc).units_per_em as f32; + let w = |g: u16| font.glyph_metrics(skrifa::instance::Size::unscaled(), loc).advance_width(skrifa::GlyphId::new(g as u32)).unwrap() / upem * 1000.0; + let cid = page_pdf( + &format!("BT /F1 60 Tf 10 30 Td <{h:04X}{i:04X}> Tj ET"), + "/Font << /F1 10 0 R >>", + vec![ + (10, "<< /Type /Font /Subtype /Type0 /BaseFont /Inter /Encoding /Identity-H /DescendantFonts [13 0 R] >>".into(), None), + ( + 13, + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Inter /CIDToGIDMap /Identity /W [{h} [{}] {i} [{}]] /FontDescriptor 11 0 R >>", + w(h), + w(i) + ), + None, + ), + (11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None), + (12, "<< >>".into(), Some(inter)), + ], + ); + let c = render(&parse(&cid).unwrap()); + assert!((coverage(&c, 0, 0, 200, 100) - ink).abs() < 1.0, "{} vs {ink}", coverage(&c, 0, 0, 200, 100)); +} + +#[test] +fn images_flate_smask_indexed_stencil_inline_and_dct() { + let objs = |im: &str, data: Vec, extra: Objs| -> Vec { + let mut e: Objs = vec![(10, im.to_string(), Some(data))]; + e.extend(extra); + page_pdf("q 100 0 0 50 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", e) + }; + // RGB with a soft mask: red opaque, green transparent. + let doc = parse(&objs( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R >>", + vec![255, 0, 0, 0, 255, 0], + vec![(11, "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 >>".into(), Some(vec![255, 0]))], + )) + .unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + assert!(img.get(25, 75)[0] > 0.95 && img.get(25, 75)[3] > 0.95, "{:?}", img.get(25, 75)); + assert!(img.get(75, 75)[3] < 0.05, "{:?}", img.get(75, 75)); + assert_eq!(img.get(25, 25)[3], 0.0, "the image is the unit square of user space"); + // Indexed (palette in a string), 8 bits. + let img = render( + &parse(&objs( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1 <0000FFFFFF00>] /BitsPerComponent 8 >>", + vec![1, 0], + vec![], + )) + .unwrap(), + ); + let (l, r) = (img.get(25, 75), img.get(75, 75)); + assert!(l[0] > 0.95 && l[1] > 0.95 && l[2] < 0.05 && r[2] > 0.95 && r[0] < 0.05, "{l:?} {r:?}"); + // ICCBased with an /Alternate space, 1-bit samples. + let img = render( + &parse(&objs( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/ICCBased 11 0 R] /BitsPerComponent 1 >>", + vec![0b0100_0000], + vec![(11, "<< /N 1 /Alternate /DeviceGray >>".into(), Some(vec![0; 16]))], + )) + .unwrap(), + ); + assert!(img.get(25, 75)[0] < 0.05 && img.get(75, 75)[0] > 0.95); + // Stencil mask painted in the fill colour (sample 0 paints). + let stencil = page_pdf( + "0 1 0 rg q 100 0 0 50 0 0 cm /Im1 Do Q", + "/XObject << /Im1 10 0 R >>", + vec![(10, "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ImageMask true >>".into(), Some(vec![0b0100_0000]))], + ); + let img = render(&parse(&stencil).unwrap()); + assert!(img.get(25, 75)[1] > 0.95 && img.get(25, 75)[3] > 0.95); + assert!(img.get(75, 75)[3] < 0.05); + // Inline image (abbreviated keys, ASCIIHex) in the top half. + let inline = page_pdf("q 100 0 0 50 0 50 cm BI /W 2 /H 1 /CS /RGB /BPC 8 /F /AHx ID 00FF00FF0000> EI Q 0 0 1 rg 150 0 50 50 re f", "", vec![]); + let doc = parse(&inline).unwrap(); + let img = render(&doc); + assert!(img.get(25, 25)[1] > 0.95 && img.get(75, 25)[0] > 0.95, "{:?} {:?}", img.get(25, 25), img.get(75, 25)); + assert!(img.get(175, 75)[2] > 0.95, "content after EI still runs"); + // DCT (JPEG): left half red, right half blue. + let mut jpg = vec![]; + let mut rgb = ::image::RgbImage::new(16, 16); + for (x, _, p) in rgb.enumerate_pixels_mut() { + *p = if x < 8 { ::image::Rgb([255, 0, 0]) } else { ::image::Rgb([0, 0, 255]) }; + } + ::image::DynamicImage::ImageRgb8(rgb).write_to(&mut std::io::Cursor::new(&mut jpg), ::image::ImageFormat::Jpeg).unwrap(); + let doc = + parse(&objs("<< /Type /XObject /Subtype /Image /Width 16 /Height 16 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode >>", jpg, vec![])) + .unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + let (l, r) = (img.get(20, 75), img.get(80, 75)); + assert!(l[0] > 0.85 && l[2] < 0.15 && r[2] > 0.85 && r[0] < 0.15, "{l:?} {r:?}"); +} + +#[test] +fn soft_masks_and_blend_modes() { + // Luminosity: a form that is white on the left half, transparent (→ black backdrop) elsewhere. + let lum = page_pdf( + "q /GS1 gs 1 0 0 rg 0 0 200 100 re f Q 0 0 1 rg 0 0 20 20 re f", + "/ExtGState << /GS1 << /SMask << /S /Luminosity /G 10 0 R >> >> >>", + vec![( + 10, + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Group << /S /Transparency /CS /DeviceGray >> >>".into(), + Some(b"1 g 0 0 100 100 re f".to_vec()), + )], + ); + let doc = parse(&lum).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + assert!(img.get(50, 50)[0] > 0.99 && img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); + assert!(img.get(150, 50)[3] < 0.01, "{:?}", img.get(150, 50)); + assert!(img.get(10, 90)[2] > 0.99, "the mask ends with Q"); + // Alpha: the mask form's coverage. + let alpha = page_pdf( + "/GS1 gs 1 0 0 rg 0 0 200 100 re f", + "/ExtGState << /GS1 << /SMask << /S /Alpha /G 10 0 R >> >> >>", + vec![(10, "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] >>".into(), Some(b"0 0 0 rg 100 0 100 100 re f".to_vec()))], + ); + let img = render(&parse(&alpha).unwrap()); + assert!(img.get(50, 50)[3] < 0.01 && img.get(150, 50)[0] > 0.99); + // Multiply over a light blue backdrop; Screen in the right half. + let blend = page_pdf( + "0.5 0.5 1 rg 0 0 200 100 re f q /M gs 1 1 0 rg 0 0 100 100 re f Q q /S gs 0.5 0 0 rg 100 0 100 100 re f Q", + "/ExtGState << /M << /BM /Multiply >> /S << /BM [/Screen] >> >>", + vec![], + ); + let img = render(&parse(&blend).unwrap()); + let m = img.get(50, 50); + assert!((m[0] - 0.5).abs() < 0.01 && (m[1] - 0.5).abs() < 0.01 && m[2] < 0.01, "multiply {m:?}"); + let s = img.get(150, 50); + assert!((s[0] - 0.75).abs() < 0.01 && (s[1] - 0.5).abs() < 0.01 && (s[2] - 1.0).abs() < 0.01, "screen {s:?}"); +} + +#[test] +fn tiling_patterns_coloured_and_uncoloured() { + let cell = b"1 0 0 rg 0 0 10 10 re f 0 0 1 rg 10 10 10 10 re f".to_vec(); + let bytes = page_pdf( + "/Pattern cs /P1 scn 0 0 100 100 re f /Cs1 cs 0 1 0 /P2 scn 100 0 100 100 re f", + "/Pattern << /P1 10 0 R /P2 11 0 R >> /ColorSpace << /Cs1 [/Pattern /DeviceRGB] >>", + vec![ + (10, "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), Some(cell)), + ( + 11, + "<< /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 >>".into(), + Some(b"1 0 0 rg 0 0 10 10 re f".to_vec()), + ), + ], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + // PDF (x, y) → pixel (x, 100 − y). + let at = |x: i64, y: i64| img.get(x, 100 - y); + assert!(at(5, 5)[0] > 0.99 && at(25, 5)[0] > 0.99 && at(85, 45)[0] > 0.99, "{:?}", at(5, 5)); + assert!(at(15, 15)[2] > 0.99 && at(55, 35)[2] > 0.99); + assert_eq!(at(15, 5)[3], 0.0); + // Uncoloured: the cell in the colour given with the pattern (green), its own colour ignored. + assert!(at(105, 5)[1] > 0.99 && at(105, 5)[0] < 0.01, "{:?}", at(105, 5)); + assert_eq!(at(115, 15)[3], 0.0); +} + +#[test] +fn multiple_pages_and_calculator_shadings() { + let objs: Objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(b"1 0 0 rg 0 0 200 100 re f".to_vec())), + (5, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /Shading << /Sh 7 0 R >> >> /Contents 6 0 R >>".into(), None), + (6, "<< >>".into(), Some(b"/Sh sh".to_vec())), + (7, "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 8 0 R >>".into(), None), + (8, "<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] >>".into(), Some(b"{ 0 0 }".to_vec())), + ]; + let bytes = pdf(&objs, 1); + assert_eq!(page_count(&bytes), 2); + let p1 = parse_page(&bytes, 0).unwrap(); + assert_eq!((p1.width, p1.height), (200.0, 100.0)); + let p2 = parse_page(&bytes, 1).unwrap(); + assert_eq!((p2.width, p2.height), (100.0, 50.0)); + assert!(p2.skipped.is_empty(), "{:?}", p2.skipped); + let img = render(&p2); + assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25)); + assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2))); +} diff --git a/crates/pdf/src/type1.rs b/crates/pdf/src/type1.rs new file mode 100644 index 000000000..b9e680cfd --- /dev/null +++ b/crates/pdf/src/type1.rs @@ -0,0 +1,535 @@ +//! Type 1 font programs (`FontFile`), from Adobe's published *Adobe Type 1 Font Format*: +//! the cleartext part (`/FontMatrix`, the built-in `/Encoding`), `eexec` decryption, `/Subrs` +//! and `/CharStrings`, and a Type 1 charstring interpreter (including flex and hint +//! replacement through `callothersubr`, and `seac` accented characters). + +use std::collections::HashMap; + +use kurbo::BezPath; + +const EEXEC_R: u16 = 55665; +const CHARSTRING_R: u16 = 4330; + +fn decrypt(data: &[u8], mut r: u16, skip: usize) -> Vec { + const C1: u16 = 52845; + const C2: u16 = 22719; + let mut out = Vec::with_capacity(data.len()); + for &c in data { + out.push(c ^ (r >> 8) as u8); + r = (c as u16).wrapping_add(r).wrapping_mul(C1).wrapping_add(C2); + } + out.drain(..skip.min(out.len())); + out +} + +/// Encrypt (for test fonts): the inverse of [`decrypt`] with `skip` leading zero bytes. +#[cfg(test)] +pub(crate) fn encrypt(plain: &[u8], mut r: u16, skip: usize) -> Vec { + const C1: u16 = 52845; + const C2: u16 = 22719; + let mut out = vec![]; + for &p in std::iter::repeat_n(&0u8, skip).chain(plain.iter()) { + let c = p ^ (r >> 8) as u8; + r = (c as u16).wrapping_add(r).wrapping_mul(C1).wrapping_add(C2); + out.push(c); + } + out +} + +/// A parsed Type 1 font. +#[derive(Clone, Debug, Default)] +pub struct Type1 { + pub font_matrix: Option<[f64; 6]>, + /// The built-in encoding (code → glyph name), when the font has one. + pub encoding: HashMap, + subrs: Vec>, + charstrings: HashMap>, +} + +fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + if from >= hay.len() { + return None; + } + hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from) +} + +/// Strip PFB segment headers (`0x80 type len32le`). +fn unpfb(data: &[u8]) -> Vec { + if data.first() != Some(&0x80) { + return data.to_vec(); + } + let mut out = vec![]; + let mut p = 0; + while p + 6 <= data.len() && data[p] == 0x80 && data[p + 1] != 3 { + let n = u32::from_le_bytes([data[p + 2], data[p + 3], data[p + 4], data[p + 5]]) as usize; + let end = (p + 6 + n).min(data.len()); + out.extend_from_slice(&data[p + 6..end]); + p = end; + } + out +} + +fn tokens(s: &[u8]) -> impl Iterator { + s.split(|c| c.is_ascii_whitespace() || matches!(c, b'[' | b']' | b'{' | b'}')).filter(|t| !t.is_empty()) +} + +impl Type1 { + pub fn parse(data: &[u8]) -> Option { + let data = unpfb(data); + let mut f = Type1::default(); + let ee = find(&data, b"eexec", 0)?; + let clear = &data[..ee]; + // /FontMatrix [a b c d e f] + if let Some(p) = find(clear, b"/FontMatrix", 0) { + let end = find(clear, b"]", p).unwrap_or(clear.len()); + let v: Vec = tokens(&clear[p + 11..end]).filter_map(|t| std::str::from_utf8(t).ok()?.parse().ok()).collect(); + if v.len() == 6 { + f.font_matrix = Some([v[0], v[1], v[2], v[3], v[4], v[5]]); + } + } + // /Encoding: `dup code /name put` entries (StandardEncoding otherwise). + if let Some(p) = find(clear, b"/Encoding", 0) { + let mut toks = tokens(&clear[p + 9..]).peekable(); + let mut last: Vec<&[u8]> = vec![]; + for t in toks.by_ref() { + if t == b"readonly" || t == b"def" { + break; + } + if t == b"put" && last.len() >= 3 && last[last.len() - 3] == b"dup" { + let code = std::str::from_utf8(last[last.len() - 2]).ok().and_then(|c| c.parse::().ok()); + let name = last[last.len() - 1].strip_prefix(b"/"); + if let (Some(c), Some(n)) = (code, name) { + f.encoding.insert(c, String::from_utf8_lossy(n).into_owned()); + } + } + last.push(t); + if last.len() > 4 { + last.remove(0); + } + } + } + // The encrypted part: binary or hexadecimal. + let mut body = &data[ee + 5..]; + while body.first().is_some_and(|c| c.is_ascii_whitespace()) { + body = &body[1..]; + } + let hex = body.iter().take(4).all(u8::is_ascii_hexdigit); + let raw: Vec = if hex { + let digits: Vec = body.iter().copied().filter(u8::is_ascii_hexdigit).collect(); + digits.chunks_exact(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect() + } else { + body.to_vec() + }; + let priv_ = decrypt(&raw, EEXEC_R, 4); + let len_iv = + find(&priv_, b"/lenIV", 0).and_then(|p| tokens(&priv_[p + 6..]).next()).and_then(|t| std::str::from_utf8(t).ok()?.parse::().ok()).unwrap_or(4); + let cs = |bytes: &[u8]| if len_iv < 0 { bytes.to_vec() } else { decrypt(bytes, CHARSTRING_R, len_iv as usize) }; + // Binary items: ` RD ` (RD may be spelt `-|`). + let read_binary = |p: usize| -> Option<(Vec, usize)> { + // p points at the length token. + let mut q = p; + while q < priv_.len() && priv_[q].is_ascii_whitespace() { + q += 1; + } + let s = q; + while q < priv_.len() && priv_[q].is_ascii_digit() { + q += 1; + } + let n: usize = std::str::from_utf8(&priv_[s..q]).ok()?.parse().ok()?; + while q < priv_.len() && priv_[q].is_ascii_whitespace() { + q += 1; + } + while q < priv_.len() && !priv_[q].is_ascii_whitespace() { + q += 1; + } + q += 1; + let bytes = priv_.get(q..q + n)?; + Some((cs(bytes), q + n)) + }; + if let Some(p) = find(&priv_, b"/Subrs", 0) { + let mut q = p + 6; + while let Some(d) = find(&priv_, b"dup", q) { + let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len()); + if d > lim { + break; + } + let mut r = d + 3; + while r < priv_.len() && priv_[r].is_ascii_whitespace() { + r += 1; + } + let s = r; + while r < priv_.len() && priv_[r].is_ascii_digit() { + r += 1; + } + let Some(i) = std::str::from_utf8(&priv_[s..r]).ok().and_then(|x| x.parse::().ok()) else { break }; + let Some((bytes, end)) = read_binary(r) else { break }; + if i < 65536 { + if f.subrs.len() <= i { + f.subrs.resize(i + 1, vec![]); + } + f.subrs[i] = bytes; + } + q = end; + } + } + let cs_at = find(&priv_, b"/CharStrings", 0)?; + let mut q = cs_at + 12; + // Skip `n dict dup begin`. + q = find(&priv_, b"begin", q).map(|b| b + 5).unwrap_or(q); + while let Some(slash) = priv_[q.min(priv_.len())..].iter().position(|&c| c == b'/').map(|x| x + q) { + let mut e = slash + 1; + while e < priv_.len() && !priv_[e].is_ascii_whitespace() && !matches!(priv_[e], b'/' | b'[' | b'{') { + e += 1; + } + let name = String::from_utf8_lossy(&priv_[slash + 1..e]).into_owned(); + let Some((bytes, end)) = read_binary(e) else { break }; + f.charstrings.insert(name, bytes); + q = end; + // Stop at the end of the CharStrings dictionary. + let mut r = q; + while r < priv_.len() && !priv_[r].is_ascii_whitespace() { + r += 1; + } + let rest = &priv_[r.min(priv_.len())..]; + let next_tok = tokens(rest).next().unwrap_or(b""); + if next_tok == b"end" { + break; + } + } + (!f.charstrings.is_empty()).then_some(f) + } + + pub fn has_glyph(&self, name: &str) -> bool { + self.charstrings.contains_key(name) + } + + /// A glyph's outline in glyph space and its advance width. + pub fn outline(&self, name: &str) -> Option<(BezPath, f64)> { + self.outline_depth(name, 0) + } + + fn outline_depth(&self, name: &str, depth: usize) -> Option<(BezPath, f64)> { + let cs = self.charstrings.get(name)?; + let mut st = T1 { + font: self, + path: BezPath::new(), + x: 0.0, + y: 0.0, + stack: vec![], + ps: vec![], + width: 0.0, + sbx: 0.0, + open: false, + flex: None, + done: false, + depth: 0, + seac: None, + }; + st.run(cs); + if st.open { + st.path.close_path(); + } + let mut path = st.path; + if let Some((asb, adx, ady, b, a)) = st.seac + && depth < 2 + { + let name_of = |c: u8| crate::encoding::Base::Standard.name(c); + if let Some((bp, _)) = name_of(b).and_then(|n| self.outline_depth(&n, depth + 1)) { + path.extend(bp); + } + if let Some((ap, _)) = name_of(a).and_then(|n| self.outline_depth(&n, depth + 1)) { + // The accent's origin: its own sidebearing is replaced by `asb`. + path.extend(kurbo::Affine::translate((adx - asb + st.sbx, ady)) * ap); + } + } + Some((path, st.width)) + } +} + +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, + } +} + +struct T1<'a> { + font: &'a Type1, + path: BezPath, + x: f64, + y: f64, + stack: Vec, + /// The PostScript operand stack shared with `callothersubr` / `pop`. + ps: Vec, + width: f64, + sbx: f64, + open: bool, + /// Flex points being collected (othersubr 1 … 0). + flex: Option>, + done: bool, + depth: usize, + seac: Option<(f64, f64, f64, u8, u8)>, +} + +impl T1<'_> { + fn rmove(&mut self, dx: f64, dy: f64) { + self.x += dx; + self.y += dy; + if let Some(f) = &mut self.flex { + f.push((self.x, self.y)); + return; + } + if self.open { + self.path.close_path(); + } + self.path.move_to((self.x, self.y)); + self.open = true; + } + fn rline(&mut self, dx: f64, dy: f64) { + if !self.open { + self.path.move_to((self.x, self.y)); + self.open = true; + } + self.x += dx; + self.y += dy; + self.path.line_to((self.x, self.y)); + } + fn rcurve(&mut self, d: [f64; 6]) { + if !self.open { + self.path.move_to((self.x, self.y)); + self.open = true; + } + let (x1, y1) = (self.x + d[0], self.y + d[1]); + let (x2, y2) = (x1 + d[2], y1 + d[3]); + self.x = x2 + d[4]; + self.y = y2 + d[5]; + self.path.curve_to((x1, y1), (x2, y2), (self.x, self.y)); + } + + fn run(&mut self, cs: &[u8]) { + if self.depth > 10 { + return; + } + let mut i = 0; + while i < cs.len() && !self.done { + let b0 = cs[i]; + i += 1; + let s = |st: &Self, k: usize| st.stack.get(k).copied().unwrap_or(0.0); + match b0 { + 32..=246 => self.stack.push(b0 as f64 - 139.0), + 247..=250 => { + self.stack.push((b0 as f64 - 247.0) * 256.0 + *cs.get(i).unwrap_or(&0) as f64 + 108.0); + i += 1; + } + 251..=254 => { + self.stack.push(-(b0 as f64 - 251.0) * 256.0 - *cs.get(i).unwrap_or(&0) as f64 - 108.0); + i += 1; + } + 255 => { + let b = |k: usize| *cs.get(i + k).unwrap_or(&0); + self.stack.push(i32::from_be_bytes([b(0), b(1), b(2), b(3)]) as f64); + i += 4; + } + 13 => { + // hsbw: sbx wx + self.sbx = s(self, 0); + self.x = self.sbx; + self.y = 0.0; + self.width = s(self, 1); + self.stack.clear(); + } + 21 => { + let (a, b) = (s(self, 0), s(self, 1)); + self.rmove(a, b); + self.stack.clear(); + } + 22 => { + let a = s(self, 0); + self.rmove(a, 0.0); + self.stack.clear(); + } + 4 => { + let a = s(self, 0); + self.rmove(0.0, a); + self.stack.clear(); + } + 5 => { + let (a, b) = (s(self, 0), s(self, 1)); + self.rline(a, b); + self.stack.clear(); + } + 6 => { + let a = s(self, 0); + self.rline(a, 0.0); + self.stack.clear(); + } + 7 => { + let a = s(self, 0); + self.rline(0.0, a); + self.stack.clear(); + } + 8 => { + let d = [s(self, 0), s(self, 1), s(self, 2), s(self, 3), s(self, 4), s(self, 5)]; + self.rcurve(d); + self.stack.clear(); + } + 30 => { + // vhcurveto: dy1 dx2 dy2 dx3 + let d = [0.0, s(self, 0), s(self, 1), s(self, 2), s(self, 3), 0.0]; + self.rcurve(d); + self.stack.clear(); + } + 31 => { + // hvcurveto: dx1 dx2 dy2 dy3 + let d = [s(self, 0), 0.0, s(self, 1), s(self, 2), 0.0, s(self, 3)]; + self.rcurve(d); + self.stack.clear(); + } + 9 => { + if self.open { + self.path.close_path(); + self.open = false; + } + self.stack.clear(); + } + 10 => { + let Some(n) = self.stack.pop() else { continue }; + if let Some(sub) = self.font.subrs.get(n.max(0.0) as usize) { + self.depth += 1; + let sub = sub.clone(); + self.run(&sub); + self.depth -= 1; + } + } + 11 => return, + 14 => { + self.done = true; + self.stack.clear(); + } + 1 | 3 => self.stack.clear(), + 12 => { + let b1 = *cs.get(i).unwrap_or(&0); + i += 1; + self.escape(b1); + } + _ => self.stack.clear(), + } + } + } + + fn escape(&mut self, b1: u8) { + match b1 { + 6 => { + // seac: asb adx ady bchar achar + let v = |k: usize| self.stack.get(k).copied().unwrap_or(0.0); + self.seac = Some((v(0), v(1), v(2), v(3) as u8, v(4) as u8)); + self.done = true; + self.stack.clear(); + } + 7 => { + // sbw: sbx sby wx wy + let v = |k: usize| self.stack.get(k).copied().unwrap_or(0.0); + self.sbx = v(0); + self.x = v(0); + self.y = v(1); + self.width = v(2); + self.stack.clear(); + } + 12 => { + let b = self.stack.pop().unwrap_or(1.0); + let a = self.stack.pop().unwrap_or(0.0); + self.stack.push(if b != 0.0 { a / b } else { 0.0 }); + } + 16 => { + // callothersubr: args… n othersubr# + let other = self.stack.pop().unwrap_or(0.0) as i32; + let n = self.stack.pop().unwrap_or(0.0).max(0.0) as usize; + let k = self.stack.len().saturating_sub(n); + let args: Vec = self.stack.drain(k..).collect(); + match other { + 1 => self.flex = Some(vec![]), + 2 => {} + 0 => { + // End of flex: seven points (a reference point, then two curves). + let pts = self.flex.take().unwrap_or_default(); + if pts.len() >= 7 { + if !self.open { + self.path.move_to((self.x, self.y)); + self.open = true; + } + self.path.curve_to(pts[1], pts[2], pts[3]); + self.path.curve_to(pts[4], pts[5], pts[6]); + self.x = pts[6].0; + self.y = pts[6].1; + } + // `pop pop setcurrentpoint` follows. + self.ps = vec![self.y, self.x]; + } + _ => { + // Hint replacement and others: hand the arguments back to `pop`. + self.ps = args.into_iter().rev().collect(); + } + } + } + 17 => { + let v = self.ps.pop().unwrap_or(0.0); + self.stack.push(v); + } + 33 => { + // setcurrentpoint + let v = |k: usize| self.stack.get(k).copied().unwrap_or(0.0); + self.x = v(0); + self.y = v(1); + self.stack.clear(); + } + _ => self.stack.clear(), + } + } +} + +/// A minimal Type 1 font for tests: `glyphs` are (name, plain charstring bytes). +#[cfg(test)] +pub(crate) fn write_test_type1(glyphs: &[(&str, Vec)], encoding: &[(u8, &str)]) -> Vec { + let mut clear = b"%!PS-AdobeFont-1.0: Test 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\n/Encoding 256 array\n".to_vec(); + for (c, n) in encoding { + clear.extend_from_slice(format!("dup {c} /{n} put\n").as_bytes()); + } + clear.extend_from_slice(b"readonly def\ncurrentfile eexec\n"); + let mut priv_ = + b"dup /Private 8 dict dup begin /RD{string currentfile exch readstring pop}executeonly def\n/lenIV 4 def\n/Subrs 0 array\nND\n2 index /CharStrings " + .to_vec(); + priv_.extend_from_slice(format!("{} dict dup begin\n", glyphs.len()).as_bytes()); + for (n, cs) in glyphs { + let enc = encrypt(cs, CHARSTRING_R, 4); + priv_.extend_from_slice(format!("/{n} {} RD ", enc.len()).as_bytes()); + priv_.extend_from_slice(&enc); + priv_.extend_from_slice(b" ND\n"); + } + priv_.extend_from_slice(b"end\nend\nmark currentfile closefile\n"); + let mut out = clear; + out.extend(encrypt(&priv_, EEXEC_R, 4)); + out.extend_from_slice(b"\n0000000000000000\ncleartomark\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape; + + #[test] + fn type1_decrypts_and_draws() { + // hsbw 50 600; rmoveto 0 0; 500 hlineto 500 vlineto -500 hlineto closepath endchar + let cs = vec![50 + 139, 248, 236, 13, 139, 139, 21, 248, 136, 6, 248, 136, 7, 252, 136, 6, 9, 14]; + let font = write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(65, "box")]); + let t = Type1::parse(&font).unwrap(); + assert_eq!(t.font_matrix, Some([0.001, 0.0, 0.0, 0.001, 0.0, 0.0])); + assert_eq!(t.encoding.get(&65).map(String::as_str), Some("box")); + let (p, w) = t.outline("box").unwrap(); + assert_eq!(w, 600.0); + assert!((p.area().abs() - 250_000.0).abs() < 1e-6); + assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); + } +} diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs index 9e54bdc37..edf1d7811 100644 --- a/crates/pdf/src/write.rs +++ b/crates/pdf/src/write.rs @@ -1,6 +1,7 @@ //! A minimal PDF writer for generating test files (EffectCraft does not export PDF). -/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed), a +/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed unless the +/// dictionary names its own `/Filter`, in which case the data is written as given), a /// cross-reference table and a trailer naming object `root` as the catalog. pub fn pdf(objects: &[(u32, String, Option>)], root: u32) -> Vec { let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); @@ -8,6 +9,12 @@ pub fn pdf(objects: &[(u32, String, Option>)], root: u32) -> Vec { for (n, dict, stream) in objects { offsets.push((*n, out.len())); match stream { + Some(data) if dict.contains("/Filter") => { + let d = format!("{} /Length {} >>", dict.trim_end().trim_end_matches(">>"), data.len()); + out.extend_from_slice(format!("{n} 0 obj\n{d}\nstream\n").as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream\nendobj\n"); + } 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()); diff --git a/crates/project/src/essential.rs b/crates/project/src/essential.rs index 885c5062e..71a38a0c8 100644 --- a/crates/project/src/essential.rs +++ b/crates/project/src/essential.rs @@ -3,7 +3,8 @@ //! Effects' Master Properties). //! //! - A comp's [`EssentialGraphics`] lists controls: properties of its layers (shown as text, -//! colour, slider, checkbox, point, angle or dropdown controls), Media Replacement slots for +//! colour, slider, checkbox, point, angle or dropdown controls; Source Text also as a font +//! control and Scale as one uniform slider), Media Replacement slots for //! footage layers, comments and groups. //! - Every layer that nests such a comp gets an **Essential Properties** group (match id //! `essential`, [`GroupKind::Essential`]) mirroring the controls, one child per control with @@ -36,6 +37,11 @@ pub struct EgControl { /// Display name (renamable; defaults to the property's name). pub name: String, pub kind: EgKind, + /// The control a property is shown as when not its natural one ([`control_type`]): a + /// Source Text property as a [`ControlType::Font`] control, a Scale as a uniform + /// [`ControlType::Scale`] slider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_type: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -66,11 +72,34 @@ pub enum ControlType { Angle, Dropdown, Media, + /// The font family, style and size of a Source Text property (the text itself stays). + Font, + /// One uniform percentage for a Scale property (every axis the same). + Scale, } impl ControlType { + /// By label or serde name, case-insensitively (`"font"`, `"Scale"`…). + pub fn parse(s: &str) -> Option { + [ + ControlType::Text, + ControlType::Color, + ControlType::Slider, + ControlType::Checkbox, + ControlType::Point, + ControlType::Angle, + ControlType::Dropdown, + ControlType::Media, + ControlType::Font, + ControlType::Scale, + ] + .into_iter() + .find(|t| t.label().eq_ignore_ascii_case(s.trim())) + } pub fn label(self) -> &'static str { match self { + ControlType::Font => "Font", + ControlType::Scale => "Scale", ControlType::Text => "Text", ControlType::Color => "Color", ControlType::Slider => "Slider", @@ -101,6 +130,35 @@ pub fn control_type(p: &Property) -> Option { }) } +/// Whether property `p` can be shown as a `ty` control: its natural control, a Source Text +/// property as Font, a two- or three-dimensional property as uniform Scale. +pub fn can_show_as(p: &Property, ty: ControlType) -> bool { + if matches!(p.ui, ParamUi::Hidden) { + return false; + } + match ty { + ControlType::Font => matches!(p.value, Value::Text(_)), + ControlType::Scale => matches!(p.value, Value::Vec2(_) | Value::Vec3(_)) && !matches!(p.ui, ParamUi::Angle), + ControlType::Media => false, + t => control_type(p) == Some(t), + } +} + +/// The control `c` shows for its property `p` (its `as_type`, else the natural one). +pub fn effective_type(c: &EgControl, p: &Property) -> Option { + c.as_type.filter(|t| can_show_as(p, *t)).or_else(|| control_type(p)) +} + +/// Apply a Font control's value: the font family, style and size of `from` onto `to` (the text, +/// colours and every other attribute stay). +pub fn merge_font(to: &mut Value, from: &Value) { + if let (Value::Text(t), Value::Text(f)) = (to, from) { + t.font = f.font.clone(); + t.style = f.style.clone(); + t.size = f.size; + } +} + impl EssentialGraphics { /// Every control, depth first (groups before their children). pub fn flat(&self) -> Vec<&EgControl> { @@ -159,6 +217,11 @@ impl EssentialGraphics { pub fn control_for(&self, layer: LayerId, prop: Uid) -> Option<&EgControl> { self.flat().into_iter().find(|c| matches!(c.kind, EgKind::Property { layer: l, prop: p } if l == layer && p == prop)) } + /// The control exposing property `prop` of `layer` as `as_type` (`None` = its natural + /// control): a property can have one control of each kind (Source Text as Text and Font). + pub fn control_for_as(&self, layer: LayerId, prop: Uid, as_type: Option) -> Option<&EgControl> { + self.flat().into_iter().find(|c| c.as_type == as_type && matches!(c.kind, EgKind::Property { layer: l, prop: p } if l == layer && p == prop)) + } /// Controls an instance can override (properties and media; not comments or groups). pub fn has_instance_controls(&self) -> bool { self.flat().iter().any(|c| matches!(c.kind, EgKind::Property { .. } | EgKind::Media { .. })) @@ -385,9 +448,22 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) - let mut p = project.clone(); let c = p.comp_mut(comp)?; let mut any = false; - for o in overrides { + // Whole values first, then Font controls (which change only the font of whatever text the + // instance shows). + let is_font = |o: &&Override| eg.find(o.control).is_some_and(|c| c.as_type == Some(ControlType::Font)); + let ordered = overrides.iter().filter(|o| !is_font(o)).chain(overrides.iter().filter(is_font)); + for o in ordered { let Some(ctl) = eg.find(o.control) else { continue }; match ctl.kind { + EgKind::Property { layer, prop } if ctl.as_type == Some(ControlType::Font) => { + if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { + merge_font(&mut pr.value, &o.value); + for k in &mut pr.keys { + merge_font(&mut k.value, &o.value); + } + any = true; + } + } EgKind::Property { layer, prop } => { if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { pr.keys.clear(); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 887bcc70e..c651165e1 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -770,6 +770,9 @@ pub struct Footage { /// project so expressions read it everywhere (and on the web). #[serde(default, skip_serializing_if = "Option::is_none")] pub data: Option, + /// PDF / Illustrator footage: the page shown (0-based; File ▸ Import ▸ Page). + #[serde(default, skip_serializing_if = "is_default")] + pub page: u32, } fn is_default(v: &T) -> bool { @@ -801,6 +804,7 @@ impl Default for Footage { linear_light: false, data: None, layer: None, + page: 0, } } } @@ -845,6 +849,10 @@ pub struct SourceLayer { /// is the footage instead of the layer's pixels. #[serde(default, skip_serializing_if = "Option::is_none")] pub embedded: Option, + /// A smart object with a perspective quad or a warp: the footage is its embedded file baked + /// as placed (warped and pinned to its corners) over the placed bounds in document space. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub placed: bool, } fn one() -> u32 { diff --git a/crates/psd/README.md b/crates/psd/README.md index b0b19ba3b..acbfe7a95 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -16,6 +16,12 @@ 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. 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 +(position, scale, rotation). A non-affine quad (`nonAffineTransform`: perspective) or a placed +layer warp (`warp`: the named styles with bend and horizontal / vertical distortion, and custom +envelope warps whose quilt mesh of bicubic Bezier patches is read from `meshPoints` (`ObAr`) +and `quiltSliceX` / `quiltSliceY`) is baked as placed over the placed bounds, at up to 4x the +document resolution ([`placed`](src/placed.rs)). Custom meshes are evaluated exactly; the +named styles' curves are not published, so they are clean-room geometric definitions matching +each style by behaviour. 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)). diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index e121f823d..d426f7143 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -30,6 +30,9 @@ pub enum DValue { Class(String, String), Raw(Vec), Reference(Vec), + /// An object array (`ObAr`): `count` objects of one class stored column-wise, each item a + /// list of `count` values (unit floats). + ObjectArray(u32, Descriptor), } /// An item of a reference (`obj `). @@ -161,6 +164,10 @@ fn read_value(r: &mut Reader, ty: &[u8; 4], depth: usize) -> Result { } DValue::List(v) } + b"ObAr" => { + let n = r.u32()?; + DValue::ObjectArray(n, read_descriptor(r)?) + } b"doub" => DValue::Double(r.f64()?), b"UntF" => { let unit = String::from_utf8_lossy(&r.tag()?).into_owned(); @@ -280,6 +287,11 @@ fn write_value(out: &mut Vec, v: &DValue) { write_value(out, x); } } + DValue::ObjectArray(n, d) => { + out.extend_from_slice(b"ObAr"); + put_u32(out, *n); + write_descriptor(out, d); + } DValue::Double(x) => { out.extend_from_slice(b"doub"); out.extend_from_slice(&x.to_be_bytes()); diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 61cadc7d9..de3ec1826 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -9,6 +9,7 @@ pub mod descriptor; pub mod engine_data; +pub mod placed; mod read; pub mod write; @@ -214,6 +215,8 @@ pub struct SmartObject { pub quad: [[f64; 2]; 4], /// The content's size in pixels (`Sz `), when recorded. pub size: Option<[f64; 2]>, + /// The placed layer's warp (`warp`), when it has one ([`placed`]). + pub warp: Option, } impl SmartObject { @@ -665,8 +668,11 @@ impl Psd { } }; let t = nums("Trnf").filter(|t| t.len() == 8).unwrap_or_else(|| vec![0.0; 8]); + // The non-affine transform when present (perspective), else the transform. + let t = nums("nonAffineTransform").filter(|t| t.len() == 8).unwrap_or(t); 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 }); + let warp = d.obj("warp").and_then(placed::Warp::read); + l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size, warp }); } b"PlLd" if l.smart_object.is_none() => { let _id = r.tag()?; @@ -681,7 +687,7 @@ impl Psd { 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 }); + 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, warp: None }); } b"vmsk" | b"vsms" => { let _ver = r.u32()?; diff --git a/crates/psd/src/placed.rs b/crates/psd/src/placed.rs new file mode 100644 index 000000000..156a8e271 --- /dev/null +++ b/crates/psd/src/placed.rs @@ -0,0 +1,410 @@ +//! Placed-layer (smart object) geometry: the non-affine corner quad (a projective map of the +//! content rectangle) and the placed-layer warp (`warp` in `SoLd`: the named warp styles with +//! bend and horizontal / vertical distortion, and custom envelope warps whose quilt mesh is a +//! grid of bicubic Bézier patches), and a renderer that bakes both into document-space pixels. +//! +//! The custom mesh is evaluated exactly (bicubic Bézier patches over `meshPoints`, split by +//! `quiltSliceX` / `quiltSliceY`). Photoshop does not publish the curves of its named styles; +//! the styles here are clean-room geometric definitions of what each name describes (Arc, +//! Arch, Bulge, Shell, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist), matched by +//! behaviour, not numerically. + +use crate::descriptor::{DValue, Descriptor}; +use crate::{Pixels, SmartObject}; + +/// A placed layer's warp. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Warp { + /// `warpNone`, `warpArc`, `warpArcLower`, `warpArcUpper`, `warpArch`, `warpBulge`, + /// `warpShellLower`, `warpShellUpper`, `warpFlag`, `warpWave`, `warpFish`, `warpRise`, + /// `warpFisheye`, `warpInflate`, `warpSqueeze`, `warpTwist`, `warpCustom`. + pub style: String, + /// Bend, −100..100 (%). + pub bend: f64, + /// Horizontal and vertical distortion, −100..100 (%). + pub horizontal: f64, + pub vertical: f64, + /// The style is applied rotated a quarter turn (`warpRotate` = vertical). + pub rotate_vertical: bool, + /// The warped area in content pixels: left, top, right, bottom. + pub bounds: [f64; 4], + /// Custom envelope: patch columns / rows and the (3·cols+1)×(3·rows+1) control points + /// (content pixels, row-major). + pub cols: usize, + pub rows: usize, + pub mesh: Vec<[f64; 2]>, +} + +fn floats(v: Option<&DValue>) -> Vec { + match v { + Some(DValue::UnitFloats(_, v)) => v.clone(), + Some(DValue::List(l)) => l + .iter() + .filter_map(|x| match x { + DValue::Double(v) | DValue::UnitFloat(_, v) => Some(*v), + DValue::Integer(i) => Some(*i as f64), + _ => None, + }) + .collect(), + _ => vec![], + } +} + +impl Warp { + /// Read a `warp` descriptor; `None` for no warp. + pub fn read(d: &Descriptor) -> Option { + let style = d.enum_value("warpStyle").unwrap_or("warpNone").to_string(); + let b = d.obj("bounds"); + let bounds = b + .map(|b| [b.num("Left").unwrap_or(0.0), b.num("Top ").unwrap_or(0.0), b.num("Rght").unwrap_or(0.0), b.num("Btom").unwrap_or(0.0)]) + .unwrap_or([0.0; 4]); + let mut w = Warp { + bend: d.num("warpValue").unwrap_or(0.0), + horizontal: d.num("warpPerspective").unwrap_or(0.0), + vertical: d.num("warpPerspectiveOther").unwrap_or(0.0), + rotate_vertical: d.enum_value("warpRotate") == Some("Vrtc"), + bounds, + cols: 1, + rows: 1, + mesh: vec![], + style, + }; + if let Some(env) = d.obj("customEnvelopeWarp") { + let cols = floats(env.get("quiltSliceX")).len().saturating_sub(1).max(1); + let rows = floats(env.get("quiltSliceY")).len().saturating_sub(1).max(1); + if let Some(DValue::ObjectArray(_, pts)) = env.get("meshPoints") { + let xs = floats(pts.get("Hrzn")); + let ys = floats(pts.get("Vrtc")); + let n = (3 * cols + 1) * (3 * rows + 1); + if xs.len() == n && ys.len() == n { + w.cols = cols; + w.rows = rows; + w.mesh = xs.into_iter().zip(ys).map(|(x, y)| [x, y]).collect(); + } + } + } + let identity = w.style == "warpNone" || (w.style != "warpCustom" && w.bend == 0.0 && w.horizontal == 0.0 && w.vertical == 0.0); + let custom_ok = w.style != "warpCustom" || !w.mesh.is_empty(); + (!identity && custom_ok).then_some(w) + } + + /// The warp of a content point (content pixels → content pixels). + pub fn apply(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + let [l, t, r, b] = if self.bounds[2] > self.bounds[0] && self.bounds[3] > self.bounds[1] { self.bounds } else { [0.0, 0.0, w, h] }; + let (bw, bh) = (r - l, b - t); + let (u, v) = ((p[0] - l) / bw, (p[1] - t) / bh); + if self.style == "warpCustom" { + return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + } + // Styles work in a centred frame (pixels), optionally rotated a quarter turn. + let (x, y, fw, fh) = if self.rotate_vertical { (bh * (v - 0.5), -bw * (u - 0.5), bh, bw) } else { (bw * (u - 0.5), bh * (v - 0.5), bw, bh) }; + let [mut x2, mut y2] = style_map(&self.style, self.bend / 100.0, x, y, fw, fh); + // Distortions: a perspective-like taper across the width (horizontal) or height. + let (hd, vd) = if self.rotate_vertical { (self.vertical / 100.0, self.horizontal / 100.0) } else { (self.horizontal / 100.0, self.vertical / 100.0) }; + let s = (x2 / (fw / 2.0)).clamp(-1.5, 1.5); + let tt = (y2 / (fh / 2.0)).clamp(-1.5, 1.5); + y2 *= 1.0 + hd * s; + x2 *= 1.0 + vd * tt; + let (ox, oy) = if self.rotate_vertical { (-y2, x2) } else { (x2, y2) }; + [l + bw / 2.0 + ox, t + bh / 2.0 + oy] + } + + /// A point of the custom quilt mesh at (u, v) ∈ [0, 1]². + fn mesh_point(&self, u: f64, v: f64) -> [f64; 2] { + let (cols, rows) = (self.cols, self.rows); + let pu = (u * cols as f64).min(cols as f64 - 1e-9); + let pv = (v * rows as f64).min(rows as f64 - 1e-9); + let (ci, cj) = (pu.floor() as usize, pv.floor() as usize); + let (fu, fv) = (pu - ci as f64, pv - cj as f64); + let stride = 3 * cols + 1; + let bern = |t: f64| { + let s = 1.0 - t; + [s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t] + }; + let (bu, bv) = (bern(fu), bern(fv)); + let mut out = [0.0; 2]; + for j in 0..4 { + for i in 0..4 { + let q = self.mesh[(3 * cj + j) * stride + 3 * ci + i]; + let k = bu[i] * bv[j]; + out[0] += q[0] * k; + out[1] += q[1] * k; + } + } + out + } +} + +/// A named style in a centred frame `fw`×`fh` (y down), bend `b` ∈ [−1, 1]. +fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] { + use std::f64::consts::PI; + let (hw, hh) = (fw / 2.0, fh / 2.0); + let s = x / hw; // −1..1 across + let t = y / hh; // −1..1 down + // Concentric arcs: the midline bends through an angle of b·π, rows keep their distance. + let arc = |x: f64, y: f64| -> [f64; 2] { + let theta = b * PI; + if theta.abs() < 1e-9 { + return [x, y]; + } + let r = fw / theta; + let phi = x / hw * theta / 2.0; + let rr = r - y; + // Arc centre below the shape for upward bends; keep the midline's centre fixed. + [rr * phi.sin(), r - rr * phi.cos()] + }; + let bump = 1.0 - s * s; + match style { + "warpArc" => arc(x, y), + "warpArcLower" => { + let a = arc(x, y); + let k = (t + 1.0) / 2.0; + [x + (a[0] - x) * k, y + (a[1] - y) * k] + } + "warpArcUpper" => { + let a = arc(x, y); + let k = (1.0 - t) / 2.0; + [x + (a[0] - x) * k, y + (a[1] - y) * k] + } + "warpArch" => [x, y - b * hh * bump], + "warpBulge" => [x, y + t * b * hh * bump], + "warpShellLower" => [x * (1.0 - b * 0.25 * (1.0 - t) / 2.0), y + (t + 1.0) / 2.0 * b * hh * bump], + "warpShellUpper" => [x * (1.0 - b * 0.25 * (t + 1.0) / 2.0), y - (1.0 - t) / 2.0 * b * hh * bump], + "warpFlag" => [x, y - b * hh * 0.5 * (PI * s).sin()], + "warpWave" => [x + b * hw * 0.1 * (PI * t).sin(), y - b * hh * 0.5 * (PI * s).sin() * (1.0 - 0.5 * t)], + "warpFish" => [x, y + t * b * hh * (PI * (s + 1.0) / 2.0).sin() * (1.0 - 0.5 * (s + 1.0) / 2.0)], + "warpRise" => [x, y - b * hh * (PI * s / 2.0).sin()], + "warpFisheye" | "warpInflate" => { + let k = if style == "warpFisheye" { (1.0 - (s * s + t * t) / 2.0).max(0.0) } else { 1.0 }; + [x * (1.0 + b * 0.5 * k * (1.0 - t * t)), y * (1.0 + b * 0.5 * k * bump)] + } + "warpSqueeze" => [x * (1.0 - b * 0.5 * (1.0 - t * t)), y * (1.0 + b * 0.5 * bump)], + "warpTwist" => { + let r = ((s * s + t * t) / 2.0).sqrt().min(1.0); + let a = b * PI / 2.0 * (1.0 - r); + let (sn, cs) = a.sin_cos(); + [x * cs - y * sn, x * sn + y * cs] + } + _ => [x, y], + } +} + +/// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q` +/// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1). +fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { + let [p0, p1, p2, p3] = q; + let sx = p0[0] - p1[0] + p2[0] - p3[0]; + let sy = p0[1] - p1[1] + p2[1] - p3[1]; + if sx.abs() < 1e-12 && sy.abs() < 1e-12 { + return [[p1[0] - p0[0], p2[0] - p1[0], p0[0]], [p1[1] - p0[1], p2[1] - p1[1], p0[1]], [0.0, 0.0, 1.0]]; + } + let (dx1, dx2) = (p1[0] - p2[0], p3[0] - p2[0]); + let (dy1, dy2) = (p1[1] - p2[1], p3[1] - p2[1]); + let den = dx1 * dy2 - dx2 * dy1; + let g = (sx * dy2 - dx2 * sy) / den; + let h = (dx1 * sy - sx * dy1) / den; + [[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]] +} + +impl SmartObject { + /// Whether the corner quad is a parallelogram (an affine placement). + pub fn is_affine(&self) -> bool { + let q = self.quad; + let ex = q[0][0] + q[2][0] - q[1][0] - q[3][0]; + let ey = q[0][1] + q[2][1] - q[1][1] - q[3][1]; + ex.abs() < 1e-3 && ey.abs() < 1e-3 + } + + /// Whether placing needs baking (a perspective quad or a warp). + pub fn needs_bake(&self) -> bool { + !self.is_affine() || self.warp.is_some() + } + + /// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad. + pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + let p = match &self.warp { + Some(wp) => wp.apply(p, w, h), + None => p, + }; + let m = square_to_quad(self.quad); + let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9)); + let z = m[2][0] * u + m[2][1] * v + m[2][2]; + let z = if z.abs() < 1e-12 { 1e-12 } else { z }; + [(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z] + } + + /// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content. + pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] { + let n = 32; + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for j in 0..=n { + for i in 0..=n { + let p = self.map([i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); + b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; + } + } + [b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()] + } + + /// The baked size for a `w`×`h` content at `k` pixels per document pixel. + pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) { + let bb = self.placed_bounds(w, h); + (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) + } + + /// Bake the content (straight RGBA) as placed: warped and mapped by the quad, rendered at + /// `k` pixels per document pixel (into `size` pixels when given; see [`Self::placed_size`]). + /// Returns the pixels and their document-space origin. + pub fn render_placed(&self, content: &Pixels, k: f64, size: Option<(u32, u32)>) -> Option<(Pixels, [f64; 2])> { + let (cw, ch) = (content.width as f64, content.height as f64); + if content.width == 0 || content.height == 0 { + return None; + } + let bb = self.placed_bounds(cw, ch); + let (ow, oh) = match size { + Some((w, h)) => (w.max(1), h.max(1)), + None => self.placed_size(cw, ch, k), + }; + let (ow, oh) = (ow as usize, oh as usize); + // A forward grid, filled triangle by triangle with inverse barycentric sampling. + let n = 48usize; + let mut dst = Vec::with_capacity((n + 1) * (n + 1)); + let mut src = Vec::with_capacity((n + 1) * (n + 1)); + for j in 0..=n { + for i in 0..=n { + let s = [i as f64 / n as f64 * cw, j as f64 / n as f64 * ch]; + let d = self.map(s, cw, ch); + dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]); + src.push(s); + } + } + let mut out = vec![[0.0f32; 4]; ow * oh]; + let sample = |x: f64, y: f64| -> [f32; 4] { + let (fx, fy) = ((x - 0.5).clamp(0.0, cw - 1.0), (y - 0.5).clamp(0.0, ch - 1.0)); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(content.width as usize - 1), (y0 + 1).min(content.height as usize - 1)); + let (a, b) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + let at = |x: usize, y: usize| { + let p = content.data[y * content.width as usize + x]; + [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]] + }; + let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)); + let mut o = [0.0; 4]; + for c in 0..4 { + o[c] = (p00[c] * (1.0 - a) + p10[c] * a) * (1.0 - b) + (p01[c] * (1.0 - a) + p11[c] * a) * b; + } + o + }; + let idx = |i: usize, j: usize| j * (n + 1) + i; + for j in 0..n { + for i in 0..n { + for tri in [[idx(i, j), idx(i + 1, j), idx(i + 1, j + 1)], [idx(i, j), idx(i + 1, j + 1), idx(i, j + 1)]] { + let [a, b, c] = tri.map(|t| dst[t]); + let den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); + if den.abs() < 1e-12 { + continue; + } + let x0 = a[0].min(b[0]).min(c[0]).floor().max(0.0) as usize; + let x1 = (a[0].max(b[0]).max(c[0]).ceil() as usize).min(ow); + let y0 = a[1].min(b[1]).min(c[1]).floor().max(0.0) as usize; + let y1 = (a[1].max(b[1]).max(c[1]).ceil() as usize).min(oh); + for y in y0..y1 { + for x in x0..x1 { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let l1 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den; + let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den; + let l3 = 1.0 - l1 - l2; + if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 { + continue; + } + let [sa, sb, sc] = tri.map(|t| src[t]); + let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3; + let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3; + out[y * ow + x] = sample(sx, sy); + } + } + } + } + } + // Back to straight alpha. + for p in &mut out { + if p[3] > 0.0 { + for c in 0..3 { + p[c] /= p[3]; + } + } + } + Some((Pixels { width: ow as u32, height: oh as u32, data: out }, [bb[0], bb[1]])) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn so(quad: [[f64; 2]; 4], warp: Option) -> SmartObject { + SmartObject { uuid: "x".into(), quad, size: Some([20.0, 10.0]), warp } + } + + #[test] + fn perspective_quad_maps_corners_and_is_projective() { + let q = [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]]; + let s = so(q, None); + assert!(!s.is_affine() && s.needs_bake()); + for (c, want) in [([0.0, 0.0], q[0]), ([20.0, 0.0], q[1]), ([20.0, 10.0], q[2]), ([0.0, 10.0], q[3])] { + let p = s.map(c, 20.0, 10.0); + assert!((p[0] - want[0]).abs() < 1e-9 && (p[1] - want[1]).abs() < 1e-9, "{c:?} → {p:?}"); + } + // Projective, not bilinear: the content centre is where the diagonals cross. + let c = s.map([10.0, 5.0], 20.0, 10.0); + assert!((c[0] - 30.0).abs() > 0.5, "{c:?}"); + let affine = so([[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], None); + assert!(affine.is_affine() && !affine.needs_bake()); + } + + #[test] + fn custom_mesh_and_styles_warp() { + // An identity 4×4 mesh shifted 3 px right in its interior row: interior points move. + let mut mesh = vec![]; + for j in 0..4 { + for i in 0..4 { + mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 3.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]); + } + } + let w = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() }; + let p = w.apply([10.0, 5.0], 20.0, 10.0); + assert!((p[0] - 12.25).abs() < 1e-9 && (p[1] - 5.0).abs() < 1e-9, "{p:?}"); + assert_eq!(w.apply([0.0, 0.0], 20.0, 10.0), [0.0, 0.0]); + // Arc: the top centre rises, the bottom centre too (concentric arcs); Bulge: top up, + // bottom down; Flag keeps the centre. + let style = |s: &str| Warp { style: s.into(), bend: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() }; + let top = style("warpArc").apply([100.0, 0.0], 200.0, 100.0); + let corner = style("warpArc").apply([0.0, 0.0], 200.0, 100.0); + assert!(top[1] < corner[1], "arc {top:?} {corner:?}"); + let b = style("warpBulge"); + assert!(b.apply([100.0, 0.0], 200.0, 100.0)[1] < 0.0 && b.apply([100.0, 100.0], 200.0, 100.0)[1] > 100.0); + assert!((style("warpFlag").apply([100.0, 50.0], 200.0, 100.0)[1] - 50.0).abs() < 1e-9); + // Vertical rotation swaps the roles of the axes. + let mut v = style("warpBulge"); + v.rotate_vertical = true; + let l = v.apply([0.0, 50.0], 200.0, 100.0); + assert!(l[0] < 0.0 && (l[1] - 50.0).abs() < 1e-9, "{l:?}"); + // Horizontal distortion: the right side grows taller. + let d = Warp { style: "warpArc".into(), horizontal: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() }; + assert!(d.apply([200.0, 0.0], 200.0, 100.0)[1] < d.apply([0.0, 0.0], 200.0, 100.0)[1]); + } + + #[test] + fn render_placed_bakes_a_perspective_quad() { + // 2×1 content: red | green, pinned to a trapezoid. + let content = Pixels { width: 2, height: 1, data: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]] }; + let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [40.0, 5.0], [40.0, 15.0], [0.0, 20.0]], size: None, warp: None }; + let (px, origin) = s.render_placed(&content, 1.0, None).unwrap(); + assert_eq!(origin, [0.0, 0.0]); + assert_eq!((px.width, px.height), (40, 20)); + let at = |x: usize, y: usize| px.data[y * 40 + x]; + assert!(at(5, 10)[0] > 0.9 && at(5, 10)[3] > 0.99, "{:?}", at(5, 10)); + assert!(at(35, 10)[1] > 0.9, "{:?}", at(35, 10)); + assert_eq!(at(38, 1)[3], 0.0, "outside the trapezoid"); + } +} diff --git a/crates/psd/src/write.rs b/crates/psd/src/write.rs index 160e81d30..cb3b2bf06 100644 --- a/crates/psd/src/write.rs +++ b/crates/psd/src/write.rs @@ -414,14 +414,53 @@ fn linked_block(files: &[(String, String, [u8; 4], Vec)]) -> Vec { /// `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) { + smart_object_block_warped(uuid, quad, size, None) +} + +/// A `warp` descriptor: a named style (bend and distortions in %) over `bounds` (left, top, +/// right, bottom), or with `mesh` (16 points, content pixels) a custom envelope. +pub fn warp_descriptor(style: &str, bend: f64, h: f64, v: f64, bounds: [f64; 4], mesh: Option<&[[f64; 2]]>) -> Descriptor { + let mut d = Descriptor::new("warp") + .with("warpStyle", DValue::Enum("warpStyle".into(), style.into())) + .with("warpValue", DValue::Double(bend)) + .with("warpPerspective", DValue::Double(h)) + .with("warpPerspectiveOther", DValue::Double(v)) + .with("warpRotate", DValue::Enum("Ornt".into(), "Hrzn".into())) + .with( + "bounds", + DValue::Descriptor( + Descriptor::new("Rctn") + .with("Top ", DValue::UnitFloat("#Pxl".into(), bounds[1])) + .with("Left", DValue::UnitFloat("#Pxl".into(), bounds[0])) + .with("Btom", DValue::UnitFloat("#Pxl".into(), bounds[3])) + .with("Rght", DValue::UnitFloat("#Pxl".into(), bounds[2])), + ), + ) + .with("uOrder", DValue::Integer(4)) + .with("vOrder", DValue::Integer(4)); + if let Some(m) = mesh { + let pts = Descriptor::new("rationalPoint") + .with("Hrzn", DValue::UnitFloats("#Pxl".into(), m.iter().map(|p| p[0]).collect())) + .with("Vrtc", DValue::UnitFloats("#Pxl".into(), m.iter().map(|p| p[1]).collect())); + d = d + .with("customEnvelopeWarp", DValue::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", DValue::ObjectArray(m.len() as u32, pts)))); + } + d +} + +/// `SoLd` with an optional warp; `quad` may be non-affine (perspective). +pub fn smart_object_block_warped(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2], warp: Option) -> ([u8; 4], Vec) { let list = |v: &[f64]| DValue::List(v.iter().map(|x| DValue::Double(*x)).collect()); let t: Vec = quad.iter().flat_map(|p| [p[0], p[1]]).collect(); - let d = Descriptor::new("null") + let mut 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])))); + if let Some(w) = warp { + d = d.with("warp", DValue::Descriptor(w)); + } let mut out = b"soLD".to_vec(); u32b(&mut out, 4); u32b(&mut out, 16); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6204b763c..003071e1d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -395,6 +395,8 @@ pub struct Renderer<'a> { /// Switches inherited from the precomp layers this comp is rendered through (see /// [`RenderOpts::nested_switches`]): the worst quality and whether motion blur is allowed. pub(crate) inherited: Option<(Quality, bool)>, + /// Classic 3D depth of field is left to an accelerator ([`three_d::Plane3d::dof`]). + pub(crate) defer_dof: bool, /// This renderer draws into the top-level frame's canvas (the top comp, or a collapsed /// precomp drawn straight into it): the region of interest's offset applies. top: bool, @@ -442,6 +444,7 @@ impl<'a> Renderer<'a> { opacity_mul: 1.0, collapse3d: None, inherited: None, + defer_dof: false, top: true, } } @@ -477,7 +480,7 @@ impl<'a> Renderer<'a> { /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { - Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, top: false, ..*self } + Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, defer_dof: false, top: false, ..*self } } /// The nested comp of a precomp layer whose transformations collapse into this comp: the diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f03c3c120..6bd8686bf 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -586,7 +586,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf mod tests; /// The filled outlines of a shape layer in layer space with their straight colours (gradient -/// fills: the colour half way along), for Advanced 3D extrusion. Strokes are not extruded. +/// fills: the colour half way along), without strokes (see [`extrusion_outlines`]). pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec, [f32; 4])> { let mut arena = Vec::new(); let (draws, _) = collect(ctx, layer, contents, &mut arena); @@ -606,6 +606,39 @@ pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec< .collect() } +/// The painted regions of a shape layer for Advanced 3D extrusion, bottom paint first: fills +/// (gradient fills: the colour half way along) and strokes, each stroke turned into the region +/// it covers (outline of the stroke with its width, joins, caps and dashes, overlaps resolved). +pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec, [f32; 4])> { + let mut arena = Vec::new(); + let (draws, _) = collect(ctx, layer, contents, &mut arena); + draws + .iter() + .rev() + .filter(|d| d.opacity > 0.0) + .filter_map(|d| { + let paths: Vec = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect(); + if paths.is_empty() { + return None; + } + let (c, region) = match &d.paint { + Paint::Fill { color, .. } => (*color, paths), + Paint::Gradient { g, stroke: None, .. } => (g.sample(0.5), paths), + Paint::Stroke { color, style } => (*color, vec![stroke_region(&paths, style)?]), + Paint::Gradient { g, stroke: Some(style), .. } => (g.sample(0.5), vec![stroke_region(&paths, style)?]), + }; + Some((region, [c[0], c[1], c[2], c[3] * d.opacity])) + }) + .collect() +} + +/// The filled region a stroke covers: its outline with self-overlaps resolved (non-zero). +pub fn stroke_region(paths: &[BezPath], style: &StrokeStyle) -> Option { + let o = effectcraft_path::stroke_outline(paths, style, 1.0)?; + let r = effectcraft_path::boolean::normalize(&o, FillRule::NonZero); + (!r.elements().is_empty()).then_some(r) +} + /// Layer-space bounds of a shape layer's painted contents (strokes included), without /// rasterising. pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option { diff --git a/crates/render/src/three_d/adv/dof.rs b/crates/render/src/three_d/adv/dof.rs new file mode 100644 index 000000000..a35b2dae5 --- /dev/null +++ b/crates/render/src/three_d/adv/dof.rs @@ -0,0 +1,123 @@ +//! Advanced 3D depth of field with the camera's iris and highlight options (the same Camera +//! Options as Classic 3D: Iris Shape from Fast Rectangle to Decagon, Iris Rotation, Roundness, +//! Aspect Ratio, Diffraction Fringe, Highlight Gain / Threshold / Saturation). +//! +//! Every pixel's blur radius comes from its camera depth (the circle of confusion of the +//! resolved scene, in output pixels). The image is blurred with the Classic 3D bokeh kernel +//! ([`crate::three_d::bokeh`]) at a few radii spanning the range and each pixel blends the two +//! levels around its own radius, so an out-of-focus point spreads into exactly the iris shape +//! a Classic 3D layer shows, and in-focus pixels stay sharp. Highlights are boosted before the +//! blur (only where the image is blurred) so bright points bloom into bokeh. + +use rayon::prelude::*; + +use crate::Image; +use crate::three_d::bokeh::{boost_highlights, progressive_blur}; +use crate::three_d::camera::Dof; + +/// Largest blur radius (output pixels). +const MAX_RADIUS: f32 = 48.0; + +/// Depth of field of a resolved Advanced 3D image (premultiplied) with camera depth per pixel +/// (∞ where nothing was drawn: blurred as far away). +pub fn depth_of_field(img: &Image, depth: &[f32], dof: &Dof, scale: f64) -> Image { + let radius: Vec = depth + .iter() + .map(|&z| { + let z = if z.is_finite() { z as f64 } else { 1.0e9 }; + ((dof.coc(z) * scale * 0.5) as f32).min(MAX_RADIUS) + }) + .collect(); + let rmax = radius.iter().fold(0.0f32, |a, &b| a.max(b)); + if rmax < 0.5 { + return img.clone(); + } + let mut src = img.clone(); + if dof.highlight.gain > 0.0 { + let mut boosted = img.clone(); + boost_highlights(&mut boosted, &dof.highlight); + src.data.par_iter_mut().zip(boosted.data.par_iter()).zip(radius.par_iter()).for_each(|((d, b), &r)| { + let k = (r - 0.5).clamp(0.0, 1.0); + for c in 0..4 { + d[c] += (b[c] - d[c]) * k; + } + }); + } + progressive_blur(&src, &dof.iris, &radius) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::three_d::bokeh::{Highlight, Iris, bokeh_blur}; + + fn dot(size: u32) -> Image { + let mut img = Image::new(size, size); + let c = (size / 2) as usize; + img.data[c * size as usize + c] = [1.0, 1.0, 1.0, 1.0]; + img + } + + /// Out of focus everywhere with a blur radius of `r` output pixels. + fn dof(r: f64, iris: Iris) -> (Dof, Vec) { + // coc = aperture · (z − focus) / z; radius = coc / 2. + let (focus, z) = (500.0, 2000.0); + let aperture = 2.0 * r * z / (z - focus); + (Dof { focus, aperture, blur_level: 1.0, iris, highlight: Highlight::default() }, vec![z as f32; 81 * 81]) + } + + fn lit(img: &Image) -> Vec { + let m = img.data.iter().fold(0.0f32, |a, p| a.max(p[3])); + img.data.iter().map(|p| p[3] > m * 0.2).collect() + } + + #[test] + fn iris_shapes_match_the_classic_kernel() { + for (sides, rotation, roundness, aspect) in + [(3u32, 0.0, 0.0, 1.0), (5, 20.0, 0.0, 1.0), (6, 0.0, 0.0, 1.0), (10, 0.0, 0.5, 1.0), (4, 45.0, 0.0, 2.0), (0, 0.0, 0.0, 1.0)] + { + let iris = Iris { sides, rotation, roundness, aspect, fringe: 0.0 }; + let (d, depth) = dof(16.0, iris); + let ours = lit(&depth_of_field(&dot(81), &depth, &d, 1.0)); + let classic = lit(&bokeh_blur(&dot(81), &iris, 16.0)); + let (mut same, mut any) = (0, 0); + for (a, b) in ours.iter().zip(&classic) { + if *a || *b { + any += 1; + if a == b { + same += 1; + } + } + } + let iou = same as f64 / any as f64; + assert!(iou > 0.8, "{sides} sides, rotation {rotation}: overlap {iou:.2}"); + } + } + + #[test] + fn triangle_is_not_a_disc() { + let iris = Iris { sides: 3, ..Default::default() }; + let (d, depth) = dof(16.0, iris); + let out = lit(&depth_of_field(&dot(81), &depth, &d, 1.0)); + // One side flat (half the radius from the centre), the opposite a vertex. + let at = |x: usize| out[40 * 81 + x]; + assert_ne!(at(40 - 12), at(40 + 12)); + } + + #[test] + fn fringe_brightens_the_rim_and_highlights_bloom() { + let mut iris = Iris { sides: 8, ..Default::default() }; + let (mut d, depth) = dof(16.0, iris); + let plain = depth_of_field(&dot(81), &depth, &d, 1.0); + iris.fringe = 1.0; + d.iris = iris; + let rimmed = depth_of_field(&dot(81), &depth, &d, 1.0); + let ratio = |im: &Image| im.data[40 * 81 + 40 + 14][3] / im.data[40 * 81 + 40 + 2][3]; + assert!(ratio(&rimmed) > ratio(&plain) * 1.3, "{} vs {}", ratio(&rimmed), ratio(&plain)); + // Highlight gain above the threshold brightens the bloom. + d.iris.fringe = 0.0; + d.highlight = Highlight { gain: 0.5, threshold: 0.5, saturation: 0.0 }; + let bright = depth_of_field(&dot(81), &depth, &d, 1.0); + assert!(bright.data[40 * 81 + 40][0] > plain.data[40 * 81 + 40][0] * 1.5); + } +} diff --git a/crates/render/src/three_d/adv/mod.rs b/crates/render/src/three_d/adv/mod.rs index f8690b8f4..4bd3fd04d 100644 --- a/crates/render/src/three_d/adv/mod.rs +++ b/crates/render/src/three_d/adv/mod.rs @@ -6,9 +6,10 @@ //! is rasterised with a depth buffer on the GPU when an [`crate::Accelerator`] implements //! [`crate::Accelerator::raster_3d`], otherwise by the software rasteriser ([`raster`]), at 2×2 //! supersampling. The resolve (box filter), depth of field (a gather blur by each pixel's -//! circle of confusion) and the conversion back to the working encoding run on the CPU for +//! circle of confusion, shaped by the camera's iris: [`dof`]) and the conversion back to the working encoding run on the CPU for //! both paths, and the result is composited over the layers below the run. +pub mod dof; pub mod raster; pub mod scene; pub mod shade; @@ -16,13 +17,19 @@ pub mod shade; use effectcraft_project::Layer; use rayon::prelude::*; +pub use dof::depth_of_field; pub use raster::Target; pub use scene::{EnvInfo, Light, Material, Scene, ShadowMap, TexInfo, Vertex}; -use super::camera::Dof; use super::compose::camera_for; use crate::{EvalCtx, Image, Renderer}; +/// Whether a comp draws its 3D layers with the Advanced 3D renderer: its own setting, or for a +/// collapsed precomp's layers, the setting of the outermost comp they collapse into. +pub(crate) fn active(r: &Renderer, ctx: &EvalCtx) -> bool { + r.collapse3d.map_or(ctx.comp, |c| c.parent.comp).renderer == effectcraft_project::Renderer::Advanced3D +} + /// Box-filter a supersampled target down by `k`: (premultiplied colour, mean depth of the /// covered subsamples, ∞ where none). pub fn resolve(t: &Target, k: u32) -> (Image, Vec) { @@ -56,68 +63,6 @@ pub fn resolve(t: &Target, k: u32) -> (Image, Vec) { (img, depth) } -/// Depth of field: a gather blur where each pixel collects the neighbours whose circle of -/// confusion (from their depth, in output pixels) reaches it; nearer in-focus surfaces are not -/// smeared by the blur of what is behind them. -pub fn depth_of_field(img: &Image, depth: &[f32], dof: &Dof, scale: f64) -> Image { - let (w, h) = (img.width as i64, img.height as i64); - let max_r = 48.0f32; - let radius: Vec = depth - .iter() - .map(|&z| { - let z = if z.is_finite() { z as f64 } else { 1.0e9 }; - ((dof.coc(z) * scale * 0.5) as f32).min(max_r) - }) - .collect(); - let rmax = radius.iter().fold(0.0f32, |a, &b| a.max(b)); - if rmax < 0.5 { - return img.clone(); - } - // Disk samples (golden-angle spiral) over the largest radius. - const N: usize = 64; - let golden = std::f32::consts::PI * (3.0 - 5.0f32.sqrt()); - let taps: Vec<(f32, f32, f32)> = (1..=N) - .map(|i| { - let r = (i as f32 / N as f32).sqrt() * rmax; - let a = i as f32 * golden; - (r * a.cos(), r * a.sin(), r) - }) - .collect(); - let mut out = Image::new(img.width, img.height); - out.data.par_chunks_mut(w as usize).enumerate().for_each(|(y, row)| { - for x in 0..w as usize { - let i = y * w as usize + x; - let (rp, zp) = (radius[i], depth[i]); - let cw = 1.0 / (std::f32::consts::PI * rp * rp).max(1.0); - let c = img.data[i]; - let mut acc = [c[0] * cw, c[1] * cw, c[2] * cw, c[3] * cw]; - let mut wsum = cw; - for &(dx, dy, d) in &taps { - let (sx, sy) = (x as i64 + dx.round() as i64, y as i64 + dy.round() as i64); - if sx < 0 || sy < 0 || sx >= w || sy >= h { - continue; - } - let j = (sy * w + sx) as usize; - // A sample behind this pixel blurs no further than this pixel's own circle. - let rq = if depth[j] > zp { radius[j].min(rp) } else { radius[j] }; - let cov = (rq - d + 0.5).clamp(0.0, 1.0); - if cov <= 0.0 { - continue; - } - // Each tap stands for an annulus of area ≈ π·rmax²/N. - let wq = cov / (std::f32::consts::PI * rq * rq).max(1.0) * (std::f32::consts::PI * rmax * rmax / N as f32); - let q = img.data[j]; - for k in 0..4 { - acc[k] += q[k] * wq; - } - wsum += wq; - } - row[x] = acc.map(|v| v / wsum); - } - }); - out -} - /// Linear → sRGB-encoded premultiplied pixels (the working encoding of non-linear projects). pub fn encode(img: &mut Image) { img.data.par_iter_mut().for_each(|p| { diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index ca124a415..5ec3e8c2b 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -212,6 +212,9 @@ pub(crate) struct Builder { /// Per triangle: transparent and its sort depth. tri_depth: Vec<(bool, f32)>, cam: CameraState, + /// World of the collapsed precomp layers the current layers are drawn through (identity at + /// the top level). + outer: Mat4, } impl Builder { @@ -451,7 +454,7 @@ fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { } _ => return, }; - let world = ctx.world_matrix(layer) * model_to_layer(unit); + let world = b.outer * ctx.world_matrix(layer) * model_to_layer(unit); // Materials of the model (one default when it has none). let mut mats: Vec = vec![]; let defaults = [model::Material::plain([0.8, 0.8, 0.8, 1.0], 0.0, 0.5)]; @@ -532,34 +535,55 @@ pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option Vec<(Primitive, [f32; 4], Mat4)> { - let mut groups: Vec<(Vec, [f32; 4], Mat4)> = vec![]; + // (paths, colour, mesh → layer, stacking level) + let mut groups: Vec<(Vec, [f32; 4], Mat4, usize)> = vec![]; match layer.source { LayerSource::Text => { if let Some(tg) = crate::text::text_geom(ctx, layer) { - for g in tg.glyphs.iter().filter(|g| g.apply_fill && g.fill[3] > 0.0) { - groups.push((vec![g.local.clone()], g.fill, g.m)); + // Fill & Stroke order: all strokes over all fills (2), all fills over all strokes + // (1), else the character style's Stroke Over Fill. + let stroke_on_top = match tg.fill_stroke { + 1 => false, + 2 => true, + _ => tg.doc.stroke_over_fill, + }; + for g in &tg.glyphs { + let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32; + if g.apply_fill && g.fill[3] * op > 0.0 { + let c = [g.fill[0], g.fill[1], g.fill[2], g.fill[3] * op]; + groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top))); + } + if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 { + let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; + if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) { + let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op]; + groups.push((vec![region], c, g.m, usize::from(stroke_on_top))); + } + } } } } LayerSource::Shape => { if let Some(c) = layer.props.sub("contents") { - groups = crate::shapes::fill_outlines(ctx, layer, c).into_iter().map(|(ps, c)| (ps, c, Mat4::IDENTITY)).collect(); + groups = crate::shapes::extrusion_outlines(ctx, layer, c).into_iter().enumerate().map(|(k, (ps, c))| (ps, c, Mat4::IDENTITY, k)).collect(); } } _ => {} } groups .into_iter() - .filter_map(|(paths, color, m)| { + .filter_map(|(paths, color, m, level)| { let cs: Vec> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect(); let outlines = model::extrude::group_contours(cs); if outlines.is_empty() { return None; } - Some((model::extrude::extrude(&outlines, p), color, m)) + Some((model::extrude::extrude_stacked(&outlines, p, level), color, m)) }) .collect() } @@ -567,7 +591,7 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) { let base = card_material(ctx, layer); let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); - let world = ctx.world_matrix(layer); + let world = b.outer * ctx.world_matrix(layer); let lin = b.s.linear_io; for (mesh, c, local) in extruded_meshes(ctx, layer, p) { let l = |v: f32| if lin { v } else { srgb_to_linear(v) }; @@ -610,7 +634,7 @@ fn card(b: &mut Builder, buf: &Buf, world: Mat4, mut mat: Material) { fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let mut mat = card_material(ctx, layer); mat.opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); - let world = ctx.world_matrix(layer); + let world = b.outer * ctx.world_matrix(layer); if crate::text::per_char_3d(ctx, layer) { for (buf, m) in crate::text::per_char_planes(ctx, layer, r.opts.scale) { let buf = r.to_blend(Arc::new(buf), None); @@ -804,6 +828,50 @@ fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 { if r.opts.draft || (out.0 as u64 * out.1 as u64) > 8_000_000 { 1 } else { 2 } } +/// Add one layer of a run: a model or primitive mesh, an extruded text/shape layer, the nested +/// layers of a collapsed precomp, or a textured card. +fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Builder) { + if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() { + return; + } + if let Some(item) = r.collapsed(ctx, layer) { + collapsed_layer(r, ctx, layer, item, b); + } else if layer.source.is_model() { + model_layer(r, ctx, layer, b); + } else if matches!(layer.source, LayerSource::Text | LayerSource::Shape) + && let Some(p) = extrusion_params(ctx, layer) + { + extruded_layer(r, ctx, layer, &p, b); + } else { + card_layer(r, ctx, layer, b); + } +} + +/// A collapsed precomp layer: its nested layers join this scene as real Advanced 3D geometry +/// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they +/// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp +/// layer's plane; nested lights and cameras are ignored (the parent's light the scene). +fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) { + let op = ctx.opacity(layer) as f32 * r.opacity_mul(); + let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return }; + let saved = b.outer; + b.outer = saved * ctx.world_matrix(layer); + let t = nctx.time; + let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); + for l in nctx.comp.layers.iter().rev() { + if !l.is_active_at(t) + || l.is_light() + || matches!(l.source, LayerSource::Camera) + || (any_solo && !l.switches.solo) + || (!r.opts.guides && l.switches.guide) + { + continue; + } + emit_layer(&sub, &nctx, l, b); + } + b.outer = saved; +} + /// Build the scene of a run of 3D layers. pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene { build_at(r, ctx, None, run, out) @@ -828,24 +896,17 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: tex_keys: HashMap::new(), tri_depth: vec![], cam, + outer: r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world), }; for layer in run { let ctx = if sub.is_some() && motion_blurred(r, base, layer) { at } else { base }; - if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() { - continue; - } - if layer.source.is_model() { - model_layer(r, ctx, layer, &mut b); - } else if matches!(layer.source, LayerSource::Text | LayerSource::Shape) - && let Some(p) = extrusion_params(ctx, layer) - { - extruded_layer(r, ctx, layer, &p, &mut b); - } else { - card_layer(r, ctx, layer, &mut b); - } + emit_layer(r, ctx, layer, &mut b); } b.sort_triangles(); - // Lights, environment, shadows (Draft 3D: unlit, like After Effects). + // Lights, environment, shadows (Draft 3D: unlit, like After Effects). A collapsed + // precomp's layers are lit by the outermost comp's lights. + let parent = r.collapse3d.map(|c| c.parent); + let ctx = parent.as_ref().unwrap_or(ctx); if !ctx.comp.draft_3d { let (ls, amb, env) = lights(ctx, lin); b.s.lights = ls; @@ -866,7 +927,7 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: } // Camera matrices. let s = r.opts.scale; - let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64); + let (cw, ch) = crate::three_d::compose::view_size(r, ctx); let (ow, oh) = (out.0 as f64, out.1 as f64); let (a, bb) = (2.0 * s * cam.zoom / ow, s * cw / ow - 1.0); let (c, d) = (2.0 * s * cam.zoom / oh, s * ch / oh - 1.0); diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 4e8b9c3b9..67eb9ed68 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -425,3 +425,113 @@ fn track_mattes_in_advanced_3d() { let img = render(&p, cid); assert!(img.data[30 * 120 + 30][3] < 0.01 && img.data[70 * 120 + 100][3] > 0.99); } + +#[test] +fn collapsed_precomps_join_the_advanced_3d_scene() { + // A cube in a nested (Classic 3D) comp, collapsed into an Advanced 3D comp: the cube is + // real geometry lit by the parent's light, not a flat card of the nested comp (which, being + // Classic 3D, draws no meshes at all). + let (mut p, cid) = project(160, 120); + let mut nc = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + nc.renderer = R3::Classic3D; + let nid = p.add_item("N", Default::default(), None, ItemKind::Comp(nc.into())); + add(&mut p, nid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| { + set(l, "geometryOptions/width", Value::Scalar(60.0)); + set(l, "geometryOptions/height", Value::Scalar(60.0)); + set(l, "geometryOptions/depth", Value::Scalar(60.0)); + set(l, "transform/rotationY", Value::Scalar(35.0)); + set(l, "transform/rotationX", Value::Scalar(-25.0)); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Parallel }, |_| {}); + add(&mut p, cid, LayerSource::Comp { item: nid }, |l| { + l.switches.three_d = true; + l.switches.collapse = true; + }); + let shades = |img: &crate::Image| { + let mut s: Vec = img.data.iter().filter(|q| q[3] > 0.99).map(|q| (q[0] * 20.0) as i32).collect(); + s.sort(); + s.dedup(); + s + }; + let img = render(&p, cid); + assert!(img.data[60 * 160 + 80][3] > 0.99, "the nested cube covers the centre"); + assert!(shades(&img).len() >= 3, "faces lit by the parent's light: {:?}", shades(&img)); + // The scene holds the cube's triangles (a card would be two). + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let comp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, comp, Tick::ZERO); + let run: Vec<&effectcraft_project::Layer> = comp.layers.iter().filter(|l| l.is_3d() && !l.is_light()).collect(); + let s = scene_of(&r, &ctx, &run, (160, 120)); + assert!(s.indices.len() / 3 >= 12, "{} triangles", s.indices.len() / 3); + // A 2D collapsed precomp draws its 3D layers through the parent's Advanced 3D renderer too. + p.comp_mut(cid).unwrap().layers[0].switches.three_d = false; + let flat = render(&p, cid); + assert!(flat.data[60 * 160 + 80][3] > 0.99); + assert!(shades(&flat).len() >= 3, "{:?}", shades(&flat)); + // Collapse off: the nested comp renders on its own (Classic 3D: no meshes). + p.comp_mut(cid).unwrap().layers[0].switches.collapse = false; + p.comp_mut(cid).unwrap().layers[0].switches.three_d = true; + assert!(render(&p, cid).data.iter().all(|q| q[3] == 0.0)); +} + +#[test] +fn extruded_strokes_render_as_bevelled_meshes() { + let (mut p, cid) = project(200, 120); + add(&mut p, cid, LayerSource::Text, |l| { + l.switches.three_d = true; + let doc = effectcraft_keyframe::TextDoc { + text: "I".into(), + size: 80.0, + fill: [1.0, 1.0, 1.0, 1.0], + apply_fill: true, + stroke: [1.0, 0.0, 0.0, 1.0], + apply_stroke: true, + stroke_width: 8.0, + stroke_over_fill: false, + ..Default::default() + }; + set(l, "text/sourceText", Value::Text(Box::new(doc))); + let mut next = 10_000u64; + l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into()); + set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0)); + set(l, "geometryOptions/bevelStyle", Value::Enum(1)); + }); + let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let l = &ctx.comp.layers[0]; + let params = scene::extrusion_params(&ctx, l).unwrap(); + let meshes = scene::extruded_meshes(&ctx, l, ¶ms); + assert_eq!(meshes.len(), 2, "fill and stroke"); + let width = |m: &effectcraft_model::Primitive| { + let (a, b) = m.positions.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), q| (a.min(q[0]), b.max(q[0]))); + b - a + }; + let (fill, stroke) = (&meshes[0], &meshes[1]); + assert_eq!(fill.1, [1.0, 1.0, 1.0, 1.0]); + assert_eq!(stroke.1, [1.0, 0.0, 0.0, 1.0]); + // The stroke reaches half its width beyond the fill on each side, and is extruded too. + assert!((width(&stroke.0) - width(&fill.0) - 8.0).abs() < 1.0, "{} vs {}", width(&stroke.0), width(&fill.0)); + assert!(stroke.0.positions.iter().any(|q| q[2] > 19.0)); + // Fill Over Stroke: the fill's front cap sits in front of the stroke's. + let front = |m: &effectcraft_model::Primitive| m.positions.iter().fold(f32::INFINITY, |a, q| a.min(q[2])); + assert!(front(&fill.0) < front(&stroke.0)); + // Rendered (unlit: base colours): a red rim around the white face. + let img = render(&p, cid); + let red = img.data.iter().filter(|q| q[3] > 0.99 && q[0] > 0.9 && q[1] < 0.1).count(); + let white = img.data.iter().filter(|q| q[3] > 0.99 && q[1] > 0.9).count(); + assert!(red > 50 && white > 50, "red {red}, white {white}"); +} + +#[test] +fn stroke_regions_are_rings() { + // A closed path's stroke covers a ring (the inside stays open), so an extruded stroke is a + // hollow frame around its fill. + let sq = effectcraft_path::rect([100.0, 100.0], [0.0, 0.0], 0.0); + let st = effectcraft_path::StrokeStyle { width: 10.0, ..Default::default() }; + let r = crate::shapes::stroke_region(std::slice::from_ref(&sq), &st).unwrap(); + let a = effectcraft_path::boolean::filled_area(&r, effectcraft_path::FillRule::NonZero); + assert!((a - (110.0 * 110.0 - 90.0 * 90.0)).abs() < 20.0, "{a}"); + let outlines = effectcraft_model::extrude::group_contours(scene::contours(&r, 0.25)); + assert_eq!(outlines.len(), 1); + assert_eq!(outlines[0].holes.len(), 1); +} diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs index 0252ecb05..12bcbcf37 100644 --- a/crates/render/src/three_d/bokeh.rs +++ b/crates/render/src/three_d/bokeh.rs @@ -190,21 +190,28 @@ pub fn boost_highlights(img: &mut Image, h: &Highlight) { }); } -/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels -/// beyond the image are transparent. -pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image { +/// Weighted row-span kernels and their normalisation. +pub type Kernels = (Vec<(f32, Vec<(i32, f64, f64)>)>, f32); + +/// The bokeh kernel of radius `r` as weighted row spans (see [`Iris::spans`]) and the sum of +/// weights; `None` when the blur does nothing (radius below 0.3 px). Fast Rectangle is the box +/// of the iris' width and height (one span per row covering whole pixels); the diffraction +/// fringe adds the rim as the full iris minus a shrunken one. The GPU gathers with exactly these +/// spans. +pub fn kernel_spans(iris: &Iris, r: f64) -> Option { if r < 0.3 { - return img.clone(); + return None; } if iris.sides == 0 { - // Fast Rectangle: a separable box of the iris' width and height. let a = iris.aspect.clamp(0.01, 100.0).sqrt(); - let rx = (r * a).round().max(0.0) as usize; - let ry = (r / a).round().max(0.0) as usize; - return effectcraft_raster::box_blur(img, rx, ry, 1, false); + let rx = (r * a).round().max(0.0) as i32; + let ry = (r / a).round().max(0.0) as i32; + if rx == 0 && ry == 0 { + return None; + } + let spans = (-ry..=ry).map(|dy| (dy, -rx as f64 - 0.5, rx as f64 + 0.5)).collect(); + return Some((vec![(1.0, spans)], ((2 * rx + 1) * (2 * ry + 1)) as f32)); } - // The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). The - // diffraction fringe adds the rim: the full iris minus a shrunken one. let outer = iris.spans(r); let area = |sp: &[(i32, f64, f64)]| sp.iter().map(|s| (s.2 - s.1) as f32).sum::(); let mut kernels = vec![(1.0f32, outer)]; @@ -216,25 +223,72 @@ pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image { kernels.push((-f, inner)); } let norm: f32 = kernels.iter().map(|(wt, sp)| wt * area(sp)).sum(); - if norm <= 0.0 { + (norm > 0.0).then_some((kernels, norm)) +} + +/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels +/// beyond the image are transparent. +pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image { + if r < 0.3 { return img.clone(); } - span_gather(img, &kernels, norm) + if iris.sides == 0 { + // Fast Rectangle: a separable box of the iris' width and height (the same as its spans). + let a = iris.aspect.clamp(0.01, 100.0).sqrt(); + let rx = (r * a).round().max(0.0) as usize; + let ry = (r / a).round().max(0.0) as usize; + return effectcraft_raster::box_blur(img, rx, ry, 1, false); + } + // The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). + match kernel_spans(iris, r) { + Some((kernels, norm)) => span_gather(img, &kernels, norm), + None => img.clone(), + } +} + +/// The blur levels of a depth-varying blur ([`progressive_blur`]): `None` when nothing blurs, +/// one level for a near-constant radius, else up to eight radii spanning the range. +pub fn blur_levels(radius: &[f32]) -> Option> { + let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r))); + if !lo.is_finite() || hi < 0.3 { + return None; + } + if hi - lo < 0.75 { + return Some(vec![(lo + hi) * 0.5]); + } + let n = (((hi - lo) / 2.0).ceil() as usize + 1).clamp(2, 8); + Some((0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect()) +} + +/// How much level `k` of `levels` contributes at radius `r` (the two levels bracketing `r` +/// are blended linearly). +pub fn level_weight(levels: &[f32], k: usize, r: f32) -> f32 { + let n = levels.len(); + if n <= 1 { + return 1.0; + } + let (lo, hi) = (levels[0], levels[n - 1]); + let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); + let kk = (f.floor() as usize).min(n - 2); + let t = f - kk as f32; + if k == kk { + 1.0 - t + } else if k == kk + 1 { + t + } else { + 0.0 + } } /// Depth-varying blur: `radius` gives each pixel's blur radius. The image is blurred at up to /// eight radii spanning the range and each pixel interpolates between the two levels around /// its own radius. pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image { - let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r))); - if !lo.is_finite() || hi < 0.3 { - return img.clone(); + let Some(levels) = blur_levels(radius) else { return img.clone() }; + if levels.len() == 1 { + return bokeh_blur(img, iris, levels[0] as f64); } - if hi - lo < 0.75 { - return bokeh_blur(img, iris, ((lo + hi) * 0.5) as f64); - } - let n = (((hi - lo) / 2.0).ceil() as usize + 1).clamp(2, 8); - let levels: Vec = (0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect(); + let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len()); let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); let mut out = Image::new(img.width, img.height); out.data.par_iter_mut().enumerate().for_each(|(i, o)| { diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 1f5470b36..5ef271bea 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -62,6 +62,21 @@ pub(crate) struct Item<'a> { winv: Mat4, bounds: [f64; 4], bbox: [i64; 4], + /// Depth of field left to an accelerator (`buf` is then unblurred and unpadded). + dof: Option, +} + +/// Depth of field of a plane for an accelerator to apply ([`Plane3d::dof`]): pad the buffer by +/// `pad` transparent pixels, boost its highlights, then blur it at each of `levels` with +/// [`super::bokeh::kernel_spans`] and blend the levels by each pixel's `radius` (padded buffer +/// pixels, row-major) with [`super::bokeh::level_weight`] — what the CPU's depth of field does. +#[derive(Clone, Debug)] +pub struct PlaneDof { + pub pad: u32, + pub iris: super::bokeh::Iris, + pub highlight: super::bokeh::Highlight, + pub radius: Arc>, + pub levels: Vec, } impl Item<'_> { @@ -218,10 +233,20 @@ fn prepare_with<'a>( let world = outer * ctx.world_matrix(layer) * local; // Depth of field: a bokeh blur by each buffer pixel's circle of confusion (constant for a // layer facing the camera, progressive for a tilted one). + let mut plane_dof = None; + let mut bounds = buf_bounds(&buf); if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { - buf = dof_blur(&cam, &dof, world, buf); + if r.defer_dof { + // An accelerator blurs: hand it the radius map of the padded buffer. + if let Some((pd, padded_bounds)) = dof_plan(&cam, &dof, world, &buf) { + plane_dof = Some(pd); + bounds = padded_bounds; + } + } else { + buf = dof_blur(&cam, &dof, world, buf); + bounds = buf_bounds(&buf); + } } - let bounds = buf_bounds(&buf); let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; let geos: Vec = if n <= 1 { geo(&cam, world, bounds, comp, s, out, r.out_offset()).into_iter().collect() @@ -260,16 +285,20 @@ fn prepare_with<'a>( winv: world.inverse().unwrap_or(Mat4::IDENTITY), bounds, bbox, + dof: plane_dof, }) } /// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from /// its camera depth: the circle of confusion over the on-screen size of one layer pixel there. fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec { - let s = buf.scale.max(1e-9); + dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale) +} + +fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec { + let s = scale.max(1e-9); let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); - let (w, h) = (buf.img.width as usize, buf.img.height as usize); - let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - buf.offset[0]) / s, (y - buf.offset[1]) / s, 0.0))); + let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0))); // Camera depth is affine across a plane. let z0 = at(0.5, 0.5); let (zx, zy) = (at(1.5, 0.5) - z0, at(0.5, 1.5) - z0); @@ -287,6 +316,30 @@ fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf out } +/// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`. +fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 { + let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt()); + (rmax * a).ceil() as u32 + 2 +} + +/// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded +/// buffer's layer-space bounds. `None` when nothing blurs. +fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> { + let radii = dof_radii(cam, dof, world, buf); + let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; + if rmax <= 0.3 { + return None; + } + let pad = dof_pad(dof, rmax); + let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize); + let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; + let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale); + let levels = super::bokeh::blur_levels(&radius)?; + let s = buf.scale.max(1e-9); + let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s]; + Some((PlaneDof { pad, iris: dof.iris, highlight: dof.highlight, radius: Arc::new(radius), levels }, bounds)) +} + /// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth. fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc) -> Arc { let radii = dof_radii(cam, dof, world, &buf); @@ -294,8 +347,7 @@ fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc, + /// Depth of field for the accelerator to apply to `buf` (`bounds` are then the padded + /// buffer's). + pub dof: Option, pub bicubic: bool, /// One per motion-blur sub-sample (equal weights). pub geos: Vec, @@ -694,10 +749,12 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3 if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || r.quality(l) == effectcraft_project::Quality::Wireframe) { return None; } - if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() { + if super::adv::active(r, ctx) { return None; } - let g = gather(r, ctx, run, out); + // Depth of field is left to the accelerator (its blur runs where the planes are drawn). + let rd = Renderer { defer_dof: true, ..*r }; + let g = gather(&rd, ctx, run, out); let planes: Vec = g .items .into_iter() @@ -706,6 +763,7 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3 layer_id: it.layer.id.0 as u32, mode: it.layer.blend_mode, buf: it.buf, + dof: it.dof, bicubic: it.bicubic, geos: it.geos, mat: it.mat, diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index b880cdcdc..80d5dd19d 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -17,7 +17,7 @@ mod tests; mod tests_orient; pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam}; -pub use compose::{Geo as PlaneGeo, Plane3d, Run3d}; +pub use compose::{Geo as PlaneGeo, Plane3d, PlaneDof, Run3d}; pub use light::{LightState, Material, lights_at}; use effectcraft_geom::Mat3; diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index a7a85213f..ad7a9fb72 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -37,7 +37,13 @@ //! `onChange` / `onChanging` / `onClose`, `show()` / `close()` and `layout.layout()`. Windows //! are published to [`Session::script_ui`](effectcraft_engine::Session::script_ui) for //! frontends and agents (`scriptui.*` commands); modal dialogs block `show()` until they close. -//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`. +//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`. Resource +//! strings (`new Window("dialog { ok: Button { text: 'OK' } }")`, `add("group { … }")`) build +//! whole trees; `onDraw` handlers paint with `ScriptUIGraphics` (`newPen`/`newBrush`/`rectPath`/ +//! `ellipsePath`/`moveTo`/`lineTo`/`fillPath`/`strokePath`/`drawString`/`measureString`) into a +//! serde draw list frontends paint; edit texts and sliders fire `onChanging` live. +//! * **Sockets** (`socket.rs`): `Socket` (`open`/`listen`/`poll`/`read`/`readln`/`write`/`close`) +//! over TCP, behind the same network preference; on the web `open` returns false. //! //! Entry points: [`install`] sets [`Session::script`](effectcraft_engine::Session::script), which //! the `script.run` command, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`), the Script Console @@ -48,6 +54,7 @@ pub mod matchnames; mod model; mod runtime; +mod socket; mod ui; use effectcraft_engine::{ScriptRequest, Session}; @@ -75,3 +82,5 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome { mod tests; #[cfg(test)] mod tests_ui; +#[cfg(test)] +mod tests_ui_more; diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index fec9d9e39..897beeaaa 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -166,7 +166,7 @@ File.prototype = { read: function (n) { if (this.__buf === null) this.open("r"); if (this.__buf === null) return ""; - var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substr(this.__pos, n); + var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substring(this.__pos, this.__pos + n); this.__pos += s.length; return s; }, @@ -255,7 +255,77 @@ Object.defineProperty(Folder, "temp", { get: function () { return null; } }); Object.defineProperty(Folder, "desktop", { get: function () { return null; } }); Object.defineProperty(Folder, "myDocuments", { get: function () { return null; } }); -function Socket() { __file("network", ""); } +// TCP sockets (behind Allow Scripts to Write Files and Access Network; see socket.rs). +function Socket() { + if (!(this instanceof Socket)) return new Socket(); + __sock("check", 0, "{}"); + this.__id = 0; + this.host = ""; + this.timeout = 10; + this.encoding = "ASCII"; + this.error = ""; +} +function __sockCall(s, op, extra) { + var p = extra || {}; + p.timeout = s.timeout; + p.encoding = s.encoding; + var r = JSON.parse(__sock(op, s.__id, JSON.stringify(p))); + s.error = r.error || ""; + return r; +} +Socket.prototype = { + constructor: Socket, + get connected() { return this.__id > 0 && __sockCall(this, "connected").value === true; }, + get eof() { return this.__id === 0 || __sockCall(this, "eof").value === true; }, + open: function (host, encoding) { + if (this.__id) this.close(); + if (encoding) this.encoding = String(encoding); + var r = __sockCall(this, "open", { host: String(host) }); + if (r.error) return false; + this.__id = r.id; + this.host = String(host); + return true; + }, + listen: function (port, encoding) { + if (this.__id) this.close(); + if (encoding) this.encoding = String(encoding); + var r = __sockCall(this, "listen", { port: __num(port, "port") }); + if (r.error) return false; + this.__id = r.id; + this.host = "127.0.0.1:" + r.port; + this.port = r.port; + return true; + }, + poll: function () { + if (!this.__id) return null; + var r = __sockCall(this, "poll"); + if (r.id === null || r.id === undefined) return null; + var s = Object.create(Socket.prototype); + s.__id = r.id; + s.host = r.host || ""; + s.timeout = this.timeout; + s.encoding = this.encoding; + s.error = ""; + return s; + }, + read: function (count) { + if (!this.__id) return ""; + return __sockCall(this, "read", { count: count === undefined ? -1 : __num(count, "count") }).data || ""; + }, + readln: function () { return this.__id ? __sockCall(this, "readln").data || "" : ""; }, + write: function () { + if (!this.__id) return false; + var s = Array.prototype.slice.call(arguments).map(__str).join(""); + return !__sockCall(this, "write", { data: s }).error; + }, + writeln: function () { return this.write(Array.prototype.slice.call(arguments).map(__str).join("") + "\n"); }, + close: function () { + if (this.__id) __sockCall(this, "close"); + this.__id = 0; + return true; + }, + toString: function () { return "[object Socket]"; } +}; var system = { callSystem: function () { throw __err("scripts can't run system commands"); } }; // ------------------------------------------------------------------ helpers @@ -645,6 +715,19 @@ Property.prototype.property = function () { return null; }; }); def("isDropdownEffect", function () { return false; }); + // Essential Graphics: expose this property in `comp` (its own composition). + P.canAddToMotionGraphicsTemplate = function (comp) { + if (!comp || comp.__id !== this.__layer.__comp) return false; + return __call("essential.canAdd", this.__ref()).ok === true; + }; + P.addToMotionGraphicsTemplateAs = function (comp, name) { + if (!this.canAddToMotionGraphicsTemplate(comp)) return false; + var p = this.__ref(); + if (name !== undefined && name !== null) p.name = String(name); + __call("essential.addProperty", p); + return true; + }; + P.addToMotionGraphicsTemplate = function (comp) { return this.addToMotionGraphicsTemplateAs(comp); }; P.getSeparationFollower = function (dim) { return this.parentProperty.property(["ADBE Position_0", "ADBE Position_1", "ADBE Position_2"][dim]); }; @@ -805,8 +888,6 @@ Property.prototype.property = function () { return null; }; }; P.keyLabel = function () { return 0; }; P.setLabelAtKey = function () {}; - P.addToMotionGraphicsTemplate = function () { return false; }; - P.canAddToMotionGraphicsTemplate = function () { return false; }; })(); // Layer / composition markers (`layer.marker`, `comp.markerProperty`): keyframes are markers. @@ -1352,7 +1433,47 @@ CompItem.prototype.constructor = CompItem; }; P.openInViewer = function () { __call("comp.open", { comp: this.__id }); return null; }; P.duplicate = function () { return __item(__call("project.duplicate", { items: [this.__id] }).items[0]); }; - P.exportAsMotionGraphicsTemplate = function () { return false; }; + // Essential Graphics (templates are EffectCraft's open .ectemplate files). + P.__eg = function () { return __call("essential.list", { comp: this.__id }); }; + P.__controllers = function () { + var out = []; + (function walk(list) { + for (var i = 0; i < list.length; i++) { + if (list[i].kind === "property" || list[i].kind === "media") out.push(list[i]); + if (list[i].children) walk(list[i].children); + } + })(this.__eg().controls); + return out; + }; + def("motionGraphicsTemplateName", function () { return this.__eg().name; }, function (v) { + __call("essential.setName", { comp: this.__id, name: String(v) }); + }); + def("motionGraphicsTemplateControllerCount", function () { return this.__controllers().length; }); + P.getMotionGraphicsTemplateControllerName = function (i) { + var c = this.__controllers(); + i = __num(i, "index"); + if (i < 1 || i > c.length) throw __err("controller index " + i + " is out of range 1.." + c.length); + return c[i - 1].name; + }; + P.setMotionGraphicsControllerName = function (i, name) { + var c = this.__controllers(); + i = __num(i, "index"); + if (i < 1 || i > c.length) throw __err("controller index " + i + " is out of range 1.." + c.length); + __call("essential.rename", { comp: this.__id, control: c[i - 1].id, name: String(name) }); + return String(name); + }; + P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); }; + P.exportAsMotionGraphicsTemplate = function (overwrite, path) { + var f = __str(path); + if (!f) return false; + if (!/\.[A-Za-z0-9]+$/.test(f)) f += ".ectemplate"; + if (!overwrite && JSON.parse(__file("exists", f)) === true) return false; + if (this.__controllers().length === 0) return false; + // The file gate applies to templates too. + __file("write", f, ""); + __call("essential.exportTemplate", { comp: this.__id, path: f }); + return true; + }; })(); function FootageItem(id) { diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 82e456af0..291608c10 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -282,7 +282,7 @@ fn normalize(p: &str) -> std::path::PathBuf { .unwrap_or(out) } -const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network"; +pub(crate) const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network"; /// The file-access gate: reads under the project's folder are always allowed; other reads, all /// writes and listing other folders need the Allow Scripts to Write Files preference. @@ -375,13 +375,14 @@ pub(crate) fn new_context() -> Result { let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(200_000_000); limits.set_recursion_limit(2000); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 9] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 10] = [ (js_string!("__exec"), 2, native_exec), (js_string!("__query"), 2, native_query), (js_string!("__print"), 1, native_print), (js_string!("__undo"), 2, native_undo), (js_string!("__task"), 3, native_task), (js_string!("__file"), 3, native_file), + (js_string!("__sock"), 3, crate::socket::native_sock), (js_string!("__uiNewId"), 0, crate::ui::native_new_id), (js_string!("__uiModal"), 1, crate::ui::native_modal), (js_string!("__uiLayoutNative"), 1, crate::ui::native_layout), diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index e214be305..57e0f445a 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -58,13 +58,205 @@ var __uiContainers = { window: 1, panel: 1, group: 1, tabbedpanel: 1, tab: 1 }; function Window(type, title, bounds, props) { if (!(this instanceof Window)) return new Window(type, title, bounds, props); - type = String(type === undefined ? "window" : type).toLowerCase(); - if (type.indexOf("{") >= 0) throw __err("ScriptUI resource strings are not supported: build the window with add()"); + type = String(type === undefined ? "window" : type); + var res = null; + if (type.indexOf("{") >= 0) { + // A resource string: `dialog { text: 'Title', ok: Button { text: 'OK' } }`. + res = __uiParseRes(type); + type = res.type; + var p0 = __uiResProps(res).props; + if (title === undefined && p0.text !== undefined) title = p0.text; + if (bounds === undefined && p0.bounds !== undefined) bounds = p0.bounds; + if (props === undefined && p0.properties !== undefined) props = p0.properties; + } + type = type.toLowerCase(); if (type !== "dialog" && type !== "palette" && type !== "window") throw __err("Bad window type " + type); __uiInit(this, "window", null, null, bounds, title, props); this.__kind = type; this.__root = true; __uiWins.push(this); + if (res) __uiApplyRes(this, res); +} + +// ---------------------------------------------------------------- resource strings +// +// The resource specification the JavaScript Tools Guide documents: `type { name: value, … }` +// where a value is a string ('…' or "…"), a number, true / false, an array `[…]`, an object +// literal `{…}` (e.g. `properties: {multiline: true}`) or a nested element `Type { … }` (a +// child control, also reachable as `parent.name`). Comments are allowed. + +function __uiParseRes(src) { + var s = String(src), i = 0; + function fail(m) { throw __err("ScriptUI resource string: " + m + " at offset " + i); } + function ws() { + for (;;) { + while (i < s.length && /\s/.test(s.charAt(i))) i++; + if (s.substring(i, i + 2) === "//") { + while (i < s.length && s.charAt(i) !== "\n") i++; + continue; + } + if (s.substring(i, i + 2) === "/*") { + var e = s.indexOf("*/", i + 2); + i = e < 0 ? s.length : e + 2; + continue; + } + return; + } + } + function ident() { + ws(); + var m = /^[A-Za-z_$][\w$]*/.exec(s.slice(i)); + if (!m) fail("expected a name"); + i += m[0].length; + return m[0]; + } + function str(q) { + i++; + var out = ""; + while (i < s.length && s.charAt(i) !== q) { + var c = s.charAt(i); + if (c === "\\") { + i++; + var e = s.charAt(i); + out += e === "n" ? "\n" : e === "t" ? "\t" : e === "r" ? "\r" : e; + } else out += c; + i++; + } + if (i >= s.length) fail("unterminated string"); + i++; + return out; + } + function value() { + ws(); + var c = s.charAt(i); + if (c === "'" || c === '"') return str(c); + if (c === "[") { + i++; + var a = []; + ws(); + if (s.charAt(i) === "]") { + i++; + return a; + } + for (;;) { + a.push(value()); + ws(); + if (s.charAt(i) === ",") { + i++; + continue; + } + if (s.charAt(i) === "]") { + i++; + return a; + } + fail("expected , or ]"); + } + } + if (c === "{") return { __object: body() }; + var m = /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?/.exec(s.slice(i)); + if (m) { + i += m[0].length; + return Number(m[0]); + } + var id = ident(); + if (id === "true") return true; + if (id === "false") return false; + if (id === "null" || id === "undefined") return null; + ws(); + if (s.charAt(i) === "{") return { __element: id, list: body() }; + // A bare word (an enum-like value). + return id; + } + function body() { + i++; + var list = []; + for (;;) { + ws(); + if (s.charAt(i) === "}") { + i++; + return list; + } + var k = s.charAt(i) === "'" || s.charAt(i) === '"' ? str(s.charAt(i)) : ident(); + ws(); + if (s.charAt(i) !== ":") fail("expected : after " + k); + i++; + list.push([k, value()]); + ws(); + if (s.charAt(i) === ",") { + i++; + continue; + } + if (s.charAt(i) === "}") { + i++; + return list; + } + fail("expected , or }"); + } + } + var t = ident(); + ws(); + if (s.charAt(i) !== "{") fail("expected { after " + t); + var list = body(); + ws(); + if (i < s.length) fail("unexpected text after the resource"); + return { type: t, list: list }; +} + +// The plain JavaScript value of a parsed resource value (object literals become objects). +function __uiResPlain(v) { + if (v instanceof Array) return v.map(__uiResPlain); + if (v && typeof v === "object" && v.__object) { + var o = {}; + for (var k = 0; k < v.__object.length; k++) o[v.__object[k][0]] = __uiResPlain(v.__object[k][1]); + return o; + } + return v; +} + +// A resource's own properties and its child elements (in source order). +function __uiResProps(res) { + var props = {}, kids = []; + for (var k = 0; k < res.list.length; k++) { + var e = res.list[k]; + if (e[1] && typeof e[1] === "object" && e[1].__element) kids.push([e[0], { type: e[1].__element, list: e[1].list }]); + else props[e[0]] = __uiResPlain(e[1]); + } + return { props: props, kids: kids }; +} + +var __uiResSkip = { text: 1, bounds: 1, properties: 1, minvalue: 1, maxvalue: 1, items: 1 }; +var __uiSizes = { preferredSize: 1, minimumSize: 1, maximumSize: 1, size: 1, location: 1 }; + +// Set a control's properties and create its children from a parsed resource. +function __uiApplyRes(c, res) { + var pk = __uiResProps(res), props = pk.props; + // Ranges before values, items before selections. + if (props.minvalue !== undefined) c.minvalue = __num(props.minvalue); + if (props.maxvalue !== undefined) c.maxvalue = __num(props.maxvalue); + if (props.items instanceof Array && c.items) for (var n = 0; n < props.items.length; n++) c.add("item", props.items[n]); + for (var k in props) { + if (__uiResSkip[k]) continue; + var v = props[k]; + if (__uiSizes[k] && v instanceof Array) v = __dim(v[0], v[1]); + c[k] = v; + } + for (var j = 0; j < pk.kids.length; j++) { + var child = __uiAddRes(c, pk.kids[j][1]); + child.__key = pk.kids[j][0]; + c[pk.kids[j][0]] = child; + } + return c; +} + +// add() with a resource string (or a parsed element): the new control. +function __uiAddRes(parent, spec) { + var res = typeof spec === "string" ? __uiParseRes(spec) : spec; + var p = __uiResProps(res).props; + var t = String(res.type).toLowerCase(); + var valued = t === "dropdownlist" || t === "listbox" || t === "slider" || t === "scrollbar" || t === "progressbar"; + var c = parent.add(t, p.bounds, valued ? undefined : p.text, p.properties); + if (valued && p.text !== undefined) c.text = String(p.text); + return __uiApplyRes(c, res); } Window.alert = function (s) { alert(s); }; Window.confirm = function (s) { return confirm(s); }; @@ -106,18 +298,95 @@ var ScriptUI = { events: { createEvent: function (t) { return { type: t }; } } }; +// A colour for the draw list: [r, g, b, a] in 0..1, or null for a theme colour (by name). +function __uiColor(c) { + if (c instanceof Array && c.length >= 3) return [__num(c[0]), __num(c[1]), __num(c[2]), c.length > 3 ? __num(c[3]) : 1]; + return null; +} +function __uiFontSize(f, g) { + var x = f && f.size !== undefined ? Number(f.size) : g && g.font && g.font.size !== undefined ? Number(g.font.size) : 12; + return x > 0 ? x : 12; +} + +// ScriptUIGraphics: what onDraw handlers paint with. Paths and paint calls are recorded into the +// control's draw list (`draw` in the window snapshot), which frontends paint. function __uiGraphics() { return { - newBrush: function (t, c) { return { type: t, color: c }; }, - newPen: function (t, c, w) { return { type: t, color: c, lineWidth: w }; }, - newPath: function () {}, moveTo: function () {}, lineTo: function () {}, rectPath: function () {}, - ellipsePath: function () {}, fillPath: function () {}, strokePath: function () {}, closePath: function () {}, - drawString: function () {}, drawOSControl: function () {}, drawImage: function () {}, drawFocusRing: function () {}, - measureString: function (s) { return __dim(String(s).length * 7, 16); }, - font: null, foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null + BrushType: ScriptUI.BrushType, + PenType: ScriptUI.PenType, + font: ScriptUI.newFont("dialog", "REGULAR", 12), + foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null, + currentPath: [], + currentPoint: [0, 0], + __rec: null, + __push: function (op) { if (this.__rec) this.__rec.push(op); }, + newBrush: function (t, c) { return { type: t, color: __uiColor(c), theme: typeof c === "string" ? c : "" }; }, + newPen: function (t, c, w) { return { type: t, color: __uiColor(c), theme: typeof c === "string" ? c : "", lineWidth: w === undefined ? 1 : __num(w) }; }, + newPath: function () { this.currentPath = []; return this.currentPath; }, + moveTo: function (x, y) { this.currentPath.push({ k: "M", x: __num(x), y: __num(y) }); this.currentPoint = [__num(x), __num(y)]; }, + lineTo: function (x, y) { this.currentPath.push({ k: "L", x: __num(x), y: __num(y) }); this.currentPoint = [__num(x), __num(y)]; }, + closePath: function () { this.currentPath.push({ k: "Z" }); }, + rectPath: function (x, y, w, h) { + if (x instanceof Array) { h = x[3] - x[1]; w = x[2] - x[0]; y = x[1]; x = x[0]; } + this.currentPath.push({ k: "R", x: __num(x), y: __num(y), w: __num(w), h: __num(h) }); + return this.currentPath; + }, + ellipsePath: function (x, y, w, h) { + if (x instanceof Array) { h = x[3] - x[1]; w = x[2] - x[0]; y = x[1]; x = x[0]; } + this.currentPath.push({ k: "E", x: __num(x), y: __num(y), w: __num(w), h: __num(h) }); + return this.currentPath; + }, + fillPath: function (brush, path) { + this.__push({ op: "fill", color: brush ? brush.color : null, path: (path || this.currentPath).slice() }); + }, + strokePath: function (pen, path) { + this.__push({ op: "stroke", color: pen ? pen.color : null, width: pen && pen.lineWidth !== undefined ? pen.lineWidth : 1, path: (path || this.currentPath).slice() }); + }, + drawString: function (text, pen, x, y, font) { + var f = font || this.font; + this.__push({ op: "text", text: String(text), color: pen ? pen.color : null, x: __num(x || 0), y: __num(y || 0), size: __uiFontSize(f, this), style: String(f && f.style !== undefined ? f.style : "") }); + }, + drawOSControl: function () { this.__push({ op: "os" }); }, + drawImage: function (img, x, y, w, h) { this.__push({ op: "image", x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); }, + drawFocusRing: function () {}, + measureString: function (s, font, boundaryWidth) { + var size = __uiFontSize(font, this); + var lines = String(s).split("\n"); + var cw = size * 0.58, w = 0; + for (var i = 0; i < lines.length; i++) w = Math.max(w, Math.ceil(lines[i].length * cw)); + var n = lines.length; + if (boundaryWidth > 0 && w > boundaryWidth) { + n = Math.ceil(w / boundaryWidth) + lines.length - 1; + w = boundaryWidth; + } + return __dim(w, Math.ceil(size * 1.25) * n); + } }; } +// Run the onDraw handlers of a window's controls (after laying it out, so `size` is known), +// recording what they paint. +function __uiRunDraws(win) { + var drawn = []; + (function walk(c) { + if (typeof c.onDraw === "function") drawn.push(c); + for (var i = 0; i < (c.children || []).length; i++) walk(c.children[i]); + })(win); + if (!drawn.length) return; + __uiLayout(win); + for (var k = 0; k < drawn.length; k++) { + var c = drawn[k]; + c.__draw = []; + c.graphics.__rec = c.__draw; + try { + c.onDraw.call(c, { type: "draw", target: c }); + } catch (e) { + try { writeLn("onDraw: " + (e && e.message ? e.message : e)); } catch (e2) {} + } + c.graphics.__rec = null; + } +} + function __uiInit(o, type, win, parent, bounds, text, props) { o.type = type; o.window = win || o; @@ -180,6 +449,10 @@ function __uiProto() { return { add: function (type, bounds, text, props) { var t = String(type).toLowerCase(); + if (t.indexOf("{") >= 0) { + if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls"); + return __uiAddRes(this, String(type)); + } if (t === "item" || t === "separator") return this.__addItem(t === "separator" ? "-" : bounds); if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls"); var ctor = __uiTypes[t]; @@ -302,7 +575,7 @@ function __uiProto() { }, __handlers: function () { var out = []; - var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize"]; + var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize", "onDraw"]; for (var i = 0; i < names.length; i++) { var n = names[i]; if (typeof this[n] === "function" || (this.__listeners[n.slice(2).toLowerCase()] || []).length) out.push(n); @@ -411,7 +684,7 @@ function __uiJson(c) { var o = { id: c.__id, type: c.type, - name: String(c.name || c.properties.name || ""), + name: String(c.name || c.properties.name || c.__key || ""), text: String(c.text === undefined || c.text === null ? "" : c.text), enabled: c.enabled !== false, visible: c.visible !== false, @@ -444,6 +717,7 @@ function __uiJson(c) { o.selection = c.__sel.slice(); } if (c.type === "tabbedpanel") o.activeTab = c.__sel.length ? c.__sel[0] : 0; + if (c.__draw && typeof c.onDraw === "function") o.draw = c.__draw; if (c.type === "edittext") { o.multiline = !!c.__multiline; o.readOnly = !!c.__readonly; @@ -456,6 +730,7 @@ function __uiSnapshot() { var out = []; for (var i = 0; i < __uiWins.length; i++) { var w = __uiWins[i]; + if (w.__shown && !w.__closed) __uiRunDraws(w); out.push({ id: w.__wid, kind: w.__kind, title: String(w.text || ""), visible: !!w.__shown && !w.__closed, modal: !!w.__modal, root: __uiJson(w) }); } // Closed windows are forgotten. @@ -529,14 +804,17 @@ function __uiDispatch(winId, id, kind, valueJson) { } return; } - // change / changing + // change / changing (live: each keystroke of an edit text, each step of a slider drag) + var before = c.type === "edittext" ? c.text : c.value; if (c.type === "edittext") c.text = v === null ? "" : String(v); else if (c.type === "statictext") c.text = v === null ? "" : String(v); else if (c.type === "slider" || c.type === "scrollbar" || c.type === "progressbar") c.value = Number(v); else if (c.type === "checkbox") c.value = !!v; else if (c.type === "radiobutton") { if (v) radio(); else c.value = false; } else if (c.type === "dropdownlist" || c.type === "listbox" || c.type === "tabbedpanel") c.selection = v; - if (c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") c.__fire("onChanging"); + var now = c.type === "edittext" ? c.text : c.value; + // onChanging already ran for live updates of the same value. + if ((c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") && (kind === "changing" || now !== before)) c.__fire("onChanging"); if (kind === "change") { if (c.type === "checkbox" || c.type === "radiobutton") c.__fire("onClick"); else c.__fire("onChange"); diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs new file mode 100644 index 000000000..8b1074d40 --- /dev/null +++ b/crates/script/src/socket.rs @@ -0,0 +1,241 @@ +//! `Socket`: TCP connections for scripts (the JavaScript Tools Guide's Socket object: `open`, +//! `listen`, `poll`, `read`, `readln`, `write`, `writeln`, `close`, `connected`, `eof`). +//! +//! Network access follows Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files +//! and Access Network: with it off, `new Socket()` throws. On the web there are no raw sockets: +//! `open` and `listen` return false (with `error` set). +//! +//! The JavaScript side holds only a handle; the streams live here, keyed by handle, so a script +//! window's handlers can keep using a socket opened earlier in the same script. + +use boa_engine::{Context, JsResult, JsValue}; +use serde_json::{Value as J, json}; + +use crate::runtime::{arg_string, js_str, throw, with_active}; + +/// `__sock(op, handle, paramsJson)` → result JSON (`{error}` on failure). +pub(crate) fn native_sock(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let op = arg_string(args, 0, ctx)?; + let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32; + let params = arg_string(args, 2, ctx)?; + let p: J = serde_json::from_str(¶ms).unwrap_or(J::Null); + let allowed = with_active(|a| Ok(a.session.prefs.scripting.allow_scripts_write_files)).unwrap_or(false); + if !allowed { + return Err(throw(format!("scripts can't access the network unless {} is on", crate::runtime::GATE))); + } + if op == "check" { + return Ok(js_str("{}")); + } + let r = imp::run(&op, id, &p); + Ok(js_str(&r.to_string())) +} + +fn err(e: impl std::fmt::Display) -> J { + json!({"error": e.to_string()}) +} + +#[cfg(target_arch = "wasm32")] +mod imp { + use super::*; + pub(super) fn run(op: &str, _: u32, _: &J) -> J { + match op { + "connected" | "eof" => json!({"value": op == "eof"}), + "close" => json!({}), + _ => err("sockets are not available in the web version of EffectCraft"), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use std::collections::BTreeMap; + use std::io::{ErrorKind, Read, Write}; + use std::net::{TcpListener, TcpStream, ToSocketAddrs}; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + use super::*; + + enum Sock { + Stream { s: TcpStream, buf: Vec, eof: bool }, + Listener(TcpListener), + } + + struct Table { + next: u32, + socks: BTreeMap, + } + + fn table() -> &'static Mutex { + static T: Mutex
= Mutex::new(Table { next: 1, socks: BTreeMap::new() }); + &T + } + + fn add(s: Sock) -> u32 { + let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); + let id = t.next; + t.next += 1; + t.socks.insert(id, s); + id + } + + fn timeout(p: &J) -> Duration { + Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0)) + } + + /// `host:port` (After Effects' form) → socket addresses. + fn addr(host: &str) -> Result, String> { + let a = host.to_socket_addrs().map_err(|e| format!("bad address `{host}` (use host:port): {e}"))?; + Ok(a.collect()) + } + + /// Text ↔ bytes in the socket's encoding: UTF-8, else one byte per character (ASCII / + /// BINARY, as in After Effects). + fn encode(s: &str, utf8: bool) -> Vec { + if utf8 { s.as_bytes().to_vec() } else { s.chars().map(|c| (c as u32).min(255) as u8).collect() } + } + fn decode(b: &[u8], utf8: bool) -> String { + if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|&c| c as char).collect() } + } + + /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. + fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) { + let end = Instant::now() + limit; + let mut chunk = [0u8; 4096]; + while !done(buf) && !*eof { + let left = end.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1)))); + match s.read(&mut chunk) { + Ok(0) => *eof = true, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => { + if e.kind() != ErrorKind::Interrupted { + break; + } + } + Err(_) => *eof = true, + } + } + } + + pub(super) fn run(op: &str, id: u32, p: &J) -> J { + let utf8 = p.get("encoding").and_then(J::as_str).is_some_and(|e| e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8")); + match op { + "open" => { + let host = p.get("host").and_then(J::as_str).unwrap_or(""); + let addrs = match addr(host) { + Ok(a) => a, + Err(e) => return err(e), + }; + let limit = timeout(p).max(Duration::from_millis(10)); + let mut last = "no address".to_string(); + for a in addrs { + match TcpStream::connect_timeout(&a, limit) { + Ok(s) => { + let _ = s.set_nodelay(true); + return json!({"id": add(Sock::Stream { s, buf: vec![], eof: false })}); + } + Err(e) => last = e.to_string(), + } + } + err(format!("cannot connect to {host}: {last}")) + } + "listen" => { + let port = p.get("port").and_then(J::as_u64).unwrap_or(0) as u16; + match TcpListener::bind(("127.0.0.1", port)).and_then(|l| l.set_nonblocking(true).map(|_| l)) { + Ok(l) => { + let port = l.local_addr().map(|a| a.port()).unwrap_or(port); + json!({"id": add(Sock::Listener(l)), "port": port}) + } + Err(e) => err(format!("cannot listen on port {port}: {e}")), + } + } + _ => { + let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); + if op == "close" { + t.socks.remove(&id); + return json!({}); + } + let Some(sock) = t.socks.get_mut(&id) else { + return match op { + "connected" => json!({"value": false}), + "eof" => json!({"value": true}), + _ => err("the socket is not open"), + }; + }; + match (op, sock) { + ("poll", Sock::Listener(l)) => match l.accept() { + Ok((s, peer)) => { + let _ = s.set_nonblocking(false); + let host = peer.to_string(); + let new = Sock::Stream { s, buf: vec![], eof: false }; + let nid = t.next; + t.next += 1; + t.socks.insert(nid, new); + json!({"id": nid, "host": host}) + } + Err(_) => json!({"id": null}), + }, + ("connected", Sock::Stream { s, buf, eof }) => { + if !*eof && buf.is_empty() { + // Peek without blocking to notice a closed peer. + let _ = s.set_nonblocking(true); + let mut b = [0u8; 1]; + match s.peek(&mut b) { + Ok(0) => *eof = true, + Err(e) if e.kind() != ErrorKind::WouldBlock => *eof = true, + _ => {} + } + let _ = s.set_nonblocking(false); + } + json!({"value": !(*eof && buf.is_empty())}) + } + ("connected", Sock::Listener(_)) => json!({"value": true}), + ("eof", Sock::Stream { buf, eof, .. }) => json!({"value": *eof && buf.is_empty()}), + ("read", Sock::Stream { s, buf, eof }) => { + let count = p.get("count").and_then(J::as_i64).unwrap_or(-1); + let limit = timeout(p); + if count < 0 { + fill(s, buf, eof, limit, |_| false); + let all = std::mem::take(buf); + json!({"data": decode(&all, utf8)}) + } else { + let n = count as usize; + fill(s, buf, eof, limit, |b| b.len() >= n); + let take = n.min(buf.len()); + let out: Vec = buf.drain(..take).collect(); + json!({"data": decode(&out, utf8)}) + } + } + ("readln", Sock::Stream { s, buf, eof }) => { + fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')); + let line: Vec = match buf.iter().position(|&c| c == b'\n') { + Some(i) => { + let mut l: Vec = buf.drain(..=i).collect(); + l.pop(); + if l.last() == Some(&b'\r') { + l.pop(); + } + l + } + None => std::mem::take(buf), + }; + json!({"data": decode(&line, utf8)}) + } + ("write", Sock::Stream { s, .. }) => { + let data = p.get("data").and_then(J::as_str).unwrap_or(""); + match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) { + Ok(()) => json!({"ok": true}), + Err(e) => err(e), + } + } + ("host", Sock::Stream { s, .. }) => json!({"value": s.peer_addr().map(|a| a.to_string()).unwrap_or_default()}), + (op, _) => err(format!("`{op}` is not supported on this socket")), + } + } + } + } +} diff --git a/crates/script/src/tests_ui.rs b/crates/script/src/tests_ui.rs index 667a32423..460376ac5 100644 --- a/crates/script/src/tests_ui.rs +++ b/crates/script/src/tests_ui.rs @@ -183,9 +183,9 @@ fn layout_and_handler_errors() { assert_eq!(r["ok"], false); assert!(r["error"]["message"].as_str().unwrap().contains("nope"), "{r}"); assert_eq!(s.script_ui.windows.len(), 1); - // Resource strings aren't supported (a clear error). - let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\")", "res.jsx"); - assert!(o.error.unwrap().message.contains("resource")); + // Resource strings build windows too (see tests_ui_more). + let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\").text", "res.jsx"); + assert_eq!(o.result, json!("x")); s.execute("scriptui.close", json!({})).unwrap(); } diff --git a/crates/script/src/tests_ui_more.rs b/crates/script/src/tests_ui_more.rs new file mode 100644 index 000000000..2c24329ac --- /dev/null +++ b/crates/script/src/tests_ui_more.rs @@ -0,0 +1,242 @@ +//! ScriptUI resource strings, onDraw graphics, live onChanging updates and sockets. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::scriptui::{DrawOp, PathSeg, WidgetKind, WindowKind}; +use serde_json::json; + +use crate::run_code; + +fn session() -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() }; + crate::install(&mut s); + s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default())); + s +} + +const RESOURCE: &str = r#" +var w = new Window("palette { text: 'Res', orientation: 'column', alignChildren: ['fill', 'top'], \ + g: Group { orientation: 'row', b: Button { text: 'OK', properties: { name: 'okb' } }, cancel: Button { text: \"Can't\" } }, \ + nm: EditText { text: 'abc', characters: 10 }, \ + s: Slider { minvalue: 0, maxvalue: 10, value: 4 }, \ + dd: DropDownList { properties: { items: ['A', 'B'] }, selection: 1 }, \ + /* a panel */ p: Panel { text: 'Opts', preferredSize: [200, -1], c: Checkbox { text: 'On', value: true } } }"); +var extra = w.p.add("group { orientation: 'column', t: StaticText { text: 'hi' } }"); +w.g.b.onClick = function () { writeLn("ok " + w.nm.text + " " + w.s.value); }; +w.show(); +[w.text, w.g.orientation, w.g.b.text, w.g.cancel.text, w.s.maxvalue, w.dd.selection.text, w.p.c.value, extra.t.text, w.g.children.length, w.p.preferredSize.width].join("|") +"#; + +#[test] +fn resource_strings_build_windows_and_controls() { + let mut s = session(); + let o = run_code(&mut s, RESOURCE, "res.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("Res|row|OK|Can't|10|B|true|hi|2|200")); + let w = s.script_ui.windows[0].clone(); + assert_eq!((w.kind, w.title.as_str()), (WindowKind::Palette, "Res")); + let kinds: Vec = w.root.children.iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [WidgetKind::Group, WidgetKind::EditText, WidgetKind::Slider, WidgetKind::DropDownList, WidgetKind::Panel]); + assert_eq!(w.root.children[2].value, 4.0); + assert_eq!(w.root.children[3].items, ["A", "B"]); + assert_eq!(w.root.children[3].selection, [1]); + assert!(w.root.children[4].children[0].checked); + assert_eq!(w.root.children[4].children[1].children[0].text, "hi"); + // Elements are named after their resource keys (agents address them by it). + assert_eq!(w.root.children[1].name, "nm"); + s.execute("scriptui.set", json!({"widget": "nm", "value": "xyz"})).unwrap(); + let r = s.execute("scriptui.click", json!({"widget": "okb"})).unwrap(); + assert_eq!(r["output"], "ok xyz 4", "{r}"); + s.execute("scriptui.close", json!({})).unwrap(); + // Syntax errors say where. + let o = run_code(&mut s, "new Window(\"dialog { text: 'x' oops }\")", "bad.jsx"); + assert!(o.error.unwrap().message.contains("resource string")); + let o = run_code(&mut s, "new Window(\"panel { }\")", "bad.jsx"); + assert!(o.error.unwrap().message.contains("Bad window type")); +} + +const DRAW: &str = r#" +var w = new Window("palette", "Draw"); +var c = w.add("group"); +c.preferredSize = [100, 50]; +c.onDraw = function () { + var g = this.graphics; + var b = g.newBrush(g.BrushType.SOLID_COLOR, [1, 0, 0, 1]); + g.newPath(); + g.rectPath(0, 0, this.size.width, this.size.height); + g.fillPath(b); + var p = g.newPen(g.PenType.SOLID_COLOR, [0, 0, 1], 2); + g.newPath(); + g.moveTo(0, 0); + g.lineTo(10, 10); + g.strokePath(p); + g.ellipsePath(20, 20, 10, 10); + g.drawString("Hi", p, 5, 6, ScriptUI.newFont("dialog", "BOLD", 20)); +}; +var m = c.graphics.measureString("Hello", ScriptUI.newFont("dialog", "REGULAR", 12)); +w.show(); +[m.width, m[1]].join(",") +"#; + +#[test] +fn on_draw_handlers_record_a_draw_list() { + let mut s = session(); + let o = run_code(&mut s, DRAW, "draw.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("35,15")); + let w = s.script_ui.windows[0].clone(); + let g = &w.root.children[0]; + assert!(g.handlers.contains(&"onDraw".to_string())); + assert_eq!(g.draw.len(), 3, "{:?}", g.draw); + // The handler saw the laid-out size. + assert_eq!(g.draw[0], DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path: vec![PathSeg::R { x: 0.0, y: 0.0, w: 100.0, h: 50.0 }] }); + match &g.draw[1] { + DrawOp::Stroke { color, width, path } => { + assert_eq!((*color, *width), (Some([0.0, 0.0, 1.0, 1.0]), 2.0)); + assert_eq!(path.len(), 2); + } + o => panic!("{o:?}"), + } + assert!(matches!(&g.draw[2], DrawOp::Text { text, size, style, .. } if text == "Hi" && *size == 20.0 && style == "BOLD")); + // scriptui.get carries it for agents. + let tree = s.execute("scriptui.get", json!({})).unwrap(); + assert_eq!(tree["root"]["children"][0]["draw"][0]["op"], "fill"); + s.execute("scriptui.close", json!({})).unwrap(); +} + +const LIVE: &str = r#" +var w = new Window("palette", "Live"); +var e = w.add("edittext", undefined, ""); +var sl = w.add("slider", undefined, 0, 0, 100); +var log = w.add("statictext", undefined, "-"); +e.onChanging = function () { writeLn("changing " + e.text); log.text = "typing " + e.text; }; +e.onChange = function () { writeLn("change " + e.text); }; +sl.onChanging = function () { writeLn("slide " + Math.round(sl.value)); }; +sl.onChange = function () { writeLn("slid " + Math.round(sl.value)); }; +w.show(); +"#; + +#[test] +fn on_changing_fires_per_keystroke_and_slider_step() { + let mut s = session(); + let o = run_code(&mut s, LIVE, "live.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "a", "changing": true})).unwrap(); + assert_eq!(r["output"], "changing a"); + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab", "changing": true})).unwrap(); + assert_eq!(r["output"], "changing ab"); + assert_eq!(s.script_ui.windows[0].root.children[2].text, "typing ab"); + // Committing the same text runs onChange only. + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab"})).unwrap(); + assert_eq!(r["output"], "change ab"); + // A committed edit without live updates runs both. + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "abc"})).unwrap(); + assert_eq!(r["output"], "changing abc\nchange abc"); + // Slider drags update the value live. + let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 40, "changing": true})).unwrap(); + assert_eq!(r["output"], "slide 40"); + assert_eq!(s.script_ui.windows[0].root.children[1].value, 40.0); + let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 41})).unwrap(); + assert_eq!(r["output"], "slide 41\nslid 41"); + s.execute("scriptui.close", json!({})).unwrap(); +} + +const MOGRT: &str = r#" +var c = app.project.activeItem; +var t = c.layer(1); +var st = t.property("ADBE Text Properties").property("ADBE Text Document"); +var op = t.property("ADBE Transform Group").property("ADBE Opacity"); +var r = [st.canAddToMotionGraphicsTemplate(c), st.addToMotionGraphicsTemplate(c), op.addToMotionGraphicsTemplateAs(c, "Fade")]; +c.motionGraphicsTemplateName = "Lower Third"; +c.setMotionGraphicsControllerName(1, "Title"); +r.concat([c.motionGraphicsTemplateName, c.motionGraphicsTemplateControllerCount, c.getMotionGraphicsTemplateControllerName(1), c.getMotionGraphicsTemplateControllerName(2)]).join("|") +"#; + +#[test] +fn essential_graphics_scripting_hooks() { + let mut s = session(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + let o = run_code(&mut s, MOGRT, "mogrt.jsx"); + assert!(o.error.is_none(), "{:?} {:?}", o.error, o.output); + assert_eq!(o.result, json!("true|true|true|Lower Third|2|Title|Fade")); + let cid = s.active_comp_id().unwrap(); + assert_eq!(s.project.comp(cid).unwrap().essential.as_ref().unwrap().name, "Lower Third"); + // Another comp can't take this comp's properties. + s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64, "duration": 1})).unwrap(); + let code = "var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); var o = app.project.activeItem; var p = card.layer(1).property('ADBE Transform Group').property('ADBE Position'); [p.canAddToMotionGraphicsTemplate(o), p.canAddToMotionGraphicsTemplate(card)].join('|')"; + let o = run_code(&mut s, code, "x.jsx"); + assert_eq!(o.result, json!("false|true"), "{:?}", o.error); + // Export (behind the file-write preference). + let dir = std::env::temp_dir().join(format!("ec-mogrt-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("lower.ectemplate"); + let export = format!( + "var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); card.exportAsMotionGraphicsTemplate(false, {:?})", + path.to_string_lossy() + ); + assert!(run_code(&mut s, &export, "x.jsx").error.is_some()); + s.prefs.scripting.allow_scripts_write_files = true; + assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(true)); + assert!(std::fs::metadata(&path).unwrap().len() > 100); + // It exists now: no overwrite → false. + assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(false)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn create_nulls_from_paths_expressions_evaluate() { + let mut s = session(); + s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap(); + s.execute("paths.nullsFollowPoints", json!({"layer": l})).unwrap(); + s.execute("paths.tracePath", json!({"layer": l})).unwrap(); + // Layer 1: the trace null; layers 2..5: the vertex nulls (top = last vertex). + let pos = |i: u32, t: f64| format!("app.project.activeItem.layer({i}).property('ADBE Transform Group').property('ADBE Position').valueAtTime({t}, false)"); + let o = run_code(&mut s, &format!("[{}, {}, {}].join('|')", pos(2, 0.0), pos(1, 0.0), pos(1, 0.5)), "x.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + let parts: Vec> = o.result.as_str().unwrap().split('|').map(|p| p.split(',').map(|x| x.parse().unwrap()).collect()).collect(); + assert_eq!(&parts[0][..2], &[30.0, 60.0], "vertex 4 (0, 40) in comp space"); + assert_eq!(&parts[1][..2], &[30.0, 20.0], "progress 0: the first vertex"); + // Halfway in time along the closed square: the opposite corner. + assert!((parts[2][0] - 70.0).abs() < 0.5 && (parts[2][1] - 60.0).abs() < 0.5, "{:?}", parts[2]); + // Points Follow Nulls: moving the last vertex's null moves the vertex. + let r = s.execute("paths.pointsFollowNulls", json!({"layer": l})).unwrap(); + let last = r["nulls"][3].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": last, "path": "transform/position", "value": [10, 10]})).unwrap(); + let code = "var c = app.project.activeItem; var m = c.layer(c.numLayers).property('ADBE Mask Parade').property(1).property('ADBE Mask Shape').valueAtTime(0, false); [m.vertices[3][0], m.vertices[3][1], m.vertices[0][0], m.vertices.length].join(',')"; + let o = run_code(&mut s, code, "x.jsx"); + assert_eq!(o.result, json!("-20,-10,0,4"), "{:?}", o.error); +} + +#[test] +fn sockets_talk_tcp_behind_the_network_preference() { + use std::io::{BufRead, BufReader, Write}; + let mut s = session(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut c, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(c.try_clone().unwrap()).read_line(&mut line).unwrap(); + c.write_all(format!("pong:{}\nrest", line.trim()).as_bytes()).unwrap(); + }); + let code = format!( + "var s = new Socket(); var ok = s.open('127.0.0.1:{port}'); s.writeln('ping'); var r = s.readln(); var rest = s.read(); var e = s.eof; s.close(); [ok, r, rest, e, s.connected].join('|')" + ); + // Off: no network. + let o = run_code(&mut s, &code, "net.jsx"); + assert!(o.error.unwrap().message.contains("Access Network")); + s.prefs.scripting.allow_scripts_write_files = true; + let o = run_code(&mut s, &code, "net.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("true|pong:ping|rest|true|false")); + server.join().unwrap(); + // Refused connections return false with an error; listen + poll serve clients. + let o = run_code(&mut s, "var s = new Socket(); s.timeout = 1; var ok = s.open('127.0.0.1:1'); [ok, s.error.length > 0].join('|')", "net.jsx"); + assert_eq!(o.result, json!("false|true")); + let o = run_code(&mut s, "var l = new Socket(); l.listen(0); var r = [l.port > 0, l.poll() === null].join('|'); l.close(); r", "net.jsx"); + assert_eq!(o.result, json!("true|true")); +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 0536480c9..8bc170b1f 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; pub use color::parse_color; pub use kurbo::{Affine, BezPath}; pub use pathdata::parse_path_data; -pub use raster::{rasterize, rasterize_with}; +pub use raster::{blend_pixel, rasterize, rasterize_with}; #[derive(Debug, Clone, PartialEq)] pub enum Error { @@ -173,6 +173,88 @@ pub struct Group { /// 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)>, + /// How the group composites onto what is below it (PDF blend modes; SVG `mix-blend-mode` + /// is not read yet). + pub blend: BlendMode, + /// A soft mask (PDF `SMask` in the graphics state): the group shows where the mask's + /// luminosity (or alpha) is high. + pub mask: Option>, +} + +impl Group { + /// An empty, opaque, unclipped group. + pub fn new(name: &str) -> Group { + Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![], blend: BlendMode::Normal, mask: None } + } +} + +/// Separable and non-separable blend modes (ISO 32000-1 §11.3.5; the same formulas as the +/// W3C Compositing and Blending Level 1 specification). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BlendMode { + #[default] + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, +} + +impl BlendMode { + /// A PDF blend mode name (`/Multiply` …); unknown names are Normal. + pub fn from_pdf_name(n: &str) -> BlendMode { + use BlendMode::*; + match n { + "Multiply" => Multiply, + "Screen" => Screen, + "Overlay" => Overlay, + "Darken" => Darken, + "Lighten" => Lighten, + "ColorDodge" => ColorDodge, + "ColorBurn" => ColorBurn, + "HardLight" => HardLight, + "SoftLight" => SoftLight, + "Difference" => Difference, + "Exclusion" => Exclusion, + "Hue" => Hue, + "Saturation" => Saturation, + "Color" => Color, + "Luminosity" => Luminosity, + _ => Normal, + } + } +} + +/// A soft mask: `content` is drawn in the masked group's space; its luminosity (composited +/// over `backdrop`) or its alpha is the mask. +#[derive(Clone, Debug, PartialEq)] +pub struct SoftMask { + pub content: Group, + pub luminosity: bool, + pub backdrop: [f64; 3], +} + +/// A raster image: `width`×`height` straight RGBA8 pixels; `transform` maps pixel space (y +/// down, pixel `(0, 0)` at the top-left corner) to the parent's space. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + pub name: String, + pub transform: Affine, + pub width: u32, + pub height: u32, + pub rgba: std::sync::Arc>, + pub opacity: f64, } #[derive(Clone, Debug, PartialEq)] @@ -180,6 +262,7 @@ pub struct Group { pub enum Node { Group(Group), Shape(Shape), + Image(Image), } /// A parsed SVG document. `root.transform` maps the viewBox to `width`×`height` pixels. @@ -207,6 +290,7 @@ impl Doc { match c { Node::Group(sub) => walk(sub, m, o, out), Node::Shape(s) => out.push((m * s.transform, o * s.opacity, s)), + Node::Image(_) => {} } } } @@ -669,7 +753,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, clip: vec![] })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None })); } "svg" => { // Nested viewport. @@ -688,7 +772,15 @@ 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, clip: vec![] })); + out.push(Node::Group(Group { + name: Self::element_name(n), + transform: Affine::translate((x, y)) * vb, + opacity, + children, + clip: vec![], + blend: BlendMode::Normal, + mask: None, + })); } "use" => { let Some(target) = self.href(n) else { return }; @@ -709,11 +801,19 @@ 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, clip: vec![] })); + children.push(Node::Group(Group { + name: Self::element_name(target), + transform: vb, + opacity: o, + children: inner, + clip: vec![], + blend: BlendMode::Normal, + mask: None, + })); } else { self.node(target, &st, &mut children, depth + 1); } - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None })); } "path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => { if !st.visible { @@ -753,7 +853,15 @@ 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, clip: vec![] })); + out.push(Node::Group(Group { + name: Self::element_name(n), + transform, + opacity, + children, + clip: vec![], + blend: BlendMode::Normal, + mask: None, + })); } } } @@ -945,7 +1053,12 @@ pub fn parse(bytes: &[u8]) -> Result { p.node(c, &rst, &mut children, 1); } let transform = viewbox_transform(root, dw, dh); - Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children, clip: vec![] }, skipped: std::mem::take(&mut p.skipped) }) + Ok(Doc { + width: dw, + height: dh, + root: Group { name: "svg".into(), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None }, + skipped: std::mem::take(&mut p.skipped), + }) } #[cfg(test)] diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index de82695b1..975adc5aa 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -6,7 +6,7 @@ use effectcraft_raster::{Image, Mask}; use kurbo::{Affine, Point, Shape as _}; use rayon::prelude::*; -use crate::{Doc, FillRule, Gradient, GradientKind, Group, Node, Paint, Shape, Spread}; +use crate::{BlendMode, Doc, FillRule, Gradient, GradientKind, Group, Node, Paint, Shape, Spread}; fn mat3(a: Affine) -> Mat3 { let c = a.as_coeffs(); @@ -31,38 +31,166 @@ 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 { - draw_node(c, m, 1.0, &mut tmp); - } - over(dst, &tmp, o as f32); - } else { + let isolated = !g.clip.is_empty() || g.mask.is_some() || g.blend != BlendMode::Normal || (g.opacity < 1.0 && g.children.len() > 1); + if !isolated { for c in &g.children { draw_node(c, m, o, dst); } + return; } + // Draw the children on their own, keep what every clip path covers and the soft mask + // lets through, then composite with the group's opacity and blend mode. + 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; + } + }); + } + if let Some(sm) = &g.mask { + let mut mk = Image::new(dst.width, dst.height); + draw_group(&sm.content, m, 1.0, &mut mk); + let bc = sm.backdrop.map(|v| v as f32); + let lum = sm.luminosity; + tmp.data.par_iter_mut().zip(mk.data.par_iter()).for_each(|(p, q)| { + let k = if lum { + // The mask group composited over its backdrop colour, then its luminosity. + let c = [0, 1, 2].map(|i| q[i] + bc[i] * (1.0 - q[3])); + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] + } else { + q[3] + }; + let k = k.clamp(0.0, 1.0); + for v in p.iter_mut() { + *v *= k; + } + }); + } + if g.blend == BlendMode::Normal { + over(dst, &tmp, o as f32); + } else { + let mode = g.blend; + let k = o as f32; + dst.data.par_iter_mut().zip(tmp.data.par_iter()).for_each(|(d, s)| { + let s = [s[0] * k, s[1] * k, s[2] * k, s[3] * k]; + if s[3] > 0.0 { + *d = blend_pixel(mode, *d, s); + } + }); + } +} + +/// `src` composited onto `dst` with a blend mode (both premultiplied RGBA): +/// `co = cs·(1 − αb) + cb·(1 − αs) + αs·αb·B(cb, cs)` (ISO 32000-1 §11.3.6). +pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4]) -> [f32; 4] { + let (ab, as_) = (dst[3], src[3]); + let un = |c: [f32; 4]| if c[3] > 0.0 { [c[0] / c[3], c[1] / c[3], c[2] / c[3]] } else { [0.0; 3] }; + let (cb, cs) = (un(dst), un(src)); + let b = blend_color(mode, cb, cs); + let mut out = [0.0; 4]; + for i in 0..3 { + out[i] = src[i] * (1.0 - ab) + dst[i] * (1.0 - as_) + as_ * ab * b[i]; + } + out[3] = as_ + ab * (1.0 - as_); + out +} + +fn blend_color(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { + use BlendMode::*; + let sep = |f: fn(f32, f32) -> f32| [f(cb[0], cs[0]), f(cb[1], cs[1]), f(cb[2], cs[2])]; + fn hard(b: f32, s: f32) -> f32 { + if s <= 0.5 { + b * 2.0 * s + } else { + let t = 2.0 * s - 1.0; + b + t - b * t + } + } + match mode { + Normal => cs, + Multiply => sep(|b, s| b * s), + Screen => sep(|b, s| b + s - b * s), + Overlay => sep(|b, s| hard(s, b)), + Darken => sep(f32::min), + Lighten => sep(f32::max), + ColorDodge => sep(|b, s| { + if b <= 0.0 { + 0.0 + } else if s >= 1.0 { + 1.0 + } else { + (b / (1.0 - s)).min(1.0) + } + }), + ColorBurn => sep(|b, s| { + if b >= 1.0 { + 1.0 + } else if s <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - b) / s).min(1.0) + } + }), + HardLight => sep(hard), + SoftLight => sep(|b, s| { + if s <= 0.5 { + b - (1.0 - 2.0 * s) * b * (1.0 - b) + } else { + let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; + b + (2.0 * s - 1.0) * (d - b) + } + }), + Difference => sep(|b, s| (b - s).abs()), + Exclusion => sep(|b, s| b + s - 2.0 * b * s), + Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)), + Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)), + Color => set_lum(cs, lum(cb)), + Luminosity => set_lum(cb, lum(cs)), + } +} + +fn lum(c: [f32; 3]) -> f32 { + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] +} + +fn clip_color(c: [f32; 3]) -> [f32; 3] { + let l = lum(c); + let n = c[0].min(c[1]).min(c[2]); + let x = c[0].max(c[1]).max(c[2]); + let mut c = c; + if n < 0.0 && l - n > 0.0 { + c = c.map(|v| l + (v - l) * l / (l - n)); + } + if x > 1.0 && x - l > 0.0 { + c = c.map(|v| l + (v - l) * (1.0 - l) / (x - l)); + } + c +} + +fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] { + let d = l - lum(c); + clip_color(c.map(|v| v + d)) +} + +fn sat(c: [f32; 3]) -> f32 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) +} + +fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { + let (mx, mn) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); + if mx - mn <= 0.0 { + return [0.0; 3]; + } + c.map(|v| (v - mn) * s / (mx - mn)) } fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) { @@ -77,9 +205,73 @@ fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) { draw_shape(s, m, opacity * s.opacity, dst); } } + Node::Image(im) => draw_image(im, m, opacity * im.opacity, dst), } } +/// Draw a raster image: bilinear samples inside the image's (anti-aliased) quadrilateral. +fn draw_image(im: &crate::Image, m: Affine, opacity: f64, dst: &mut Image) { + let (iw, ih) = (im.width as usize, im.height as usize); + if iw == 0 || ih == 0 || im.rgba.len() < iw * ih * 4 || opacity <= 0.0 { + return; + } + let full = m * im.transform; + let inv = full.inverse(); + if !inv.as_coeffs().iter().all(|v| v.is_finite()) { + return; + } + let quad = kurbo::Rect::new(0.0, 0.0, iw as f64, ih as f64).to_path(0.01); + let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&quad), &mat3(full), dst.width, dst.height, effectcraft_path::FillRule::NonZero); + let w = dst.width as usize; + if w == 0 { + return; + } + let px = |x: usize, y: usize| -> [f32; 4] { + let i = (y * iw + x) * 4; + let a = im.rgba[i + 3] as f32 / 255.0; + [im.rgba[i] as f32 / 255.0 * a, im.rgba[i + 1] as f32 / 255.0 * a, im.rgba[i + 2] as f32 / 255.0 * a, a] + }; + // Pixel footprint in image pixels: box-filter heavy minification instead of aliasing. + let c = inv.as_coeffs(); + let fx = (c[0] * c[0] + c[1] * c[1]).sqrt(); + let fy = (c[2] * c[2] + c[3] * c[3]).sqrt(); + let taps = (fx.max(fy).ceil() as usize).clamp(1, 8); + let k = opacity as f32; + dst.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, d) in row.iter_mut().enumerate() { + let cv = cov.data[y * w + x]; + if cv <= 0.0 { + continue; + } + let mut acc = [0.0f32; 4]; + for ty in 0..taps { + for tx in 0..taps { + let sx = x as f64 + (tx as f64 + 0.5) / taps as f64; + let sy = y as f64 + (ty as f64 + 0.5) / taps as f64; + let p = inv * Point::new(sx, sy); + let (u, v) = ((p.x - 0.5).clamp(0.0, iw as f64 - 1.0), (p.y - 0.5).clamp(0.0, ih as f64 - 1.0)); + let (x0, y0) = (u.floor() as usize, v.floor() as usize); + let (x1, y1) = ((x0 + 1).min(iw - 1), (y0 + 1).min(ih - 1)); + let (a, b) = ((u - x0 as f64) as f32, (v - y0 as f64) as f32); + let (p00, p10, p01, p11) = (px(x0, y0), px(x1, y0), px(x0, y1), px(x1, y1)); + for i in 0..4 { + acc[i] += (p00[i] * (1.0 - a) + p10[i] * a) * (1.0 - b) + (p01[i] * (1.0 - a) + p11[i] * a) * b; + } + } + } + let n = (taps * taps) as f32; + let s = acc.map(|v| v / n * cv * k); + if s[3] <= 0.0 { + continue; + } + for i in 0..3 { + d[i] = s[i] + d[i] * (1.0 - s[3]); + } + d[3] = s[3] + d[3] * (1.0 - s[3]); + } + }); +} + /// `dst = src·k over dst` (premultiplied). fn over(dst: &mut Image, src: &Image, k: f32) { dst.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(d, s)| { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3333cdd11..f90aa7f3d 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -85,6 +85,9 @@ pub struct TextStyle { pub leading: Option, /// OpenType features (stylistic sets, figures, fractions, all small caps…). pub opentype: OpenType, + /// Variable font axis values (tag, user units): shaping (advances through HVAR / gvar + /// phantom points) and outlines use this design-space position. + pub variations: Vec<(String, f32)>, } impl Default for TextStyle { @@ -109,6 +112,7 @@ impl Default for TextStyle { script: Script::Normal, leading: None, opentype: OpenType::default(), + variations: Vec::new(), } } } @@ -194,6 +198,10 @@ impl Hash for TextStyle { 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); + for (t, v) in &self.variations { + t.hash(h); + hf(h, *v); + } } } @@ -225,6 +233,9 @@ pub struct Glyph { pub v_scale: f32, /// Index of the style run the glyph belongs to. pub run: usize, + /// The run's variable font axis values, interned ([`crate::variable::coords`]; 0 = the + /// default instance): outlines are drawn there. + pub variations: u32, } /// One laid-out line. @@ -425,7 +436,13 @@ fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> { fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize, form: Form) -> Vec { 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(); + let instance = (!style.variations.is_empty()).then(|| { + harfrust::ShaperInstance::from_variations( + &font, + style.variations.iter().map(|(t, v)| harfrust::Variation { tag: crate::variable::tag_of(t), value: *v }), + ) + }); + let shaper = data.shaper(&font).instance(instance.as_ref()).build(); let mut buf = UnicodeBuffer::new(); for &(b, _, sc) in chars { buf.add(if sc == '\t' { ' ' } else { sc }, b as u32); @@ -819,6 +836,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar h_scale: st.h_scale, v_scale: st.v_scale, run: g.run, + variations: crate::variable::intern(&st.variations), }); pen += g.adv; if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index beef03822..65232f02f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -70,7 +70,8 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option> { /// The outline of a laid-out glyph in pixels, origin on its baseline (y down), with its /// horizontal / vertical scale and faux italic. pub fn glyph_outline(g: &Glyph) -> BezPath { - let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() }; + let units = if g.variations == 0 { outline_units(g.face, g.id) } else { variable::outline_units_at(g.face, g.id, &variable::coords(g.variations)) }; + let Some(units) = units else { return BezPath::new() }; glyph_affine(g) * (*units).clone() } @@ -107,6 +108,8 @@ pub struct CharGlyph { pub face: FaceId, pub gid: u32, pub outline_xf: Affine, + /// The character's variable font axis values (its style's Variable Font Axes). + pub variations: Vec<(String, f32)>, } #[derive(Clone, Debug, Default)] @@ -162,6 +165,7 @@ pub fn text_style(s: &CharStyle) -> TextStyle { }, leading: s.leading.map(|l| l as f32), opentype: s.opentype, + variations: s.variations.clone(), } } @@ -336,6 +340,7 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { face: g.face, gid: g.id, outline_xf, + variations: variable::coords(g.variations).to_vec(), }); } } diff --git a/crates/text/src/variable.rs b/crates/text/src/variable.rs index 95fdec182..2072c294f 100644 --- a/crates/text/src/variable.rs +++ b/crates/text/src/variable.rs @@ -12,6 +12,32 @@ use skrifa::{GlyphId, MetadataProvider, Tag}; use crate::fonts::{self, FaceId}; use crate::{CharGlyph, Pen}; +/// Intern a set of axis values (0 for none), so `Copy` glyphs can carry them. +pub fn intern(coords: &[(String, f32)]) -> u32 { + if coords.is_empty() { + return 0; + } + let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(i) = t.iter().position(|c| c.as_slice() == coords) { + return i as u32 + 1; + } + t.push(Arc::new(coords.to_vec())); + t.len() as u32 +} + +/// The axis values of an interned id (empty for 0). +pub fn coords(id: u32) -> Arc> { + if id == 0 { + return Arc::new(Vec::new()); + } + table().lock().unwrap_or_else(|e| e.into_inner()).get(id as usize - 1).cloned().unwrap_or_default() +} + +fn table() -> &'static Mutex>>> { + static T: OnceLock>>>> = OnceLock::new(); + T.get_or_init(Default::default) +} + /// One variation axis of a face, in user units. #[derive(Clone, Debug, PartialEq)] pub struct FontAxis { @@ -38,7 +64,7 @@ pub fn font_axes(face: FaceId) -> Vec { } /// A tag string as a [`Tag`] (padded with spaces, at most four characters). -fn tag_of(s: &str) -> Tag { +pub(crate) fn tag_of(s: &str) -> Tag { let mut b = [b' '; 4]; for (i, c) in s.bytes().take(4).enumerate() { b[i] = c; @@ -78,14 +104,21 @@ pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Opt } /// A laid-out character's outline (same placement as [`CharGlyph::path`]) with its face's axes -/// moved by `deltas` (tag, user-unit offset from the axis default). +/// moved by `deltas` (tag, user-unit offset from the character's own axis value — its style's +/// Variable Font Axes setting, else the axis default). pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option { let axes = font_axes(g.face); - let coords: Vec<(String, f32)> = - deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (a.default + d).clamp(a.min, a.max)))).collect(); + let base = |t: &str, d: f32| g.variations.iter().find(|(x, _)| x.trim_end() == t.trim_end()).map(|(_, v)| *v).unwrap_or(d); + let mut coords: Vec<(String, f32)> = + deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (base(&a.tag, a.default) + d).clamp(a.min, a.max)))).collect(); if coords.is_empty() { return None; } + for (t, v) in &g.variations { + if !coords.iter().any(|(c, _)| c == t) { + coords.push((t.clone(), *v)); + } + } let units = outline_units_at(g.face, g.gid, &coords)?; Some(g.outline_xf * (*units).clone()) } @@ -122,4 +155,37 @@ mod tests { let s = crate::outline_units(face, gid).unwrap(); assert!((d.area() - s.area()).abs() < 1e-3); } + + #[test] + fn style_variations_change_advances_and_outlines() { + assert_eq!(intern(&[]), 0); + let id = intern(&[("wght".into(), 700.0)]); + assert_eq!(intern(&[("wght".into(), 700.0)]), id); + assert_eq!(coords(id).as_slice(), &[("wght".to_string(), 700.0)]); + let Some((face, axes)) = find_variable_face() else { + eprintln!("no variable font installed: skipped"); + return; + }; + // An axis that changes widths (wdth or wght, else the first that moves). + let a = axes.iter().find(|a| a.tag == "wdth" && a.max > a.min).or_else(|| axes.iter().find(|a| a.tag == "wght" && a.max > a.min)).unwrap_or(&axes[0]); + let info = crate::fonts::face(face).info.clone(); + let st = |v: f32| crate::TextStyle { + family: info.family.clone(), + style: info.style.clone(), + size: 100.0, + variations: vec![(a.tag.clone(), v)], + ..Default::default() + }; + let (lo, hi) = (crate::layout::measure("Hamburgefonts", &st(a.min)), crate::layout::measure("Hamburgefonts", &st(a.max))); + if crate::resolve(&info.family, &info.style).face != face { + eprintln!("variable face not reachable by family/style: skipped"); + return; + } + assert!((lo - hi).abs() > 1.0, "{} {}..{}: advances {lo} vs {hi}", a.tag, a.min, a.max); + let ink = |v: f32| { + let l = crate::layout::layout("H", &st(v), &crate::ParagraphStyle::default()); + l.glyphs.iter().map(|g| crate::glyph_outline(g).area().abs()).sum::() + }; + assert!((ink(a.min) - ink(a.max)).abs() > 1.0); + } } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 28c048374..1fb2ef790 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -44,10 +44,14 @@ pub enum PanelKind { Metadata, Progress, ContentAwareFill, + /// Window ▸ Create Nulls From Paths (our own panel). + CreateNullsFromPaths, + /// Window ▸ VR Comp Editor (our own panel). + VrCompEditor, } impl PanelKind { - pub const ALL: [PanelKind; 32] = [ + pub const ALL: [PanelKind; 34] = [ PanelKind::Project, PanelKind::EffectControls, PanelKind::Composition, @@ -80,6 +84,8 @@ impl PanelKind { PanelKind::Metadata, PanelKind::Progress, PanelKind::ContentAwareFill, + PanelKind::CreateNullsFromPaths, + PanelKind::VrCompEditor, ]; pub fn title(self) -> &'static str { match self { @@ -116,6 +122,8 @@ impl PanelKind { PanelKind::Metadata => "Metadata", PanelKind::Progress => "Progress", PanelKind::ContentAwareFill => "Content-Aware Fill", + PanelKind::CreateNullsFromPaths => "Create Nulls From Paths", + PanelKind::VrCompEditor => "VR Comp Editor", } } pub fn id(self) -> String { @@ -342,7 +350,20 @@ pub fn workspace(name: &str) -> DockNode { tabs(&[Composition, Layer, Footage], 0), right( &[Info, Preview, Audio, Align, Character, Paragraph, LumetriScopes], - &[EffectsPresets, Properties, Tracker, Wiggler, Smoother, MotionSketch, Paint, Brushes, Markers, ContentAwareFill], + &[ + EffectsPresets, + Properties, + Tracker, + Wiggler, + Smoother, + MotionSketch, + Paint, + Brushes, + Markers, + ContentAwareFill, + CreateNullsFromPaths, + VrCompEditor, + ], ), ), ), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 48587f9e1..5f1d59806 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -110,6 +110,20 @@ pub struct Playback { pub shown: u64, pub waiting: bool, pub fps: f64, + /// The Preview panel shortcut that started this preview and what it plays. + pub shortcut: effectcraft_engine::preview::PreviewShortcut, + pub plan: Option, + /// Cache Before Playback: rendering the range before the clock starts. + pub caching: bool, + /// Resolution override while playing (pixel step), `None` = the viewer's. + pub res: Option, + /// Include Overlays / Include Layer Controls. + pub overlays: bool, + pub layer_controls: bool, + /// Full Screen: the maximized panel to restore when the preview stops. + pub restore_max: Option>, + /// Audio-only previews: the frame under the audible sample (the current time stays put). + pub audio_frame: Option, } pub struct EffectcraftApp { @@ -419,6 +433,19 @@ impl EffectcraftApp { /// The render scale used by the viewer right now. pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 { + // The preview's Resolution (when not Auto) while it plays. + if self.playback.playing + && let Some(f) = self.playback.res + { + let r = match f { + 1 => state::Resolution::Full, + 2 => state::Resolution::Half, + 3 => state::Resolution::Third, + 4 => state::Resolution::Quarter, + n => state::Resolution::Custom(n.min(40) as u8), + }; + return r.scale(zoom, ppp); + } if self.playback.playing && self.ui.viewer.res == state::Resolution::Auto { // Keep previews snappy: half the displayed size, but no lower than Settings ▸ // Previews ▸ Adaptive Resolution Limit. @@ -510,64 +537,163 @@ impl EffectcraftApp { // ---------------------------------------------------------------- playback pub fn play(&mut self, now: f64) { + let sc = self.session.prefs.preview.current; + self.play_with(now, sc); + } + + /// Start the preview of a Preview panel shortcut (its range, play-from, rate, skip, + /// resolution, loop, caching, full screen and include options). + pub fn play_with(&mut self, now: f64, sc: effectcraft_engine::preview::PreviewShortcut) { let Some(c) = self.session.active_comp().cloned() else { return }; - let mut t = self.session.time(); - let (wa, wb) = c.work_area; - if t >= wb - c.frame_duration() || t < wa { - t = wa; + let preset = self.session.prefs.preview.get(sc).clone(); + let plan = effectcraft_engine::preview::plan(&preset, &c, self.session.time()); + let fr = c.frame_rate; + if plan.video { + self.session.set_time(fr.tick_of(plan.first)); + } + let restore_max = preset.full_screen.then_some(self.ui.maximized); + if preset.full_screen && self.ui.dock.contains(PanelKind::Composition) { + self.ui.maximized = Some(PanelKind::Composition); } - self.session.set_time(t); self.playback = Playback { playing: true, start_wall: now, - start_time: t, - start_frame: c.frame_rate.frame_at(t), + start_time: fr.tick_of(plan.first), + start_frame: plan.first, shown: 0, waiting: false, - fps: c.frame_rate.as_f64(), + fps: plan.fps, + shortcut: sc, + plan: Some(plan), + caching: plan.cache_first && plan.video, + res: preset.resolution.factor(preset.custom_resolution), + overlays: preset.include_overlays, + layer_controls: preset.include_layer_controls, + restore_max, + audio_frame: (!plan.video).then_some(plan.first), }; - self.start_audio(t); + if !self.playback.caching { + self.start_audio(fr.tick_of(plan.first)); + } } - /// Start audio preview from comp time `t` when the Preview panel includes audio, the comp - /// has something audible and an output device opens. + /// Start audio preview from comp time `t` when the preview includes audio at the comp's + /// frame rate, the comp has something audible and an output device opens. fn start_audio(&mut self, t: Tick) { self.audio = None; self.meter.clipped = [false; 2]; - if !self.ui.preview_audio { + let Some(pl) = self.playback.plan else { return }; + if !pl.audio { return; } let Some(cid) = self.session.active_comp_id() else { return }; let Some(c) = self.session.project.comp(cid).cloned() else { return }; + // A preview at another frame rate plays slower or faster than real time: silent. + if (pl.fps - c.frame_rate.as_f64()).abs() > 0.01 { + return; + } if !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) { return; } let out = audio::AudioOutput::from_prefs(&self.session.prefs); let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return }; let mix = self.session.prefs.audio.preview_sample_rate; - match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop, mix) { + let fr = c.frame_rate; + match audio::AudioPlayback::start(dev, self.render_source(), cid, t, fr.tick_of(pl.start), fr.tick_of(pl.end + 1), pl.looping, mix) { Ok(a) => self.audio = Some(a), Err(e) => log::warn!("audio preview: {e}"), } } pub fn stop(&mut self) { - self.playback.playing = false; + let was = std::mem::replace(&mut self.playback.playing, false); self.audio = None; + self.playback.caching = false; + if !was { + return; + } + if let Some(m) = self.playback.restore_max.take() { + self.ui.maximized = m; + } + let Some(pl) = self.playback.plan else { return }; + let move_time = self.session.prefs.preview.get(self.playback.shortcut).move_time_to_preview_time; + if !move_time { + self.session.set_time(pl.origin); + } else if let Some(f) = self.playback.audio_frame + && let Some(c) = self.session.active_comp() + { + let t = c.frame_rate.tick_of(f); + self.session.set_time(t); + } } pub fn toggle_play(&mut self, now: f64) { - if self.playback.playing { self.stop() } else { self.play(now) } + let sc = self.session.prefs.preview.current; + self.toggle_play_with(now, sc); } - /// RAM preview: advance at the comp frame rate when frames are cached; otherwise hold and - /// keep rendering ahead (the green cache bar fills, then playback runs in real time). + /// A preview shortcut pressed: start its preview, or stop the running one. While Cache + /// Before Playback is still rendering, "If caching, play cached frames" plays what is + /// cached so far instead of stopping. + pub fn toggle_play_with(&mut self, now: f64, sc: effectcraft_engine::preview::PreviewShortcut) { + if !self.playback.playing { + self.play_with(now, sc); + return; + } + if self.playback.caching && self.session.prefs.preview.get(self.playback.shortcut).play_cached_frames && self.play_cached(now) { + return; + } + self.stop(); + } + + /// Cut the caching preview down to the frames cached from its first frame on and play them. + fn play_cached(&mut self, now: f64) -> bool { + let (Some(mut pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false }; + let scale = self.viewer_shown.as_ref().map(|(_, k)| k.scale as f64 / 1000.0).unwrap_or(1.0); + let mut last = None; + let mut f = pl.first; + while f <= pl.end && self.frames.is_cached(&self.frame_key(cid, f, scale)) { + last = Some(f); + f += pl.step; + } + let Some(last) = last else { return false }; + pl.end = last; + pl.start = pl.first; + self.playback.plan = Some(pl); + self.playback.caching = false; + self.playback.start_wall = now; + self.playback.start_frame = pl.first; + let t = self.session.active_comp().map(|c| c.frame_rate.tick_of(pl.first)).unwrap_or_default(); + self.start_audio(t); + true + } + + /// The viewer shows grids, guides and safe margins (hidden while a preview without + /// Include Overlays plays). + pub fn overlays_visible(&self) -> bool { + !self.playback.playing || self.playback.overlays + } + + /// The viewer shows layer handles and paths (View ▸ Show Layer Controls, and Include Layer + /// Controls while a preview plays). + pub fn layer_controls_visible(&self) -> bool { + self.ui.viewer.show_layer_controls && (!self.playback.playing || self.playback.layer_controls) + } + + /// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold + /// and keep rendering ahead (the green cache bar fills, then playback runs in real time). fn advance_playback(&mut self, ctx: &egui::Context, scale: f64) { let Some(cid) = self.session.active_comp_id() else { return }; let Some(c) = self.session.project.comp(cid).cloned() else { return }; let now = ctx.input(|i| i.time); let fr = c.frame_rate; - let (wa, wb) = (fr.frame_at(c.work_area.0), fr.frame_at(c.work_area.1 - c.frame_duration())); + let plan = self.playback.plan.filter(|_| self.playback.playing); + let (wa, wb) = match plan { + Some(p) => (p.start, p.end), + None => (fr.frame_at(c.work_area.0), fr.frame_at(c.work_area.1 - c.frame_duration())), + }; + let looping = plan.is_none_or(|p| p.looping); + let video = plan.is_none_or(|p| p.video); // Prefetch ahead. let cur = fr.frame_at(self.session.time()); // While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few @@ -580,17 +706,53 @@ impl EffectcraftApp { (self.frames_parallelism() / 2).max(2) as i64 }; let mut queued = self.frames.inflight(); + if self.playback.caching + && let Some(p) = plan + { + // Cache Before Playback: the whole range in play order, then start the clock. + let mut all = true; + let n = (p.end - p.start) / p.step + 1; + let order: Vec = (0..n).filter_map(|k| p.frame_after(p.first, k)).collect(); + for f in order { + let key = self.frame_key(cid, f, scale); + if self.frames.is_cached(&key) { + continue; + } + all = false; + if queued < ahead as usize { + self.request_frame(cid, f, scale); + queued += 1; + } + } + self.playback.waiting = !all; + if all { + self.playback.caching = false; + self.playback.start_wall = now; + self.playback.start_frame = p.first; + self.session.set_time(fr.tick_of(p.first)); + self.start_audio(fr.tick_of(p.first)); + } + ctx.request_repaint(); + return; + } + let step = plan.map_or(1, |p| p.step); for k in 0..ahead * 3 { - if queued >= ahead as usize { + if queued >= ahead as usize || !video { break; } - let mut f = cur + k; - if f > wb { - if !self.ui.preview_loop { - break; + let f = match plan { + Some(p) => match p.frame_after(cur, k) { + Some(f) => f, + None => break, + }, + None => { + let mut f = cur + k; + if f > wb { + f = wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1)); + } + f } - f = wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1)); - } + }; let key = self.frame_key(cid, f, scale); if !self.frames.is_cached(&key) { self.request_frame(cid, f, scale); @@ -607,33 +769,61 @@ impl EffectcraftApp { if !self.playback.playing { return; } + let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; // Audio clock drives playback: show the frame under the audible sample (frames that // are not rendered yet are dropped, never delayed). if let Some(a) = &self.audio { if a.finished() { - self.session.set_time(fr.tick_of(wb)); + if video { + self.session.set_time(fr.tick_of(wb)); + } else { + self.playback.audio_frame = Some(wb); + } self.stop(); return; } - let target = audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb); - self.playback.waiting = !self.frames.is_cached(&self.frame_key(cid, target, scale)); - if target != cur { - self.session.set_time(fr.tick_of(target)); - self.playback.shown += 1; + let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb)); + if !video { + self.playback.audio_frame = Some(target); + } else { + self.playback.waiting = !self.frames.is_cached(&self.frame_key(cid, target, scale)); + if target != cur { + self.session.set_time(fr.tick_of(target)); + self.playback.shown += 1; + } } ctx.request_repaint(); return; } let elapsed = now - self.playback.start_wall; - let mut target = self.playback.start_frame + (elapsed * fr.as_f64()).floor() as i64; - if target > wb { - if self.ui.preview_loop { - target = wa + (target - wa).rem_euclid((wb - wa + 1).max(1)); - } else { - self.session.set_time(fr.tick_of(wb)); - self.stop(); - return; + let n = ((elapsed * self.playback.fps) / step as f64).floor() as i64; + let target = match plan { + Some(p) => p.frame_after(self.playback.start_frame, n), + None => { + let t = self.playback.start_frame + n; + if t <= wb { + Some(t) + } else if looping { + Some(wa + (t - wa).rem_euclid((wb - wa + 1).max(1))) + } else { + None + } } + }; + let Some(target) = target else { + if video { + self.session.set_time(fr.tick_of(wb)); + } else { + self.playback.audio_frame = Some(wb); + } + self.stop(); + return; + }; + if !video { + // Audio-only preview without an audio device: the clock runs, the time stays. + self.playback.audio_frame = Some(target); + ctx.request_repaint(); + return; } let key = self.frame_key(cid, target, scale); if self.frames.is_cached(&key) { @@ -645,8 +835,7 @@ impl EffectcraftApp { } else { // Not cached yet: hold the clock at the current frame (cache first, then play). self.playback.waiting = true; - let next = cur + 1; - let next = if next > wb { wa } else { next }; + let next = plan.and_then(|p| p.frame_after(cur, 1)).unwrap_or(if cur + 1 > wb { wa } else { cur + 1 }); if self.frames.is_cached(&self.frame_key(cid, next, scale)) { self.session.set_time(fr.tick_of(next)); } @@ -905,6 +1094,23 @@ impl EffectcraftApp { if self.session.prefs.auto_save.enabled { ctx.request_repaint_after(std::time::Duration::from_secs(30)); } + // File ▸ Watch Folder: look for new projects every 10 seconds. + if self.session.state.watch_folder.is_some() { + let key = egui::Id::new("watchFolder.lastPoll"); + let last: f64 = ctx.data(|d| d.get_temp(key)).unwrap_or(0.0); + if now - last >= 10.0 { + ctx.data_mut(|d| d.insert_temp(key, now)); + match self.session.execute("file.watchFolder.poll", serde_json::json!({})) { + Ok(r) => { + if let Some(n) = r["rendered"].as_array().map(Vec::len).filter(|n| *n > 0) { + self.ui.status = format!("Watch Folder: rendered {n} project(s)"); + } + } + Err(e) => self.ui.status = e.to_string(), + } + } + ctx.request_repaint_after(std::time::Duration::from_secs(10)); + } } /// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f3800ef6a..1cb295677 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -27,7 +27,10 @@ macro_rules! uic { /// UI-only commands (not in the AE menus; shortcuts and the command palette). pub const UI_COMMANDS: &[UiCommand] = &[ - uic!("playback.ramPreview", "Play Current Preview", [], Some("Num0")), + uic!("playback.ramPreview", "Preview (Numpad 0)", [], Some("Num0")), + uic!("playback.preview.shiftSpacebar", "Preview (Shift+Spacebar)", [], Some("Shift+Space")), + uic!("playback.preview.shiftNumpad0", "Preview (Shift+Numpad 0)", [], Some("Shift+Num0")), + uic!("playback.preview.altNumpad0", "Preview (Alt+Numpad 0)", [], Some("Alt+Num0")), uic!("playback.stop", "Stop", [], None), uic!("view.fit", "Fit", [], Some("Shift+/")), uic!("view.actualSize", "100%", [], Some("/")), @@ -290,7 +293,12 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V let timing = |app: &mut EffectcraftApp, op: &str| app.session.execute("layer.timing", json!({"op": op})).map_err(|e| e.to_string()); match id { "playback.ramPreview" => { - app.toggle_play(now); + app.toggle_play_with(now, effectcraft_engine::preview::PreviewShortcut::Numpad0); + return Ok(json!({"playing": app.playback.playing})); + } + "playback.preview.shiftSpacebar" | "playback.preview.shiftNumpad0" | "playback.preview.altNumpad0" => { + let sc = effectcraft_engine::preview::PreviewShortcut::parse(id.trim_start_matches("playback.preview.")).ok_or("unknown preview shortcut")?; + app.toggle_play_with(now, sc); return Ok(json!({"playing": app.playback.playing})); } "playback.stop" => { @@ -534,8 +542,14 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu Value::Null } "playback.toggle" => { - app.toggle_play(now); - json!({"playing": app.playback.playing}) + // The play button and Spacebar: `{shortcut?}` picks which Preview panel shortcut's + // options to play with (default Spacebar). + let sc = match p.get("shortcut").and_then(Value::as_str) { + Some(k) => effectcraft_engine::preview::PreviewShortcut::parse(k).ok_or_else(|| format!("unknown preview shortcut `{k}`"))?, + None => effectcraft_engine::preview::PreviewShortcut::Spacebar, + }; + app.toggle_play_with(now, sc); + json!({"playing": app.playback.playing, "shortcut": sc.id()}) } "playback.cacheWhenIdle" => { let r = toggle(&mut app.ui.cache_when_idle, &p); @@ -543,8 +557,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu r } "playback.audio" => { - let r = toggle(&mut app.ui.preview_audio, &p); - if !app.ui.preview_audio { + // Include Audio of the shortcut shown in the Preview panel. + let mut on = app.session.prefs.preview.active().include_audio; + let r = toggle(&mut on, &p); + app.session.execute("playback.settings.set", json!({"includeAudio": on})).map_err(|e| e.to_string())?; + if !on { app.audio = None; } r @@ -801,6 +818,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu crate::panels::dialogs::open_form(app, "file.interpretFootage", &json!({})); Value::Null } + // Help ▸ In-App Tutorials: the Home screen's Learn tab. + "help.inAppTutorials" => { + app.ui.start_screen = true; + app.ui.home_learn = true; + Value::Null + } "effect.manage" | "anim.browsePresets" => { app.show_panel(PanelKind::EffectsPresets); Value::Null @@ -831,6 +854,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Save("Untitled Project.ecproj")), "file.saveAs" | "file.saveCopy" => ("path", Ask::Save("Untitled Project.ecproj")), "comp.saveFrameAs" => ("path", Ask::Save("Frame.png")), + "comp.saveFrameAsPsd" => ("path", Ask::Save("Frame.psd")), + "comp.saveFrameAsExr" => ("path", Ask::Save("Frame.exr")), + "file.watchFolder" if params.get("stop").is_none() => ("folder", Ask::Save("Watch Folder")), "anim.savePreset" => ("path", Ask::Save("Preset.ecpreset")), "anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])), "view.exportGuides" => ("path", Ask::Save("Guides.json")), @@ -955,7 +981,7 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option "view.snapToGrid" => Some(v.snap_grid), "view.layerControls" => Some(v.show_layer_controls), "playback.cacheWhenIdle" => Some(app.ui.cache_when_idle), - "playback.audio" => Some(app.ui.preview_audio), + "playback.audio" => Some(app.session.prefs.preview.active().include_audio), "view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" => { Some(v.res.label().eq_ignore_ascii_case(e.command.trim_start_matches("view.res."))) } diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 42c3aacfc..3d2b61806 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -294,7 +294,7 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); v.res.label().hash(&mut h); - (app.ui.cache_when_idle, app.ui.preview_audio, &app.ui.workspace, app.dialog.is_some()).hash(&mut h); + (app.ui.cache_when_idle, app.session.prefs.preview.active().include_audio, &app.ui.workspace, app.dialog.is_some()).hash(&mut h); h.finish() } diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index f4c565c52..b22263b9d 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -4,7 +4,8 @@ //! be exposed) and the template name. //! - The controls: drag a property name from the timeline onto the list (or Animation ▸ Add //! Property to Essential Graphics); each shows as a text / colour / slider / checkbox / point / -//! angle / dropdown control editing the source property; rename by double-clicking the name; +//! angle / dropdown control editing the source property (Add Selected As: Source Text as a font +//! control, Scale as one uniform slider); rename by double-clicking the name; //! reorder with ▲▼, remove with ✕; groups and comments from Add Formatting; Media //! Replacement for the selected footage layer. //! - **Export Template…** writes an open `.ectemplate`. @@ -134,6 +135,19 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) { } }); app.auto.add("essential.addFormatting", r.response.rect, "Add Formatting"); + // The selected property as a Font control (Source Text) or a uniform Scale slider. + let has_sel = !app.session.state.selected_props.is_empty(); + let r = ui.add_enabled_ui(has_sel, |ui| { + ui.menu_button("Add Selected As", |ui| { + for (label, as_type) in [("Font Control", "font"), ("Uniform Scale", "scale")] { + if ui.button(label).clicked() { + actions.push(("essential.addProperty".into(), json!({"as": as_type}))); + ui.close(); + } + } + }) + }); + app.auto.add("essential.addAs", r.response.rect, "Add Selected As"); let footage_layer = app.session.active_comp().and_then(|c| { app.session.state.selected_layers.iter().filter_map(|l| c.layer(*l)).find(|l| matches!(l.source, LayerSource::Footage { .. })).map(|l| l.id.0) }); @@ -184,7 +198,7 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg match src { Some((layer, lt, p)) => { let v = p.value_at(lt); - if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), &p, &v) { + if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), essential::effective_type(c, &p), &p, &v) { actions.push(( "prop.set".into(), json!({"comp": cid.0, "layer": layer, "prop": p.uid, "value": nv, "merge": format!("eg-{}", c.id)}), @@ -245,8 +259,8 @@ fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCo } /// The editor for a control's value; returns the new value (JSON for `prop.set`). -fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Property, v: &KV) -> Option { - let ty = essential::control_type(p)?; +fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Option, p: &Property, v: &KV) -> Option { + let ty = ty?; let mut out = None; let rect; match ty { @@ -319,6 +333,48 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Pro } rect = r.rect; } + ControlType::Font => { + // Family (from the installed fonts), style and size; the text stays as it is. + let KV::Text(doc) = v else { return None }; + let fams = effectcraft_engine::text::fonts::families(); + let mut d = (**doc).clone(); + let mut changed = false; + let r1 = egui::ComboBox::from_id_salt(("eg-font", auto)).selected_text(&d.font).width(110.0).show_ui(ui, |ui| { + for (f, _) in &fams { + if ui.selectable_label(*f == d.font, f).clicked() { + d.font = f.clone(); + changed = true; + } + } + }); + let styles: Vec = fams.iter().find(|(f, _)| *f == d.font).map(|(_, st)| st.clone()).unwrap_or_default(); + let r2 = egui::ComboBox::from_id_salt(("eg-style", auto)).selected_text(&d.style).width(70.0).show_ui(ui, |ui| { + for st in &styles { + if ui.selectable_label(*st == d.style, st).clicked() { + d.style = st.clone(); + changed = true; + } + } + }); + let r3 = ui.add(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1)); + app.auto.add(&format!("{auto}.font"), r1.response.rect, "Font"); + app.auto.add(&format!("{auto}.style"), r2.response.rect, "Style"); + app.auto.add(&format!("{auto}.size"), r3.rect, "Size"); + if changed || r3.changed() { + out = serde_json::to_value(&d).ok(); + } + rect = r1.response.rect.union(r3.rect); + } + ControlType::Scale => { + // One percentage for every axis. + let comps = v.components(); + let mut x = comps.first().copied().unwrap_or(100.0); + let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1)); + if r.changed() { + out = Some(json!(vec![x; comps.len().max(1)])); + } + rect = r.rect; + } ControlType::Slider | ControlType::Media => { let mut x = v.as_f64(); let r = match p.ui { @@ -389,7 +445,7 @@ fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions) } _ => { let v = p.value_at(lt); - if let Some(nv) = value_widget(app, ui, &auto, &p, &v) { + if let Some(nv) = value_widget(app, ui, &auto, essential::effective_type(c, &p), &p, &v) { actions.push(( "essential.set".into(), json!({"layer": layer.id.0, "control": c.id, "value": nv, "merge": format!("egi-{}-{}", layer.id.0, c.id)}), diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 8d2df3474..2bbe2f437 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -118,6 +118,16 @@ fn is_layered(p: &Value) -> bool { } } +/// The import includes a PDF or Illustrator file (which may have several pages). +fn is_pdf(p: &Value) -> bool { + let pdf = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("pdf" | "ai"))); + match p.get("paths").or(p.get("path")) { + Some(Value::Array(a)) => a.iter().any(pdf), + Some(v) => pdf(v), + None => false, + } +} + pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let s = &app.session; let comp = s.active_comp(); @@ -136,6 +146,14 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { 1, )], ), + // PDF / Illustrator files: Import Kind and the page. + "file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => ( + "Import PDF / Illustrator File".into(), + vec![ + Field::choice("importAs", "Import Kind", &[("Footage", json!("footage")), ("Composition", json!("composition"))], 0), + Field::num("page", "Page", 1.0), + ], + ), // Puppet tool ▸ Record Options. "puppet.recordOptions" if !has(p, &["speed", "smoothing", "useDraftDeformation", "showMesh"]) => { let o = &s.state.puppet; diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index ea12591c0..7b750be7a 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -13,8 +13,11 @@ pub fn placeholder(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, p: P ui.painter().text(rect.center(), Align2::CENTER_CENTER, format!("{} — coming soon", p.title()), Tokens::ui(12.0), t.text_faint); } -/// Preview panel: transport controls and RAM preview options. +/// Preview panel: transport controls, the shortcut whose options are shown, and that +/// shortcut's options (each Preview shortcut keeps its own; see `effectcraft_engine::preview`). +/// Every change runs `playback.settings.set`. pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + use effectcraft_engine::preview::{PlayFrom, PreviewRange, PreviewResolution, PreviewShortcut}; let t = app.tokens; let ctx = ui.ctx().clone(); let p = ui.painter().with_clip_rect(rect); @@ -29,57 +32,248 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { (Icon::StepFwd, "time.nextFrame", "Next Frame"), (Icon::Last, "time.end", "Last Frame"), ]; + let current = app.session.prefs.preview.current; for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0)); let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon }; if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tip).clicked() { - let _ = crate::menus::invoke(app, &ctx, cmd, json!({})); + // The play button plays with the options of the shortcut shown below. + let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) }; + let _ = crate::menus::invoke(app, &ctx, cmd, params); } app.auto.add(&format!("preview.{}", tip.replace([' ', '/'], "")), r, tip); x += bw + 4.0; } - let mut yy = y + 44.0; - let row = |p: &egui::Painter, yy: f32, k: &str, v: &str| { - p.text(pos2(rect.min.x + 12.0, yy), Align2::LEFT_CENTER, k, Tokens::ui(12.0), t.text_dim); - p.text(pos2(rect.min.x + 130.0, yy), Align2::LEFT_CENTER, v, Tokens::ui(12.0), t.text); + let o = app.session.prefs.preview.get(current).clone(); + let mut changes: Vec = vec![]; + let x0 = rect.min.x + 12.0; + let xv = rect.min.x + 92.0; + let wv = (rect.max.x - xv - 12.0).clamp(80.0, 240.0); + let label = |yy: f32, k: &str| { + p.text(pos2(x0, yy), Align2::LEFT_CENTER, k, Tokens::ui(12.0), t.text_dim); }; - row(&p, yy, "Shortcut", "Spacebar"); - yy += 22.0; - let lr = Rect::from_min_size(pos2(rect.min.x + 10.0, yy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, lr, app.ui.preview_loop, &t, egui::Id::new("pv-loop")).clicked() { - app.ui.preview_loop = !app.ui.preview_loop; + let mut yy = y + 46.0; + + // Shortcut. + label(yy, "Shortcut"); + let names: Vec = PreviewShortcut::ALL.iter().map(|s| s.label().to_string()).collect(); + if let Some(i) = dropdown_row( + app, + ui, + Rect::from_min_size(pos2(xv, yy - 10.0), vec2(wv, 20.0)), + "shortcut", + current.label(), + &names, + PreviewShortcut::ALL.iter().position(|s| *s == current), + ) { + let s = PreviewShortcut::ALL[i]; + changes.push(json!({"shortcut": s.id(), "current": s.id()})); } - app.auto.add("preview.loop", lr, "Loop"); - p.text(pos2(lr.max.x + 6.0, yy), Align2::LEFT_CENTER, "Loop", Tokens::ui(12.0), t.text); - let ar = Rect::from_min_size(pos2(rect.min.x + 90.0, yy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, ar, app.ui.preview_audio, &t, egui::Id::new("pv-audio")).clicked() { - let _ = crate::menus::invoke(app, &ctx, "playback.audio", json!({})); - } - app.auto.add("preview.audio", ar, "Include Audio"); - p.text(pos2(ar.max.x + 6.0, yy), Align2::LEFT_CENTER, "Include Audio", Tokens::ui(12.0), t.text); - yy += 24.0; - row(&p, yy, "Range", "Work Area Extended By Current Time"); - yy += 22.0; - row(&p, yy, "Play From", "Current Time"); - yy += 22.0; - let comp = app.session.active_comp().cloned(); - row(&p, yy, "Frame Rate", &comp.as_ref().map(|c| format!("({:.2})", c.frame_rate.as_f64())).unwrap_or_default()); - yy += 22.0; - row(&p, yy, "Resolution", app.ui.viewer.res.label()); yy += 26.0; - if let Some(c) = comp { + + // Include Video / Audio / Overlays / Layer Controls, Loop, Cache Before Playback. + let checks: [(&str, &str, bool); 6] = [ + ("includeVideo", "Video", o.include_video), + ("includeAudio", "Audio", o.include_audio), + ("includeOverlays", "Overlays", o.include_overlays), + ("includeLayerControls", "Layer Controls", o.include_layer_controls), + ("loop", "Loop", o.loop_), + ("cacheBeforePlayback", "Cache Before Playback", o.cache_before_playback), + ]; + for (i, (key, text, on)) in checks.into_iter().enumerate() { + let col = if i < 4 { i % 2 } else { 0 }; + let row = if i < 4 { i / 2 } else { i - 2 }; + let cx = x0 + col as f32 * 110.0 - 2.0; + let cy = yy + row as f32 * 22.0; + if check_row(app, ui, &p, pos2(cx, cy), key, text, on) { + changes.push(json!({"shortcut": current.id(), key: !on})); + } + } + yy += 4.0 * 22.0 + 6.0; + + // Range (and the pre/post-roll of Play Around Current Time). + label(yy, "Range"); + let ranges: Vec = PreviewRange::ALL.iter().map(|r| r.label().to_string()).collect(); + let range_ids = ["workArea", "workAreaExtended", "entireDuration", "aroundCurrentTime"]; + if let Some(i) = dropdown_row( + app, + ui, + Rect::from_min_size(pos2(xv, yy - 10.0), vec2(wv, 20.0)), + "range", + o.range.label(), + &ranges, + PreviewRange::ALL.iter().position(|r| *r == o.range), + ) { + changes.push(json!({"shortcut": current.id(), "range": range_ids[i]})); + } + yy += 24.0; + if o.range == PreviewRange::AroundCurrentTime { + for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() { + let lx = xv + k as f32 * 100.0; + p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::new(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t); + app.auto.add(&format!("preview.{key}"), r, text); + if let Some(nv) = nv { + changes.push(json!({"shortcut": current.id(), key: nv})); + } + } + yy += 22.0; + } + + // Play From. + label(yy, "Play From"); + let froms: Vec = PlayFrom::ALL.iter().map(|f| f.label().to_string()).collect(); + if let Some(i) = dropdown_row( + app, + ui, + Rect::from_min_size(pos2(xv, yy - 10.0), vec2(wv, 20.0)), + "playFrom", + o.play_from.label(), + &froms, + PlayFrom::ALL.iter().position(|f| *f == o.play_from), + ) { + changes.push(json!({"shortcut": current.id(), "playFrom": (["rangeStart", "currentTime"][i])})); + } + yy += 26.0; + + // Frame Rate, Skip, Resolution. + let comp_fps = app.session.active_comp().map(|c| c.frame_rate.as_f64()); + label(yy, "Frame Rate"); + const RATES: [f64; 12] = [8.0, 10.0, 12.0, 12.5, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0]; + let auto = format!("({:.2}) Auto", comp_fps.unwrap_or(0.0)); + let mut rates = vec![auto.clone()]; + rates.extend(RATES.iter().map(|r| fmt_rate(*r))); + let rate_text = o.frame_rate.map(fmt_rate).unwrap_or(auto); + let rate_cur = match o.frame_rate { + None => Some(0), + Some(r) => RATES.iter().position(|x| (x - r).abs() < 1e-3).map(|i| i + 1), + }; + if let Some(i) = dropdown_row(app, ui, Rect::from_min_size(pos2(xv, yy - 10.0), vec2(96.0, 20.0)), "frameRate", &rate_text, &rates, rate_cur) { + let v = if i == 0 { json!("auto") } else { json!(RATES[i - 1]) }; + changes.push(json!({"shortcut": current.id(), "frameRate": v})); + } + let sx = xv + 106.0; + p.text(pos2(sx, yy), Align2::LEFT_CENTER, "Skip", Tokens::ui(12.0), t.text_dim); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::new("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t); + app.auto.add("preview.skip", r, "Skip"); + if let Some(nv) = nv { + changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32})); + } + yy += 24.0; + label(yy, "Resolution"); + let ress: Vec = PreviewResolution::ALL.iter().map(|r| r.label().to_string()).collect(); + let res_ids = ["auto", "full", "half", "third", "quarter", "custom"]; + if let Some(i) = dropdown_row( + app, + ui, + Rect::from_min_size(pos2(xv, yy - 10.0), vec2(96.0, 20.0)), + "resolution", + o.resolution.label(), + &ress, + PreviewResolution::ALL.iter().position(|r| *r == o.resolution), + ) { + changes.push(json!({"shortcut": current.id(), "resolution": res_ids[i]})); + } + if o.resolution == PreviewResolution::Custom { + let (r, nv, _) = + widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::new("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t); + app.auto.add("preview.customResolution", r, "Custom Resolution"); + if let Some(nv) = nv { + changes.push(json!({"shortcut": current.id(), "customResolution": nv.round() as u32})); + } + } + yy += 24.0; + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", "Full Screen", o.full_screen) { + changes.push(json!({"shortcut": current.id(), "fullScreen": !o.full_screen})); + } + yy += 24.0; + + // What stopping does. + p.text(pos2(x0, yy), Align2::LEFT_CENTER, format!("On ({}) stop:", current.label()), Tokens::ui(11.5), t.text_dim); + yy += 20.0; + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", "If caching, play cached frames", o.play_cached_frames) { + changes.push(json!({"shortcut": current.id(), "playCachedFrames": !o.play_cached_frames})); + } + yy += 22.0; + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "moveTimeToPreviewTime", "Move time to preview time", o.move_time_to_preview_time) { + changes.push(json!({"shortcut": current.id(), "moveTimeToPreviewTime": !o.move_time_to_preview_time})); + } + yy += 24.0; + for c in changes { + if let Err(e) = app.session.execute("playback.settings.set", c) { + app.ui.status = e.to_string(); + } + } + + // Cache bar over the preview range. + if let Some(c) = app.session.active_comp().cloned() { let cid = app.session.active_comp_id().map(|i| i.0).unwrap_or(0); let scale = app.viewer_shown.as_ref().map(|(_, k)| k.scale).unwrap_or(1000); - let cached = app.frames.cached_frames(app.session.revision, cid, scale).len(); - let total = c.frame_rate.frame_at(c.work_area.1) - c.frame_rate.frame_at(c.work_area.0); + let pl = match app.playback.plan.filter(|_| app.playback.playing) { + Some(pl) => pl, + None => effectcraft_engine::preview::plan(&o, &c, app.session.time()), + }; + let cached_set = app.frames.cached_frames(app.session.revision, cid, scale); + let total = pl.frames().count(); + let cached = pl.frames().filter(|f| cached_set.binary_search(f).is_ok()).count(); let bar = Rect::from_min_size(pos2(rect.min.x + 12.0, yy), vec2(rect.width() - 24.0, 6.0)); p.rect_filled(bar, 3.0, t.field_bg); let f = (cached as f32 / total.max(1) as f32).min(1.0); p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * f, bar.height())), 3.0, t.cache_green); - p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached"), Tokens::ui(11.0), t.text_faint); + let status = if app.playback.playing && app.playback.caching { " — caching before playback" } else { "" }; + p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached{status}"), Tokens::ui(11.0), t.text_faint); } } +fn fmt_rate(r: f64) -> String { + if (r - r.round()).abs() < 1e-6 { format!("{r:.0}") } else { format!("{r}") } +} + +/// A checkbox with its label; returns true when clicked. +fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool { + let t = app.tokens; + let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0)); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).clicked(); + p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); + app.auto.add(&format!("preview.{key}"), r, text); + clicked +} + +/// A dropdown with its popup list (automation ids `preview.` and `preview..`); +/// returns the chosen index. +fn dropdown_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, key: &str, text: &str, items: &[String], current: Option) -> Option { + let t = app.tokens; + let pid = egui::Id::new(("pv-pop", key)); + if widgets::dropdown(ui, r, text, &t, pid.with("btn")).clicked() { + let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false)); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + app.auto.add(&format!("preview.{key}"), r, text); + let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false)); + if !open { + return None; + } + let mut chosen = None; + let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(r.left_bottom() + vec2(0.0, 2.0)).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(r.width().max(140.0)); + for (i, label) in items.iter().enumerate() { + let resp = ui.selectable_label(current == Some(i), label.as_str()); + app.auto.add(&format!("preview.{key}.{i}"), resp.rect, label); + if resp.clicked() { + chosen = Some(i); + } + } + }); + }); + let outside = + ui.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer() && !r.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default()); + if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(pid.with("open"), false)); + } + chosen +} + /// Audio panel: L/R VU meters (dBFS, 0 to -48) with peak hold and clip indicators, fed by the /// audio preview; the selected layer's Audio Levels on the right. pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d82c6d972..8ebd6ad30 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -28,6 +28,7 @@ pub mod media_panels; pub mod misc; pub mod paint_panels; pub mod panel_kit; +pub mod path_vr_panels; pub mod precomp; pub mod project; pub mod properties; @@ -117,6 +118,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::Metadata => media_panels::metadata(app, ui, rect), PanelKind::Progress => media_panels::progress(app, ui, rect), PanelKind::ContentAwareFill => content_fill_panel::show(app, ui, rect), + PanelKind::CreateNullsFromPaths => path_vr_panels::create_nulls(app, ui, rect), + PanelKind::VrCompEditor => path_vr_panels::vr_editor(app, ui, rect), } } diff --git a/crates/ui-egui/src/panels/path_vr_panels.rs b/crates/ui-egui/src/panels/path_vr_panels.rs new file mode 100644 index 000000000..1fc5b808a --- /dev/null +++ b/crates/ui-egui/src/panels/path_vr_panels.rs @@ -0,0 +1,123 @@ +//! Two built-in panels After Effects ships as scripts, written from scratch as panels: +//! +//! - **Create Nulls From Paths** (Window ▸ Create Nulls From Paths): with a mask or shape path (or +//! a layer that has one) selected, Points Follow Nulls / Nulls Follow Points / Trace Path +//! (`paths.*` commands; Trace Path can loop). +//! - **VR Comp Editor** (Window ▸ VR Comp Editor): the project's VR environments (Composition ▸ VR +//! ▸ Create VR Environment); pick one and edit its 360 view's camera orientation (pan, tilt, +//! roll) with `comp.vr.setView`; open its output. +//! +//! Automation ids: `createNulls.pointsFollowNulls`, `createNulls.nullsFollowPoints`, +//! `createNulls.tracePath`, `createNulls.loop`, `vrEditor.env.`, `vrEditor.pan`, +//! `vrEditor.tilt`, `vrEditor.roll`, `vrEditor.reset`, `vrEditor.open`. + +use egui::{Rect, RichText}; +use serde_json::{Value, json}; + +use crate::EffectcraftApp; + +fn run(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, Value)>) { + for (id, p) in actions { + match crate::menus::invoke(app, ctx, id, p) { + Ok(_) => {} + Err(e) => app.ui.status = e, + } + } +} + +fn area(ui: &mut egui::Ui, rect: Rect) -> egui::Ui { + ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(10.0)).layout(egui::Layout::top_down(egui::Align::Min))) +} + +/// Window ▸ Create Nulls From Paths. +pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let mut ui = area(ui, rect); + let loop_id = egui::Id::new("createNulls.loop"); + let mut looping: bool = ui.data(|d| d.get_temp(loop_id)).unwrap_or(false); + let ready = app.session.is_enabled("paths.nullsFollowPoints") && !app.session.state.selected_layers.is_empty(); + ui.label(RichText::new("Select a layer with a mask or shape path (or the path property).").color(t.text_dim).small()); + ui.add_space(6.0); + let mut acts = vec![]; + for (id, label, tip) in [ + ("paths.pointsFollowNulls", "Points Follow Nulls", "A null on each vertex; move the nulls to reshape the path"), + ("paths.nullsFollowPoints", "Nulls Follow Points", "A null on each vertex that follows the (animated) path"), + ("paths.tracePath", "Trace Path", "A null moving along the path, driven by its Progress slider"), + ] { + let r = ui.add_enabled(ready, egui::Button::new(label).min_size(egui::vec2(170.0, 24.0))).on_hover_text(tip); + let auto = format!("createNulls.{}", id.trim_start_matches("paths.")); + app.auto.add(&auto, r.rect, label); + if r.clicked() { + acts.push((id, if id == "paths.tracePath" { json!({"loop": looping}) } else { json!({}) })); + } + } + let r = ui.checkbox(&mut looping, "Loop (Trace Path)"); + app.auto.add("createNulls.loop", r.rect, "Loop"); + ui.data_mut(|d| d.insert_temp(loop_id, looping)); + run(app, &ctx, acts); +} + +/// Window ▸ VR Comp Editor. +pub fn vr_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let mut ui = area(ui, rect); + let envs = app.session.execute("comp.vr.environments", json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + if envs.is_empty() { + ui.label(RichText::new("No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…").color(t.text_faint)); + return; + } + let sel_id = egui::Id::new("vrEditor.selected"); + let active = app.session.state.active_comp.map(|c| c.0); + let mut sel: u64 = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0); + // Default: the active comp's environment, else the first. + let owns = |e: &Value, c: u64| e["output"] == c || e["cubeMap"] == c || e["faces"].as_array().is_some_and(|f| f.iter().any(|x| x["comp"] == c)); + if !envs.iter().any(|e| e["output"] == sel) { + sel = active.and_then(|c| envs.iter().find(|e| owns(e, c))).or(envs.first()).and_then(|e| e["output"].as_u64()).unwrap_or(0); + } + let mut acts = vec![]; + ui.label(RichText::new("VR Environments").strong()); + for e in &envs { + let out = e["output"].as_u64().unwrap_or(0); + let r = ui.selectable_label(out == sel, e["name"].as_str().unwrap_or("")); + app.auto.add(&format!("vrEditor.env.{out}"), r.rect, e["name"].as_str().unwrap_or("")); + if r.clicked() { + sel = out; + } + } + ui.data_mut(|d| d.insert_temp(sel_id, sel)); + let Some(env) = envs.iter().find(|e| e["output"] == sel) else { return }; + ui.separator(); + ui.label(RichText::new("360 View Orientation").strong()); + let view: Vec = env["view"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let signed = |a: f64| if a > 180.0 { a - 360.0 } else { a }; + let (mut tilt, mut pan, mut roll) = + (signed(view.first().copied().unwrap_or(0.0)), signed(view.get(1).copied().unwrap_or(0.0)), signed(view.get(2).copied().unwrap_or(0.0))); + let mut changed = false; + for (label, auto, v) in [("Pan", "vrEditor.pan", &mut pan), ("Tilt", "vrEditor.tilt", &mut tilt), ("Roll", "vrEditor.roll", &mut roll)] { + ui.horizontal(|ui| { + ui.label(label); + let r = ui.add(egui::Slider::new(v, -180.0..=180.0).suffix("°").max_decimals(1)); + app.auto.add(auto, r.rect, label); + changed |= r.changed(); + }); + } + if changed { + acts.push(("comp.vr.setView", json!({"comp": sel, "pan": pan, "tilt": tilt, "roll": roll}))); + } + ui.horizontal(|ui| { + let r = ui.button("Reset"); + app.auto.add("vrEditor.reset", r.rect, "Reset"); + if r.clicked() { + acts.push(("comp.vr.setView", json!({"comp": sel, "orientation": [0.0, 0.0, 0.0]}))); + } + let r = ui.button("Open Output"); + app.auto.add("vrEditor.open", r.rect, "Open Output"); + if r.clicked() { + acts.push(("comp.open", json!({"comp": sel}))); + } + }); + ui.label(RichText::new("All six face cameras turn together; the scene is not changed.").color(t.text_faint).small()); + run(app, &ctx, acts); +} diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 580e287b4..ddf3d35ef 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -3,9 +3,10 @@ //! computed; what the user does goes back through the `scriptui.click` / `scriptui.set` / //! `scriptui.close` commands, which run the script's handlers. Every control registers an //! automation id `scriptui..` (and `scriptui..` when the script -//! named it). +//! named it). Controls with an `onDraw` handler are painted from their draw list; edit texts and +//! sliders send live `changing` updates (onChanging) while typing / dragging. -use effectcraft_engine::scriptui::{ScriptWindow, Widget, WidgetKind, WindowKind, layout}; +use effectcraft_engine::scriptui::{DrawOp, PathSeg, ScriptWindow, Widget, WidgetKind, WindowKind, layout}; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2}; use serde_json::{Value, json}; @@ -16,6 +17,8 @@ use crate::theme::Tokens; enum Act { Click(u32, u32), Set(u32, u32, Value), + /// A live update (each keystroke / slider step): fires onChanging. + Changing(u32, u32, Value), Close(u32), } @@ -78,6 +81,27 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, register(app, win, w, rect); let id = egui::Id::new(("sui", win, w.id)); let tip = |r: egui::Response| if w.help_tip.is_empty() { r } else { r.on_hover_text(&w.help_tip) }; + // onDraw: the script paints the control. Without drawOSControl() its paint replaces the + // default look (buttons and toggles still respond to clicks); with it, it goes on top. + let custom = w.handlers.iter().any(|h| h == "onDraw"); + let os = w.draw.iter().any(|d| matches!(d, DrawOp::Os)); + if custom && !os { + if w.kind.is_container() { + paint_draw(ui, rect, &w.draw, &t); + draw_children(app, ui, origin, win, w, acts); + return; + } + let clickable = matches!(w.kind, WidgetKind::Button | WidgetKind::IconButton | WidgetKind::Checkbox | WidgetKind::RadioButton); + let r = ui.interact(rect, id, if clickable && w.enabled { Sense::click() } else { Sense::hover() }); + paint_draw(ui, rect, &w.draw, &t); + if tip(r).clicked() && clickable { + acts.push(Act::Click(win, w.id)); + } + return; + } + if custom && w.kind.is_container() { + paint_draw(ui, rect, &w.draw, &t); + } match w.kind { WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts), WidgetKind::Panel => { @@ -112,8 +136,16 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, let te = te.id(id).interactive(!w.read_only).font(Tokens::ui(12.0)); let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, te)).inner; let r = tip(r); - if r.lost_focus() && buf != w.text { + // Live updates already sent the text: the commit still runs onChange. + let dirty_id = id.with("dirty"); + let dirty = ui.data(|d| d.get_temp::(dirty_id)).unwrap_or(false); + if r.lost_focus() && (buf != w.text || dirty) { acts.push(Act::Set(win, w.id, json!(buf))); + ui.data_mut(|d| d.remove::(dirty_id)); + } else if r.changed() && w.handlers.iter().any(|h| h == "onChanging") { + // Each keystroke reaches the script's onChanging (onChange runs on commit). + acts.push(Act::Changing(win, w.id, json!(buf))); + ui.data_mut(|d| d.insert_temp(dirty_id, true)); } ui.data_mut(|d| d.insert_temp(id, buf)); } @@ -145,6 +177,10 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, .inner; let r = tip(r); if r.dragged() { + // Live: each step of the drag reaches the script (onChanging). + if r.changed() { + acts.push(Act::Changing(win, w.id, json!(v))); + } ui.data_mut(|d| d.insert_temp(id, v)); } else { ui.data_mut(|d| d.remove::(id)); @@ -203,6 +239,45 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); } } + if custom && !w.kind.is_container() { + paint_draw(ui, rect, &w.draw, &t); + } +} + +/// Paint an onDraw draw list in `rect` (control coordinates → screen). +fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) { + let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); + let col = |c: &Option<[f32; 4]>, theme: Color32| match c { + Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(), + None => theme, + }; + let at = |x: f64, y: f64| rect.min + vec2(x as f32, y as f32); + for op in ops { + match op { + DrawOp::Fill { color, path } => { + let c = col(color, t.field_bg); + for (pts, _) in PathSeg::polylines(path, 48) { + let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); + p.add(egui::Shape::convex_polygon(pts, c, Stroke::NONE)); + } + } + DrawOp::Stroke { color, width, path } => { + let s = Stroke::new(*width as f32, col(color, t.text)); + for (pts, closed) in PathSeg::polylines(path, 48) { + let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); + p.add(if closed { egui::Shape::closed_line(pts, s) } else { egui::Shape::line(pts, s) }); + } + } + DrawOp::Text { text, color, x, y, size, .. } => { + p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), col(color, t.text)); + } + DrawOp::Image { x, y, w, h } => { + let r = Rect::from_min_size(at(*x, *y), vec2(*w as f32, *h as f32)); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + DrawOp::Os => {} + } + } } /// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds. @@ -216,6 +291,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { let (cmd, p) = match a { Act::Click(w, id) => ("scriptui.click", json!({"window": w, "widget": id})), Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})), + Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})), Act::Close(w) => ("scriptui.close", json!({"window": w})), }; match crate::menus::invoke(app, ctx, cmd, p) { diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 81b9d0771..782cb5994 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -7,7 +7,6 @@ use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; -use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -257,6 +256,29 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text(pos2(cr.max.x + 6.0, y + 7.0), Align2::LEFT_CENTER, label, Tokens::ui(11.0), t.text_dim); } } + // Variable Font Axes: one value per axis of a variable font (`character.axis.`). + let face = effectcraft_engine::text::resolve(&doc.font, &doc.style).face; + let axes = effectcraft_engine::text::variable::font_axes(face); + if !axes.is_empty() { + y += 26.0; + p.text(pos2(x0, y + 7.0), Align2::LEFT_CENTER, "Variable Font Axes", Tokens::semibold(11.0), t.text_dim); + for (i, a) in axes.iter().enumerate() { + let (col, row) = (i % 2, i / 2); + let fx = x0 + col as f32 * (w / 2.0); + let fy = y + 20.0 + row as f32 * 24.0; + let tag = a.tag.trim_end().to_string(); + let v = doc.variations.iter().find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default); + p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim); + let (r, nv, _) = + widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::new(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t); + app.auto.add(&format!("character.axis.{tag}"), r, &a.name); + if let Some(nv) = nv + && enabled + { + actions.push(json!({"variations": {tag.clone(): nv}, "merge": format!("char-axis-{tag}")})); + } + } + } match &target { None => { p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint); @@ -568,90 +590,131 @@ fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui:: chosen } -/// Align panel: align selected layers to the composition (or to the selection). +/// Align panel: Align Layers to Selection / Composition, the six align buttons and the six +/// Distribute Layers buttons. Buttons run `layer.align {edge, to}` and `layer.distribute +/// {mode}` on the selected layers. pub fn align(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); let x0 = rect.min.x + 10.0; - let y = rect.min.y + 10.0; - p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Align Layers to: Composition", Tokens::ui(11.5), t.text_dim); + let mut y = rect.min.y + 10.0; + p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, "Align Layers to:", Tokens::ui(11.5), t.text_dim); + let dr = Rect::from_min_size(pos2(x0 + 100.0, y), vec2(110.0, 20.0)); + let to_sel = app.ui.align_to_selection; + let pid = egui::Id::new("align-to-pop"); + if widgets::dropdown(ui, dr, if to_sel { "Selection" } else { "Composition" }, &t, pid.with("btn")).clicked() { + widgets::open_popup(ui, pid); + } + app.auto.add("align.to", dr, if to_sel { "Selection" } else { "Composition" }); + if let Some(i) = + widgets::popup_menu(ui, pid, dr.left_bottom() + vec2(0.0, 2.0), &["Selection".to_string(), "Composition".to_string()], Some(if to_sel { 0 } else { 1 })) + { + app.ui.align_to_selection = i == 0; + } + y += 28.0; let ops = ["left", "hcenter", "right", "top", "vcenter", "bottom"]; - for (i, op) in ops.iter().enumerate() { - let r = Rect::from_min_size(pos2(x0 + i as f32 * 34.0 + if i >= 3 { 12.0 } else { 0.0 }, y + 24.0), vec2(30.0, 28.0)); - let resp = ui.interact(r, egui::Id::new(("align", *op)), Sense::click()); - p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); - // Glyph: a bar (alignment edge) and two boxes. - let c = r.center(); - let st = egui::Stroke::new(1.3, t.text); - match *op { - "left" | "hcenter" | "right" => { - let ex = match *op { - "left" => r.min.x + 7.0, - "right" => r.max.x - 7.0, - _ => c.x, - }; - p.line_segment([pos2(ex, r.min.y + 5.0), pos2(ex, r.max.y - 5.0)], st); - let off = |w: f32| match *op { - "left" => ex, - "right" => ex - w, - _ => ex - w / 2.0, - }; - p.rect_filled(Rect::from_min_size(pos2(off(14.0), c.y - 7.0), vec2(14.0, 5.0)), 1.0, t.text_dim); - p.rect_filled(Rect::from_min_size(pos2(off(9.0), c.y + 2.0), vec2(9.0, 5.0)), 1.0, t.text_dim); - } - _ => { - let ey = match *op { - "top" => r.min.y + 6.0, - "bottom" => r.max.y - 6.0, - _ => c.y, - }; - p.line_segment([pos2(r.min.x + 5.0, ey), pos2(r.max.x - 5.0, ey)], st); - let off = |h: f32| match *op { - "top" => ey, - "bottom" => ey - h, - _ => ey - h / 2.0, - }; - p.rect_filled(Rect::from_min_size(pos2(c.x - 8.0, off(14.0)), vec2(5.0, 14.0)), 1.0, t.text_dim); - p.rect_filled(Rect::from_min_size(pos2(c.x + 3.0, off(9.0)), vec2(5.0, 9.0)), 1.0, t.text_dim); + let mut run: Option<(&str, &str)> = None; + for (row, kind) in ["align", "distribute"].into_iter().enumerate() { + if row == 1 { + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Distribute Layers:", Tokens::ui(11.5), t.text_dim); + y += 20.0; + } + for (i, op) in ops.iter().enumerate() { + let r = Rect::from_min_size(pos2(x0 + i as f32 * 34.0 + if i >= 3 { 12.0 } else { 0.0 }, y), vec2(30.0, 28.0)); + let resp = ui.interact(r, egui::Id::new((kind, *op)), Sense::click()); + p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); + align_glyph(&p, r, op, row == 1, &t); + let tip = align_tip(kind, op); + app.auto.add(&format!("{kind}.{op}"), r, &tip); + if resp.on_hover_text(tip).clicked() { + run = Some((kind, op)); } } - app.auto.add(&format!("align.{op}"), r, op); - if resp.clicked() { - align_layers(app, op); + y += 34.0; + } + if let Some((kind, op)) = run { + let r = if kind == "align" { + let to = if app.ui.align_to_selection { "selection" } else { "composition" }; + app.session.execute("layer.align", json!({"edge": op, "to": to})) + } else { + app.session.execute("layer.distribute", json!({"mode": op})) + }; + if let Err(e) = r { + app.ui.status = e.to_string(); } } - let _ = icons::paint; - let _ = Icon::Grid; } -/// Move selected layers so their content bounds align to the comp. -pub fn align_layers(app: &mut EffectcraftApp, op: &str) { - let Some(cid) = app.session.active_comp_id() else { return }; - let Some(comp) = app.session.project.comp(cid).cloned() else { return }; - let time = app.session.time(); - let mut moves = vec![]; - { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None }; - for l in comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id)) { - let Some(b) = effectcraft_engine::render::content_bounds(&ectx, l) else { continue }; - let (m, _) = ectx.layer_to_comp(l); - let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| m.apply(effectcraft_engine::geom::vec2(p[0], p[1]))); - let (x0, x1) = (pts.iter().map(|p| p.x).fold(f64::INFINITY, f64::min), pts.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max)); - let (y0, y1) = (pts.iter().map(|p| p.y).fold(f64::INFINITY, f64::min), pts.iter().map(|p| p.y).fold(f64::NEG_INFINITY, f64::max)); - let (cw, ch) = (comp.width as f64, comp.height as f64); - let (dx, dy) = match op { - "left" => (-x0, 0.0), - "right" => (cw - x1, 0.0), - "hcenter" => (cw / 2.0 - (x0 + x1) / 2.0, 0.0), - "top" => (0.0, -y0), - "bottom" => (0.0, ch - y1), - _ => (0.0, ch / 2.0 - (y0 + y1) / 2.0), - }; - let pos = l.transform().map(|tr| ectx.v3(l, tr, "position", [0.0; 3])).unwrap_or([0.0; 3]); - moves.push((l.id.0, [pos[0] + dx, pos[1] + dy, pos[2]])); +fn align_tip(kind: &str, op: &str) -> String { + let what = match op { + "left" => "Left", + "hcenter" => "Horizontal Center", + "right" => "Right", + "top" => "Top", + "vcenter" => "Vertical Center", + _ => "Bottom", + }; + if kind == "align" { format!("Align {what}") } else { format!("Distribute {what}") } +} + +/// Button glyph: the alignment edge as a line and two boxes (distribute: three boxes spread, +/// the line through each). +fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Tokens) { + let c = r.center(); + let st = egui::Stroke::new(1.3, t.text); + let horizontal = matches!(op, "left" | "hcenter" | "right"); + if distribute { + for k in 0..3 { + let f = (k as f32 - 1.0) * 7.0; + if horizontal { + let bx = c.x + f; + let ex = match op { + "left" => bx - 2.5, + "right" => bx + 2.5, + _ => bx, + }; + p.rect_filled(Rect::from_center_size(pos2(bx, c.y), vec2(5.0, 10.0 + k as f32 * 2.0)), 1.0, t.text_dim); + p.line_segment([pos2(ex, r.min.y + 5.0), pos2(ex, r.max.y - 5.0)], st); + } else { + let by = c.y + f; + let ey = match op { + "top" => by - 2.5, + "bottom" => by + 2.5, + _ => by, + }; + p.rect_filled(Rect::from_center_size(pos2(c.x, by), vec2(10.0 + k as f32 * 2.0, 5.0)), 1.0, t.text_dim); + p.line_segment([pos2(r.min.x + 5.0, ey), pos2(r.max.x - 5.0, ey)], st); + } } + return; } - for (id, v) in moves { - let _ = app.session.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": v})); + if horizontal { + let ex = match op { + "left" => r.min.x + 7.0, + "right" => r.max.x - 7.0, + _ => c.x, + }; + p.line_segment([pos2(ex, r.min.y + 5.0), pos2(ex, r.max.y - 5.0)], st); + let off = |w: f32| match op { + "left" => ex, + "right" => ex - w, + _ => ex - w / 2.0, + }; + p.rect_filled(Rect::from_min_size(pos2(off(14.0), c.y - 7.0), vec2(14.0, 5.0)), 1.0, t.text_dim); + p.rect_filled(Rect::from_min_size(pos2(off(9.0), c.y + 2.0), vec2(9.0, 5.0)), 1.0, t.text_dim); + } else { + let ey = match op { + "top" => r.min.y + 6.0, + "bottom" => r.max.y - 6.0, + _ => c.y, + }; + p.line_segment([pos2(r.min.x + 5.0, ey), pos2(r.max.x - 5.0, ey)], st); + let off = |h: f32| match op { + "top" => ey, + "bottom" => ey - h, + _ => ey - h / 2.0, + }; + p.rect_filled(Rect::from_min_size(pos2(c.x - 8.0, off(14.0)), vec2(5.0, 14.0)), 1.0, t.text_dim); + p.rect_filled(Rect::from_min_size(pos2(c.x + 3.0, off(9.0)), vec2(5.0, 9.0)), 1.0, t.text_dim); } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 66c3c94a7..cb897125b 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -519,7 +519,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("viewer.area", area, "Composition viewer"); // Guides. - if app.ui.viewer.safe_margins { + let overlays = app.overlays_visible(); + if overlays && app.ui.viewer.safe_margins { let g = &app.session.prefs.grids; let (act, title) = (1.0 - g.action_safe as f32 / 100.0, 1.0 - g.title_safe as f32 / 100.0); for (k, a) in [(act, 120u8), (title, 160)] { @@ -530,7 +531,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { painter.line_segment([c - vec2(10.0, 0.0), c + vec2(10.0, 0.0)], Stroke::new(1.0, Color32::from_white_alpha(140))); painter.line_segment([c - vec2(0.0, 10.0), c + vec2(0.0, 10.0)], Stroke::new(1.0, Color32::from_white_alpha(140))); } - if app.ui.viewer.grid { + if overlays && app.ui.viewer.grid { // Settings ▸ Grids & Guides: gridline spacing, subdivisions and colour. let g = &app.session.prefs.grids; let [r, gg, b] = hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]); @@ -567,7 +568,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { vt::proportional_grid(app, &painter, comp_rect); // Comp guides (View ▸ Show Guides) and the region of interest. - if app.ui.viewer.guides { + if overlays && app.ui.viewer.guides { let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]); let stroke = Stroke::new(1.0, Color32::from_rgb(r, gg, b)); // Settings ▸ Grids & Guides ▸ Guide Style. @@ -625,7 +626,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if finish || app.ui.tool != Tool::Selection { ov::end_free_transform(&ctx); } - if app.ui.viewer.show_layer_controls { + if app.layer_controls_visible() { // Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for // Layer Handles and Paths. let hs = app.session.prefs.general.path_point_size as f32 + 2.0; @@ -748,7 +749,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Track points of the current track (Tracker panel) on its layer, while that layer is selected. let mut track_hits = vec![]; let track_cur = super::tracker::viewer_track(app); - if app.ui.viewer.show_layer_controls + if app.layer_controls_visible() && let Some((tl, tu)) = track_cur && selected.contains(&tl) && let Some(layer) = comp.layer(tl) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 50031da7d..4b657f527 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -590,16 +590,13 @@ pub struct UiState { pub snapping: bool, /// Tool creates shape (true) or mask (false) when a layer is selected. pub tool_creates_shape: bool, - /// Preview panel options. - pub preview_loop: bool, - pub preview_cache_first: bool, - /// Preview panel "Include Audio" (Mute Audio off). - #[serde(default = "yes")] - pub preview_audio: bool, pub start_screen: bool, /// The Home screen shows its Learn tab (tutorials) instead of the recent projects. #[serde(default)] pub home_learn: bool, + /// Align panel: Align Layers to Selection (off: to the composition). + #[serde(default)] + pub align_to_selection: bool, /// Composition ▸ Preview ▸ Cache Frames When Idle. pub cache_when_idle: bool, /// Tracker panel ▸ Motion Source chosen without a tracker yet (layer id). @@ -665,11 +662,9 @@ impl Default for UiState { stroke_width: 0.0, snapping: true, tool_creates_shape: true, - preview_loop: true, - preview_cache_first: false, - preview_audio: true, start_screen: false, home_learn: false, + align_to_selection: false, cache_when_idle: false, tracker_source: None, layer_panel: None, diff --git a/crates/ui-egui/tests/ui_preview_align.rs b/crates/ui-egui/tests/ui_preview_align.rs new file mode 100644 index 000000000..3629a02cc --- /dev/null +++ b/crates/ui-egui/tests/ui_preview_align.rs @@ -0,0 +1,186 @@ +//! Headless checks for M13.8: the Preview panel's per-shortcut options drive playback (range, +//! play from, resolution, overlays, full screen, move time), the preview shortcuts pick their +//! options, and the Align panel runs `layer.align` / `layer.distribute`. + +use effectcraft_engine::Session; +use effectcraft_engine::preview::PreviewShortcut; +use effectcraft_engine::time::Tick; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Event, Pos2, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Preview", "width": 320, "height": 180, "frameRate": 24, "duration": 10})).unwrap(); + s.execute("layer.newSolid", json!({"name": "A", "color": "#406080", "width": 40, "height": 30})).unwrap(); + s.execute("layer.newSolid", json!({"name": "B", "color": "#806040", "width": 60, "height": 20})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, p: serde_json::Value) -> serde_json::Value { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, p).unwrap() +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + let p: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn set(h: &mut Harness<'_, EffectcraftApp>, p: serde_json::Value) { + h.state_mut().session.execute("playback.settings.set", p).unwrap(); +} + +#[test] +fn preview_panel_edits_the_shown_shortcut() { + let mut h = harness(); + invoke(&mut h, "window.panel", json!({"panel": "preview"})); + // The default workspace keeps the Preview panel short: maximize it. + h.state_mut().toggle_maximize(PanelKind::Preview); + h.run_steps(3); + for id in [ + "preview.shortcut", + "preview.includeVideo", + "preview.includeAudio", + "preview.includeOverlays", + "preview.includeLayerControls", + "preview.loop", + "preview.cacheBeforePlayback", + "preview.range", + "preview.playFrom", + "preview.frameRate", + "preview.skip", + "preview.resolution", + "preview.fullScreen", + "preview.playCachedFrames", + "preview.moveTimeToPreviewTime", + ] { + assert!(h.state().auto.find(id).is_some(), "{id}"); + } + assert!(h.state().session.prefs.preview.spacebar.loop_); + click(&mut h, "preview.loop"); + assert!(!h.state().session.prefs.preview.spacebar.loop_); + // Shortcut popup: Shift+Numpad 0; its options are shown and edited from now on. + click(&mut h, "preview.shortcut"); + click(&mut h, "preview.shortcut.3"); + assert_eq!(h.state().session.prefs.preview.current, PreviewShortcut::ShiftNumpad0); + click(&mut h, "preview.fullScreen"); + assert!(h.state().session.prefs.preview.shift_numpad0.full_screen); + assert!(!h.state().session.prefs.preview.spacebar.full_screen); + // Range popup → Play Around Current Time shows the pre/post-roll. + click(&mut h, "preview.range"); + click(&mut h, "preview.range.3"); + h.run_steps(2); + assert!(h.state().auto.find("preview.preRoll").is_some()); +} + +#[test] +fn playback_follows_the_shortcut_options() { + let mut h = harness(); + // Numpad 0: Work Area from its start; the time returns when the preview stops. + h.state_mut().session.execute("comp.workArea", json!({"start": 2.0, "end": 4.0})).unwrap(); + set(&mut h, json!({"shortcut": "numpad0", "moveTimeToPreviewTime": false, "resolution": "quarter", "includeOverlays": false})); + h.state_mut().session.set_time(Tick::from_seconds_f64(7.0)); + invoke(&mut h, "playback.ramPreview", json!({})); + let app = h.state(); + assert!(app.playback.playing); + assert_eq!(app.playback.shortcut, PreviewShortcut::Numpad0); + let pl = app.playback.plan.unwrap(); + assert_eq!((pl.start, pl.end, pl.first), (48, 95, 48)); + assert_eq!(app.session.time(), Tick::from_seconds_f64(2.0)); + assert_eq!(app.viewer_scale(1.0, 1.0), 0.25); + assert!(!app.overlays_visible()); + invoke(&mut h, "playback.stop", json!({})); + assert!(!h.state().playback.playing); + assert_eq!(h.state().session.time(), Tick::from_seconds_f64(7.0)); + assert!(h.state().overlays_visible()); + + // Spacebar with Full Screen: the viewer is maximized while it plays. + set(&mut h, json!({"shortcut": "spacebar", "fullScreen": true, "skip": 2})); + invoke(&mut h, "playback.toggle", json!({})); + assert_eq!(h.state().ui.maximized, Some(PanelKind::Composition)); + assert_eq!(h.state().playback.plan.unwrap().step, 3); + invoke(&mut h, "playback.toggle", json!({})); + assert_eq!(h.state().ui.maximized, None); + + // Shift+Spacebar from the keyboard plays with its own options. + let ev = Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }; + h.input_mut().events.push(ev); + h.step(); + assert!(h.state().playback.playing); + assert_eq!(h.state().playback.shortcut, PreviewShortcut::ShiftSpacebar); + assert_eq!(h.state().playback.plan.unwrap().step, 2); +} + +#[test] +fn cache_before_playback_waits_then_plays_cached() { + let mut h = harness(); + set(&mut h, json!({"shortcut": "spacebar", "cacheBeforePlayback": true, "range": "entireDuration", "playFrom": "rangeStart"})); + invoke(&mut h, "playback.toggle", json!({})); + assert!(h.state().playback.caching); + // Pressing again while caching ("If caching, play cached frames") stops only when nothing + // is cached yet, otherwise plays the cached frames. + h.run_steps(4); + invoke(&mut h, "playback.toggle", json!({})); + let st = &h.state().playback; + assert!(!st.caching); + if st.playing { + let pl = st.plan.unwrap(); + assert!(pl.end < 239); + } +} + +#[test] +fn align_panel_runs_align_and_distribute() { + let mut h = harness(); + invoke(&mut h, "window.panel", json!({"panel": "align"})); + h.state_mut().toggle_maximize(PanelKind::Align); + h.run_steps(3); + for id in ["align.to", "align.left", "align.vcenter", "distribute.hcenter", "distribute.bottom"] { + assert!(h.state().auto.find(id).is_some(), "{id}"); + } + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap(); + h.run_steps(2); + // (x position, width) of A and B (anchors in the middle of the solids). + let pos = |h: &Harness<'_, EffectcraftApp>| -> Vec<(f64, f64)> { + let c = h.state().session.active_comp().unwrap(); + ["A", "B"] + .iter() + .map(|n| { + let l = c.layers.iter().find(|l| l.name == *n).unwrap(); + let x = l.transform().unwrap().get("position").unwrap().value_at(Tick::ZERO).to_json()[0].as_f64().unwrap(); + (x, if *n == "A" { 40.0 } else { 60.0 }) + }) + .collect() + }; + let orig = pos(&h); + click(&mut h, "align.left"); + assert_eq!(pos(&h), vec![(20.0, 40.0), (30.0, 60.0)]); + // One undo step for the whole alignment. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pos(&h), orig); + // Align Layers to: Selection — right edges meet at the rightmost. + click(&mut h, "align.to"); + click(&mut h, "align.to"); + h.state_mut().ui.align_to_selection = true; + h.run_steps(2); + assert_eq!(h.state().auto.find("align.to").unwrap().label, "Selection"); + click(&mut h, "align.right"); + let p = pos(&h); + let right = |(x, w): (f64, f64)| x + w / 2.0; + let goal = orig.iter().map(|v| right(*v)).fold(f64::NEG_INFINITY, f64::max); + assert!((right(p[0]) - goal).abs() < 1e-9 && (right(p[1]) - goal).abs() < 1e-9, "{p:?}"); + // Distribute needs three layers: nothing moves. + click(&mut h, "distribute.hcenter"); + assert_eq!(pos(&h), p); +} diff --git a/docs/architecture.md b/docs/architecture.md index ec03c3322..d0c9267e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +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 | `pdf` | PDF, PDF-compatible Illustrator (`.ai`) and EPS (PostScript subset) vector footage into the `svg` render tree: paths, fills, strokes, shadings, tiling patterns, clipping, text (embedded TrueType / CFF / Type 1 / Type 3 fonts; the bundled `text` fonts for the standard 14), images, blend modes, soft masks, 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 | @@ -43,7 +43,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L5 | `automation` | The MCP server, headless or bridged to the running app | | L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) | -Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path, track`, +Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `pdf → svg, text`, `effects → project, text, path, track`, `media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine, script`, `script → engine`. diff --git a/docs/parity.md b/docs/parity.md index a2c752ad8..fee439c40 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -15,13 +15,17 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much | Remaining work | ≈ 19–22 agent-hours at the pace measured so far (≈ 58 on the conservative audit scale) | | **Wall-clock estimate** | **≈ 4–5 hours** with five agents in parallel; ≈ 3 hours for 100% of P0 + P1 | -Partial features: effect implementations (42 simplified effects), the Preview panel's settings -(range, play from, skip, frame rate, full screen), GPU coverage (Advanced 3D, more GPU effects), -the web app (no threads for viewer frames and Roto Brush), the Learn panel and Align to Selection / -Distribute, Advanced 3D (iris shapes, collapsed precomps, extruded strokes), ScriptUI resource -strings and `onDraw`, Essential Graphics font and scale controls, and the learned-model quality of -Roto Brush and face tracking (both are classical). Menu items still absent: Save Frame As ▸ -Photoshop Layers / ProEXR, Open in Essential Graphics, Vanishing Point import, Watch Folder. +Partial features: GPU coverage (Advanced 3D, more GPU effects), the web app (no threads for +viewer frames and Roto Brush), Essential Graphics mirrored properties, and the learned-model quality +of Roto Brush and face tracking (both are classical). Since this audit, M9.13 completed all 42 +simplified effects, M13.5 added the Learn panel, and M13.8 the Preview panel settings, Align to +Selection / Distribute and Advanced 3D iris DOF, collapsed precomps and extruded strokes. M13.7 added ScriptUI resource strings, +`onDraw` (ScriptUIGraphics draw lists), live `onChanging`, `Socket`, the Essential Graphics +scripting hooks and Font / uniform Scale controls, and the last missing menu items: Save Frame As ▸ +Photoshop Layers / ProEXR, Composition ▸ Open in Essential Graphics, File ▸ Watch Folder, +Window ▸ Create Nulls From Paths / VR Comp Editor (built-in panels) and Help ▸ In-App / Online +Tutorials. File ▸ Import ▸ Vanishing Point (.vpe) is present but permanently disabled: the format +has no public specification, so a clean-room implementation is not possible (its tooltip says so). ### Previous audit (commit `fa26ad9`, 2 October 2026, late): ≈ 93% @@ -31,8 +35,8 @@ Tracking 96%, 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; -approximated effects (Key Cleaner) and Liquify's viewer brush; text in imported -PDF/AI files and Illustrator procset EPS; and more codecs. Face tracking and a true Subspace Warp +approximated effects (Key Cleaner) and Liquify's viewer brush; mesh shadings and JPX images in +imported PDF/AI files and Illustrator procset EPS; and more codecs. Face tracking and a true Subspace Warp landed in M13.3. ScriptUI (dialogs that block `show()`, palettes, dockable ScriptUI panels, File ▸ Scripts install and sample scripts), puppet pin recording and the remaining "better than After Effects" items — a versioned effect plug-in API with sandboxed WebAssembly plug-ins ([plugins.md](plugins.md)), @@ -124,19 +128,19 @@ 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 | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control. Left: Opus is CELT-only, Photoshop sequence output, overflow for movies only checks at file creation | | Audio | 85% | 0.5 | audio to keyframes | -| 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 | ≈ 97% | 0.3 | `.jsxbin`, sockets, ScriptUI resource strings and custom `onDraw` graphics (the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | +| Import | 96% | 0.6 | mesh shadings (types 4–7), JPX / CCITT / JBIG2 images and non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; 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 | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | | 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) | +| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' mirrored properties (CMP-7: Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | | Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), 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 | ≈ 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) | +| Text | ≈ 95% | 0.4 | no extruded strokes, the Variable Font Axes animator changes outlines but not advances (M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; 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 for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); 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 | +| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); 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 depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (152 run on the GPU since M13.9, EFF-5: keying, mattes, channel, colour correction, stylize, distort, noise, blurs and time effects joined the 58 of M12.7; Warp, Bezier Warp, Reshape, Smear, Cartoon, CC Page Turn and others still render on the CPU) 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 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; 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) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | -| Preview | 66% | 4.0 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | +| Preview | 74% | 3.2 | wireframes and Advanced 3D compositing on the GPU (Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 96% | 0.3 | face tracking is a classical (skin model + feature components + shape model) fitter, not a learned detector: profile views and occluded faces are weak; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | @@ -176,7 +180,29 @@ Tate-Chu-Yoko, forced LTR paragraphs, the editing caret on animated / path text style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and layer ends (ANM-4; the reference graph existed); viewer ROI resize handles, Pan Behind snapping and 3D Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry -taper/wave, Advanced 3D's DOF has no iris shapes, and variable axes don't change advances. +taper/wave and the Variable Font Axes animator doesn't change advances (the Character panel's +axes do since M13.6; Advanced 3D's DOF has iris shapes since M13.8). + +## Update: M13.8 Preview panel, Align panel, Advanced 3D completion + +- **Preview panel (PRV-1)**: Shortcut popup (Spacebar, Shift+Spacebar, Numpad 0, Shift+Numpad + 0, Alt+Numpad 0), each shortcut with its own saved options (settings `preview`): Include + Video / Audio / Overlays / Layer Controls, Loop, Cache Before Playback, Range (Work Area, + Work Area Extended By Current Time, Entire Duration, Play Around Current Time with pre/post + roll), Play From (Range Start / Current Time), Frame Rate (Auto or a rate), Skip, Resolution + (Auto, Full … Quarter, Custom), Full Screen, "If caching, play cached frames" and "Move time to + preview time". `playback.settings.get/set`; playback follows the plan (range, start, skip grid, + rate, loop, cache-first, audio-only, resolution override, hidden overlays / layer controls, + maximized viewer); the keys trigger their shortcut (`playback.toggle {shortcut?}`, + `playback.ramPreview`, `playback.preview.shiftSpacebar|shiftNumpad0|altNumpad0`). Full Screen + maximizes the Composition panel rather than taking over the display. +- **Align panel (UI-1)**: `layer.align {edge, to: composition|selection}` and `layer.distribute + {mode}` (edges or centres) on content bounds through transform and parents, one undo step; + Align Layers to dropdown and the Distribute Layers row. +- **Advanced 3D (3D-3)**: depth of field with the camera's iris and highlight options (the + Classic 3D bokeh kernel, by each pixel's depth); collapsed precomps add their nested layers + as real geometry lit by the parent (2D collapsed precomps draw nested 3D layers with the + parent's renderer); text and shape strokes extrude as bevelled meshes in paint order. ## Update: M13.5 UI completion & fidelity diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 11899dc4b..01cb44b4b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -61,6 +61,7 @@ const SAME_LAYER: &[(&str, &str)] = &[ ("path", "raster"), ("text", "path"), ("pdf", "svg"), + ("pdf", "text"), ("effects", "project"), ("effects", "text"), ("effects", "path"),