diff --git a/Cargo.lock b/Cargo.lock index c7934100..87cd95d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1213,6 +1213,7 @@ dependencies = [ "effectcraft-lottie", "effectcraft-model", "effectcraft-path", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1309,6 +1310,7 @@ dependencies = [ "effectcraft-export", "effectcraft-expr", "effectcraft-media", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1352,6 +1354,7 @@ name = "effectcraft-media" version = "0.1.0" dependencies = [ "effectcraft-model", + "effectcraft-pdf", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", @@ -1398,6 +1401,15 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "effectcraft-pdf" +version = "0.1.0" +dependencies = [ + "effectcraft-svg", + "kurbo", + "miniz_oxide 0.8.9", +] + [[package]] name = "effectcraft-project" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 22709f60..cda7c302 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ effectcraft-media = { path = "crates/media" } effectcraft-export = { path = "crates/export" } effectcraft-expr = { path = "crates/expr" } effectcraft-lottie = { path = "crates/lottie" } +effectcraft-pdf = { path = "crates/pdf" } effectcraft-psd = { path = "crates/psd" } effectcraft-vp9enc = { path = "crates/vp9enc" } effectcraft-svg = { path = "crates/svg" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 45508a71..7a7adb30 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -26,6 +26,7 @@ effectcraft-lottie = { workspace = true } effectcraft-psd = { workspace = true } kurbo = { workspace = true } effectcraft-svg = { workspace = true } +effectcraft-pdf = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 6431d5e2..fb0473d4 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -433,8 +433,8 @@ fn ae(v: [f64; 3]) -> Vec3 { } /// Composition ▸ VR ▸ Create VR Environment: renders the active comp's 3D scene in every -/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, with a -/// copy of the scene's layers), a 3:2 cube map of them, and an equirectangular output comp +/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, looking +/// at the scene nested as a collapsed 3D precomp), a 3:2 cube map of them, and an equirectangular output comp /// (VR Converter, cube map → equirectangular). One undo step. fn create_vr_environment(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; @@ -462,30 +462,41 @@ fn create_vr_environment(s: &mut Session, p: &Value) -> Result { id_of(&s.execute("layer.newCamera", json!({"comp": fc, "name": format!("{fname} Camera"), "angleOfView": 90.0, "type": "oneNode"}))?, "layer")?; s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/position", "value": center}))?; s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/orientation", "value": o}))?; - // The scene's layers (not its cameras), in the scene's coordinates, under the face - // camera. - let layers: Vec = src.layers.iter().filter(|l| !l.is_camera()).cloned().collect(); - s.edit("Create VR Environment", None, |proj, _| { - let mut next = proj.next_id; - let mut map = std::collections::BTreeMap::new(); - let mut copies = vec![]; - for l in &layers { - let mut c = l.clone(); - super::edit::reid(&mut c, &mut next); - map.insert(l.id, c.id); - copies.push(c); - } - for c in &mut copies { - c.parent = c.parent.and_then(|p| map.get(&p).copied()); - if let Some(m) = &mut c.track_matte { - m.layer = map.get(&m.layer).copied().unwrap_or(m.layer); + // The scene, nested as a collapsed 3D precomp whose layer maps the scene's + // coordinates onto the face comp's unchanged (anchor = position): its 3D layers are + // seen through the face camera and edits to the scene show in every face. Lights + // inside a collapsed precomp do not light it (the containing comp's do), so the + // scene's lights are copied next to it. + let pre = id_of(&s.execute("layer.addItem", json!({"comp": fc, "item": cid.0, "time": 0.0}))?, "layer")?; + let mid = [src.width as f64 / 2.0, src.height as f64 / 2.0, 0.0]; + s.execute("layer.setSwitch", json!({"comp": fc, "layers": [pre], "switch": "threeD", "value": true}))?; + s.execute("layer.setSwitch", json!({"comp": fc, "layers": [pre], "switch": "collapse", "value": true}))?; + s.execute("prop.set", json!({"comp": fc, "layer": pre, "path": "transform/anchor", "value": mid}))?; + s.execute("prop.set", json!({"comp": fc, "layer": pre, "path": "transform/position", "value": mid}))?; + let lights: Vec = src.layers.iter().filter(|l| l.is_light()).cloned().collect(); + if !lights.is_empty() { + s.edit("Create VR Environment", None, |proj, _| { + let mut next = proj.next_id; + let mut map = std::collections::BTreeMap::new(); + let mut copies = vec![]; + for l in &lights { + let mut c = l.clone(); + super::edit::reid(&mut c, &mut next); + map.insert(l.id, c.id); + copies.push(c); } - } - proj.next_id = next; - let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?; - comp.layers.extend(copies); - Ok(()) - })?; + for c in &mut copies { + c.parent = c.parent.and_then(|p| map.get(&p).copied()); + } + proj.next_id = next; + let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?; + let at = comp.layers.len().saturating_sub(1); + for (k, c) in copies.into_iter().enumerate() { + comp.layers.insert(at + k, c); + } + Ok(()) + })?; + } faces.push(fc); } let cube = id_of( diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/commands/create.rs index 72cb2420..a1d22fb5 100644 --- a/crates/engine/src/commands/create.rs +++ b/crates/engine/src/commands/create.rs @@ -28,12 +28,14 @@ fn create(s: &mut Session, p: &Value) -> Result { }); match op { "shapesFromVector" => { - let Some(f) = footage.filter(|f| f.codec == "SVG" || f.path.to_ascii_lowercase().ends_with(".svg")) else { - skipped.push(format!("{}: not a vector (SVG) footage layer", src.name)); + let Some(f) = footage.filter(crate::vector::is_vector) else { + skipped.push(format!("{}: not a vector (SVG, PDF, AI or EPS) footage layer", src.name)); continue; }; let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?; - let doc = effectcraft_svg::parse(&bytes).map_err(|e| EngineError::Other(format!("{}: {e}", f.path)))?; + let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref()) + .unwrap_or_else(|| Err(format!("{}: not a vector file", f.path))) + .map_err(EngineError::Other)?; let id = s.edit("Create Shapes from Vector Layer", None, |proj, st| { let l = crate::vector::shapes_from_vector(proj, &comp, &src, &doc); let id = l.id; diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index fcff953b..fe057683 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -111,6 +111,17 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { for path in paths { let bytes = match s.services.read_file(&path) { Ok(b) if effectcraft_psd::is_psd(&b) => b, + Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => { + // PDF / Illustrator / EPS: one layer per file layer. + match import_vector_comp(s, &path, &b) { + Ok((comp, items)) => { + out_comps.push(comp); + ids.extend(items); + } + Err(e) => errors.push(e.to_string()), + } + continue; + } _ => { rest.push(path); continue; @@ -179,7 +190,9 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { && f.codec == "PSD" { match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) { - Some((index, name)) => f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false }), + Some((index, name)) => { + f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None }) + } None => { errors.push(format!("{path}: no layer {sel}")); continue; @@ -285,6 +298,22 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, 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()); + let rate = effectcraft_time::FrameRate::FPS_29_97; + let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 }; + let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs)); + let (comp, folder, items) = s.edit("Import", None, |proj, st| { + let r = crate::vector::import_vector_comp(proj, path, bytes, &name, rate, duration).map_err(EngineError::Other)?; + st.project_selection = vec![r.0]; + Ok(r) + })?; + let mut out = vec![folder.0]; + out.extend(items.iter().map(|i| i.0)); + Ok((comp.0, out)) +} + /// Extensions imported as data footage. pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"]; @@ -449,7 +478,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}", + "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}", always, import ), diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index fec069bd..58135b35 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?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", + "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, OpenType: discretionaryLigatures?, contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01…ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", has_layers, set_text ), diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 28048389..37ed9da0 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -465,6 +465,38 @@ pub(crate) fn sanitize(s: &mut Session) { } } +/// The OpenType features of a font (`font` / `style`, else the target text layer's style at the +/// caret or its first character), and which Character panel options it sets with its own glyphs +/// (the others are synthesized or have no effect). +pub fn font_features(s: &mut Session, p: &Value) -> Result { + let (family, style) = match p.get("font").and_then(Value::as_str) { + Some(f) => (f.to_string(), p.get("style").and_then(Value::as_str).unwrap_or("Regular").to_string()), + None => { + let lid = target(s, p, "text.fontFeatures")?; + let doc = layer_doc(s, lid).ok_or_else(|| bad("text.fontFeatures", "not a text layer"))?; + let at = s.state.text_edit.as_ref().filter(|e| e.layer == lid).map_or(0, |e| e.range().start); + let st = doc.style_at(at); + (st.font, st.style) + } + }; + let r = effectcraft_text::resolve(&family, &style); + let face = effectcraft_text::fonts::face(r.face); + let feats = face.features(); + let has = |t: &str| feats.iter().any(|f| f == t); + let sets: Vec = (1..=20u32).filter(|n| has(&format!("ss{n:02}"))).collect(); + Ok(json!({ + "family": face.info.family, "style": face.info.style, "missing": r.missing, + "features": feats, + "options": { + "kerning": has("kern"), "ligatures": has("liga") || has("clig"), "discretionaryLigatures": has("dlig"), + "contextualAlternates": has("calt"), "stylisticAlternates": has("salt"), "stylisticSets": sets, + "swash": has("swsh"), "titling": has("titl"), "ordinals": has("ordn"), "fractions": has("frac"), + "smallCaps": has("smcp"), "allSmallCaps": has("c2sc"), "superscript": has("sups"), "subscript": has("subs"), + "oldStyleFigures": has("onum"), "liningFigures": has("lnum"), "tabularFigures": has("tnum"), "proportionalFigures": has("pnum"), + }, + })) +} + fn always_ok(_: &Session) -> std::result::Result<(), String> { Ok(()) } @@ -485,6 +517,15 @@ pub fn specs() -> Vec { edit ), cmd!("text.endEdit", "Exit Text Editing", [], None, "{}", always_ok, end_edit), + cmd!( + "text.fontFeatures", + "OpenType Features", + [], + None, + "{layer?, font?, style?} → {family, style, features: [tags], options: {smallCaps, superscript, stylisticSets: [n], fractions, …}} (what the font sets with its own glyphs)", + always_ok, + font_features + ), cmd!( "text.setSelection", "Set Text Selection", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f7fda487..17d9b235 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -824,6 +824,11 @@ pub fn text_families() -> Vec { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() } +/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to. +pub fn font_features(family: &str, style: &str) -> Vec { + effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features() +} + /// One row of the Character panel's font menu. #[derive(Clone, Debug, PartialEq)] pub struct FontRow { diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index f64fd3af..555dd97b 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -6,7 +6,9 @@ //! so their blend modes act on the layers below, like Photoshop); type layers become editable //! text layers; layer masks are baked into the layer's alpha and vector masks become masks; //! layer effects map to Layer Styles; adjustment layers become adjustment layers with the -//! matching effect (best effort); clipped layers preserve the transparency underneath. +//! matching effect (best effort); clipped layers preserve the transparency underneath. Smart +//! objects whose file is embedded become footage of that file (an embedded Photoshop document's +//! merged image, or the embedded image) placed with the smart object's transform. use std::sync::Arc; @@ -156,7 +158,7 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> ( missing: false, sequence: vec![], color_profile: None, - layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty() }), + layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None }), ..Default::default() }; let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); @@ -164,6 +166,60 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> ( (id, (w, h)) } +/// Size of an embedded file's image: the smart object's recorded size, else the embedded +/// document's header (Photoshop, PNG). +fn embedded_size(so: &effectcraft_psd::SmartObject, data: &[u8]) -> Option<(u32, u32)> { + if let Some([w, h]) = so.size.filter(|s| s[0] >= 1.0 && s[1] >= 1.0) { + return Some((w.round() as u32, h.round() as u32)); + } + if effectcraft_psd::is_psd(data) && data.len() >= 22 { + let h = u32::from_be_bytes([data[14], data[15], data[16], data[17]]); + let w = u32::from_be_bytes([data[18], data[19], data[20], data[21]]); + return Some((w, h)); + } + if data.starts_with(b"\x89PNG") && data.len() >= 24 { + let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]); + let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]); + return Some((w, h)); + } + None +} + +/// A smart object layer as footage of its embedded file, placed by the smart object's +/// transform. `None` when the file is not embedded (linked externally) or unreadable. +fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usize, name: &str) -> Option { + let pl = &cx.psd.layers[index]; + let so = pl.smart_object.clone()?; + let data = cx.psd.linked_data(&so.uuid)?; + let (w, h) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?; + let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default(); + let f = Footage { + path: cx.path.to_string(), + kind: FootageKind::Still, + width: w, + height: h, + pixel_aspect: 1.0, + frame_rate: cx.rate, + has_video: true, + alpha: AlphaMode::Straight, + loop_count: 1, + codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() }, + layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()) }), + ..Default::default() + }; + let label = if file.is_empty() { name.to_string() } else { file }; + let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); + cx.items.push(id); + let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None); + let (c, sx, sy, rot) = so.placement(w as f64, h as f64); + set_vec2(&mut l, "transform/position", c); + set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]); + if rot.abs() > 1e-9 { + set_scalar(&mut l, "transform/rotation", rot); + } + Some(l) +} + fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Option { let (index, children) = match n { Node::Layer(i) => (*i, None), @@ -183,6 +239,8 @@ fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Optio let mut l = adjustment_layer(proj, comp, &name); add_adjustment_effect(proj, &mut l, adj, comp, &mut cx.warnings, &name); l + } else if let Some(l) = pl.smart_object.as_ref().and_then(|_| smart_object_layer(proj, cx, comp, index, &name)) { + l } else if pl.has_pixels() || pl.fill.is_some() { let (item, size) = footage_item(proj, cx, index, &name); let mut l = build::layer(proj, comp, &name, LayerSource::Footage { item }, size, None); diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index edc8fe18..d538da63 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -476,8 +476,9 @@ fn create_vr_environment_and_extract_cubemap() { let c = s.project.comp(ItemId(*f)).unwrap(); assert_eq!((c.width, c.height), (64, 64)); assert!(c.layers.iter().any(|l| l.is_camera())); - assert!(c.layers.iter().any(|l| l.name == aname && l.is_3d()), "the scene's layers"); - let _ = main; + let nested = c.layers.iter().find(|l| matches!(l.source, effectcraft_project::LayerSource::Comp { item } if item == main)).expect("the scene, nested"); + assert!(nested.switches.collapse && nested.is_3d(), "collapsed 3D precomp"); + let _ = &aname; } let cube = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap(); assert_eq!((cube.width, cube.height, cube.layers.len()), (192, 128, 6)); diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 2476727f..46610786 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -202,3 +202,28 @@ fn edit_state_round_trips_through_serde() { let back: crate::EditorState = serde_json::from_value(j).unwrap(); assert_eq!(back.text_edit, s.state.text_edit); } + +#[test] +fn opentype_features_per_range_render_and_query() { + let (mut s, t) = session("Office 1/2"); + s.execute("layer.setText", json!({"layer": t, "range": [7, 10], "fractions": true, "ss01": true})).unwrap(); + let d = doc(&s, t); + assert!(!d.style_at(0).opentype.fractions); + assert!(d.style_at(8).opentype.fractions && d.style_at(8).opentype.stylistic_set(1)); + // The laid-out glyphs change (frac turns 1/2 into a fraction). + let plain = effectcraft_text::layout_doc(&TextDoc { text: "1/2".into(), ..Default::default() }); + let frac = effectcraft_text::layout_doc(&d.slice(7..10)); + let ids = |l: &effectcraft_text::TextLayout| l.glyphs.iter().map(|g| g.gid).collect::>(); + assert_ne!(ids(&plain), ids(&frac)); + // Undo restores the uniform style. + s.execute("edit.undo", json!({})).unwrap(); + assert!(doc(&s, t).is_uniform()); + // Font feature query (font by name, or the layer's). + let f = s.execute("text.fontFeatures", json!({"font": "Noto Serif"})).unwrap(); + assert_eq!(f["options"]["smallCaps"], true); + assert!(f["features"].as_array().unwrap().iter().any(|t| t == "onum")); + let f = s.execute("text.fontFeatures", json!({"layer": t})).unwrap(); + assert_eq!(f["family"], "Inter"); + assert!(f["options"]["stylisticSets"].as_array().unwrap().contains(&json!(1))); + assert_eq!(f["options"]["smallCaps"], false); +} diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index a3f3e040..3465d385 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -1,5 +1,6 @@ -//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG footage → -//! shape layer), Create Shapes from Text and Create Masks from Text (glyph outlines). +//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG, PDF, +//! Illustrator and EPS footage → shape layer), Create Shapes from Text and Create Masks from +//! Text (glyph outlines), and vector files imported as compositions (one layer per file layer). use effectcraft_keyframe::{Gradient, ShapePath, Value}; use effectcraft_project::build::{self, Ids}; @@ -149,6 +150,74 @@ pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec { if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items } } +/// Whether footage is a vector file (SVG / PDF / AI / EPS). +pub fn is_vector(f: &effectcraft_project::Footage) -> bool { + effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg") +} + +/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted +/// to one layer when `layer` names it. +pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option> { + if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) { + return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); + } + effectcraft_pdf::sniff(bytes)?; + Some( + effectcraft_pdf::parse(bytes) + .map(|d| match layer { + Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + None => d, + }) + .map_err(|e| format!("{path}: {e}")), + ) +} + +/// Import a PDF / Illustrator / EPS document as a composition the size of its page: one footage +/// item per file layer (each showing only that layer, continuously rasterisable), top layer +/// first. Returns (comp, folder, items). +pub fn import_vector_comp( + proj: &mut Project, + path: &str, + bytes: &[u8], + name: &str, + rate: effectcraft_time::FrameRate, + duration: effectcraft_time::Tick, +) -> Result<(ItemId, ItemId, Vec), String> { + use effectcraft_color::Label; + use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer}; + let doc = effectcraft_pdf::parse(bytes).map_err(|e| format!("{path}: {e}"))?; + let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF"); + let (w, h) = doc.pixel_size(); + let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder); + let mut comp = Comp::new(w, h, rate, duration); + let mut items = vec![]; + let names = effectcraft_pdf::layer_names(&doc); + for (i, lname) in names.iter().enumerate() { + let f = Footage { + path: path.to_string(), + kind: FootageKind::Still, + width: w, + height: h, + pixel_aspect: 1.0, + frame_rate: rate, + has_video: true, + alpha: AlphaMode::Straight, + loop_count: 1, + codec: codec.into(), + layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }), + ..Default::default() + }; + let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f)); + items.push(id); + let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None); + l.name = comp.unique_layer_name(lname); + comp.layers.insert(0, l); + } + let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(std::sync::Arc::new(comp))); + Ok((cid, folder, items)) +} + /// A copy of `src`'s transform group with fresh uids. fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) { let Some(tr) = src.props.sub("transform") else { return }; diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 635e3260..564efb3b 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -21,5 +21,6 @@ effectcraft-export = { workspace = true } serde_json = { workspace = true } effectcraft-psd = { workspace = true } effectcraft-svg = { workspace = true } +effectcraft-pdf = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-time = { workspace = true } diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 590112c9..9b77ccae 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -184,3 +184,153 @@ fn svg_footage_continuously_rasterizes() { assert!(sharp < 0.004, "continuously rasterised diff {sharp}"); assert!(sharp < soft, "sharper than the upscaled pixels ({sharp} vs {soft})"); } + +/// A two-layer Illustrator-style PDF (optional-content layers "Sky" and "Art"): a gradient sky, +/// a red curved shape with a dark outline, and a clipped green bar. +fn layered_pdf() -> Vec { + let content = "/OC /L0 BDC\n/Pattern cs /P0 scn 0 0 120 80 re f\nEMC\n\ +/OC /L1 BDC\n1 0 0 rg 0.1 0.1 0.1 RG 3 w 20 20 m 20 60 l 60 70 100 60 v 100 20 l h B\n\ +q 30 0 40 80 re W n 0 0.8 0 rg 0 35 120 10 re f Q\nEMC\n"; + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>"; + let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 0 80] /Function << /FunctionType 2 /Domain [0 1] /C0 [0.9 0.9 1] /C1 [0.2 0.4 0.9] /N 1 >> /Extend [true true] >> >>"; + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, page.into(), None), + (4, "<< >>".into(), Some(content.as_bytes().to_vec())), + (5, "<< /Type /OCG /Name (Sky) >>".into(), None), + (6, "<< /Type /OCG /Name (Art) >>".into(), None), + (7, pattern.into(), None), + ], + 1, + ) +} + +#[test] +fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() { + let bytes = layered_pdf(); + let doc = effectcraft_pdf::parse(&bytes).unwrap(); + let (w, h) = doc.pixel_size(); + assert_eq!((w, h), (120, 80)); + let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0); + for name in ["art.pdf", "art.ai"] { + let path = tmp(name); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let item = r["items"][0].as_u64().unwrap(); + let effectcraft_project::ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!() }; + assert_eq!(f.codec, if name.ends_with(".ai") { "AI" } else { "PDF" }); + assert_eq!((f.width, f.height), (120, 80)); + s.execute("comp.new", json!({"name": "V", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap(); + let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let t = effectcraft_time::Tick::ZERO; + let foot = s.render(cid, t, RenderOpts::default()); + assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "{name}: footage = rasterised page"); + // Continuously Rasterize at 300 %. + for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] { + s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap(); + } + let want = effectcraft_svg::rasterize(&doc, w, h, 3.0); + let soft = mean_diff(&s.render(cid, t, RenderOpts::default()), &want); + s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap(); + let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want); + assert!(sharp < 0.004 && sharp < soft, "{name}: continuously rasterised {sharp} vs {soft}"); + s.execute("prop.set", json!({"layer": lid, "path": "transform/scale", "value": [100, 100]})).unwrap(); + // Create Shapes from Vector Layer. + s.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap(); + let sid = r["layers"][0].as_u64().unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!(c.layers[0].id.0, sid); + let shapes = s.render(cid, t, RenderOpts::default()); + // Clipping groups are not shape-layer features: the clipped bar (rows 33–47) draws in + // full; everything else matches. + let rows = |img: &Image| Image { + width: w, + height: h - 15, + data: img.data.iter().enumerate().filter(|(i, _)| !(33..48).contains(&(*i as u32 / w))).map(|(_, p)| *p).collect(), + }; + let d = mean_diff(&rows(&shapes), &rows(&direct)); + assert!(d < 0.01, "{name}: shapes vs page mean diff {d}"); + let fill = shapes.get(60, 30); + assert!(fill[0] > 0.9 && fill[1] < 0.1, "red shape {fill:?}"); + } + // Import As: Composition — one layer per file layer, top first, together like the page. + let path = tmp("layers.ai"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["Art", "Sky"]); + assert_eq!((comp.width, comp.height), (120, 80)); + let art = comp.layers[0].id.0; + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + let d = mean_diff(&img, &direct); + assert!(d < 1.0 / 255.0, "layered comp vs page {d}"); + // Each layer's footage shows only its layer. + s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap(); + let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + assert_eq!(sky.get(5, 5), img.get(5, 5)); + let p = sky.get(60, 30); + assert!(p[2] > p[0], "only sky under the art {p:?}"); +} + +#[test] +fn psd_smart_object_imports_its_embedded_document() { + // The embedded document: red left half, green right half (20×10). + let mut inner = WDoc::new(20, 10); + inner.layers = + vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])]; + let inner_bytes = write(&inner); + let mut d = WDoc::new(64, 48); + // Placed at 2× and turned a quarter clockwise: the content's top-left corner at (40, 4). + let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]]; + d.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]), + // Placeholder pixels (grey) that the embedded file replaces. + WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("so-1", quad, [20.0, 10.0])), + ]; + d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes)]; + let path = tmp("smart.psd"); + std::fs::write(&path, write(&d)).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + let placed = comp.layers.iter().find(|l| l.name == "Placed").unwrap(); + let effectcraft_project::LayerSource::Footage { item } = placed.source else { panic!("footage layer") }; + let effectcraft_project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; + assert_eq!((f.width, f.height), (20, 10)); + assert_eq!(f.layer.as_ref().unwrap().embedded.as_deref(), Some("so-1")); + assert!(s.project.item(item).unwrap().name.contains("inner.psd")); + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + // Rotated a quarter clockwise: the red half is on top (rows 4–24), green below. + let (red, green) = (img.get(30, 14), img.get(30, 34)); + assert!(red[0] > 0.95 && red[1] < 0.05, "{red:?}"); + assert!(green[1] > 0.95 && green[0] < 0.05, "{green:?}"); + assert!(img.get(10, 24)[0] < 0.01 && img.get(10, 24)[3] > 0.99, "background outside"); +} + +#[test] +fn eps_footage_imports() { + let eps = + b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n1 0 0 setrgbcolor 0 0 20 20 rectfill 0 0 1 setrgbcolor newpath 30 10 8 0 360 arc fill\n%%EOF\n"; + let path = tmp("shape.eps"); + std::fs::write(&path, eps).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let item = r["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "E", "width": 40, "height": 20, "frameRate": 30, "duration": 1})).unwrap(); + s.execute_checked("layer.addItem", json!({"item": item})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + assert!(img.get(10, 10)[0] > 0.99 && img.get(30, 10)[2] > 0.99, "{:?} {:?}", img.get(10, 10), img.get(30, 10)); +} diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 2bc0258e..bf34d592 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -13,7 +13,7 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; -pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun}; +pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun}; pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; /// Temporal interpolation on one side of a keyframe. diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 2e9d5c6e..ae0b2e56 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -143,6 +143,125 @@ fn yes() -> bool { true } +/// Figure style (OpenType `lnum` / `onum`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FigureStyle { + /// The font's default figures. + #[default] + Default, + Lining, + OldStyle, +} + +/// Figure spacing (OpenType `pnum` / `tnum`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FigureWidth { + #[default] + Default, + Proportional, + Tabular, +} + +/// OpenType layout features of a run of characters (Character panel ▸ OpenType). Kerning, +/// standard ligatures, small caps and superscript / subscript have their own [`CharStyle`] +/// fields (they use the font's `smcp` / `sups` / `subs` glyphs when it has them and synthesize +/// them otherwise). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct OpenType { + /// `dlig`. + pub discretionary_ligatures: bool, + /// `calt` (on by default, as in the font). + pub contextual_alternates: bool, + /// `salt`. + pub stylistic_alternates: bool, + /// Stylistic sets: bit `n - 1` turns on `ssNN` (n = 1…20). + pub stylistic_sets: u32, + /// `swsh`. + pub swash: bool, + /// `titl`. + pub titling: bool, + /// `ordn`. + pub ordinals: bool, + /// `frac`. + pub fractions: bool, + /// All Small Caps: capitals become small capitals too (`c2sc` with `smcp`). + pub all_small_caps: bool, + pub figure_style: FigureStyle, + pub figure_width: FigureWidth, +} + +impl Default for OpenType { + fn default() -> Self { + OpenType { + discretionary_ligatures: false, + contextual_alternates: true, + stylistic_alternates: false, + stylistic_sets: 0, + swash: false, + titling: false, + ordinals: false, + fractions: false, + all_small_caps: false, + figure_style: FigureStyle::Default, + figure_width: FigureWidth::Default, + } + } +} + +impl OpenType { + pub fn is_default(&self) -> bool { + *self == OpenType::default() + } + /// Whether stylistic set `n` (1…20) is on. + pub fn stylistic_set(&self, n: u32) -> bool { + (1..=20).contains(&n) && self.stylistic_sets & (1 << (n - 1)) != 0 + } + pub fn set_stylistic_set(&mut self, n: u32, on: bool) { + if (1..=20).contains(&n) { + if on { + self.stylistic_sets |= 1 << (n - 1); + } else { + self.stylistic_sets &= !(1 << (n - 1)); + } + } + } + /// The OpenType feature settings these options ask for (tag, value), beyond the font's + /// defaults: e.g. `[("dlig", 1), ("ss02", 1), ("calt", 0)]`. + pub fn feature_settings(&self) -> Vec<([u8; 4], u32)> { + let mut v = Vec::new(); + for (tag, f) in [ + (b"dlig", self.discretionary_ligatures), + (b"salt", self.stylistic_alternates), + (b"swsh", self.swash), + (b"titl", self.titling), + (b"ordn", self.ordinals), + (b"frac", self.fractions), + (b"c2sc", self.all_small_caps), + (b"smcp", self.all_small_caps), + (b"lnum", self.figure_style == FigureStyle::Lining), + (b"onum", self.figure_style == FigureStyle::OldStyle), + (b"pnum", self.figure_width == FigureWidth::Proportional), + (b"tnum", self.figure_width == FigureWidth::Tabular), + ] { + if f { + v.push((*tag, 1)); + } + } + if !self.contextual_alternates { + v.push((*b"calt", 0)); + } + for n in 1..=20u32 { + if self.stylistic_set(n) { + let d = format!("ss{n:02}"); + let b = d.as_bytes(); + v.push(([b[0], b[1], b[2], b[3]], 1)); + } + } + v + } +} + /// The formatting of a run of characters (Character panel). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -177,6 +296,9 @@ pub struct CharStyle { /// Vertical type: keep Roman (half-width) characters upright instead of turning them on /// their side (Character panel menu ▸ Standard Vertical Roman Alignment). pub vertical_roman_upright: bool, + /// OpenType features (stylistic sets, figures, fractions…). + #[serde(skip_serializing_if = "OpenType::is_default")] + pub opentype: OpenType, } impl Default for CharStyle { @@ -258,6 +380,9 @@ pub struct TextDoc { /// Base Tate-Chu-Yoko and Standard Vertical Roman Alignment (see [`CharStyle`]). pub tate_chu_yoko: bool, pub vertical_roman_upright: bool, + /// Base OpenType features (see [`CharStyle::opentype`]). + #[serde(skip_serializing_if = "OpenType::is_default")] + pub opentype: OpenType, pub indent_left: f64, pub indent_right: f64, pub indent_first: f64, @@ -309,6 +434,7 @@ impl Default for TextDoc { ligatures: true, tate_chu_yoko: false, vertical_roman_upright: false, + opentype: OpenType::default(), indent_left: 0.0, indent_right: 0.0, indent_first: 0.0, @@ -395,6 +521,7 @@ impl TextDoc { ligatures: self.ligatures, tate_chu_yoko: self.tate_chu_yoko, vertical_roman_upright: self.vertical_roman_upright, + opentype: self.opentype, } } @@ -422,6 +549,7 @@ impl TextDoc { self.ligatures = s.ligatures; self.tate_chu_yoko = s.tate_chu_yoko; self.vertical_roman_upright = s.vertical_roman_upright; + self.opentype = s.opentype; } /// The base (first paragraph's) settings. @@ -777,6 +905,9 @@ fn color_json(v: &J) -> Option<[f32; 4]> { fn num(key: &str, v: &J) -> Result { v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("{key}: expected a number")) } +fn norm(v: &J) -> Option { + v.as_str().map(|s| s.to_ascii_lowercase().replace(['-', ' ', '_'], "")) +} fn flag(key: &str, v: &J) -> Result { v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)).ok_or_else(|| format!("{key}: expected a boolean")) } @@ -808,6 +939,18 @@ pub const CHAR_ATTRS: &[&str] = &[ "ligatures", "tateChuYoko", "verticalRomanUpright", + "discretionaryLigatures", + "contextualAlternates", + "stylisticAlternates", + "stylisticSets", + "swash", + "titling", + "ordinals", + "fractions", + "allSmallCaps", + "figureStyle", + "figureWidth", + "figures", ]; /// Paragraph attribute keys of `layer.setText`. @@ -850,6 +993,7 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result { @@ -869,6 +1013,65 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result s.ligatures = flag(key, v)?, "tateChuYoko" => s.tate_chu_yoko = flag(key, v)?, "verticalRomanUpright" => s.vertical_roman_upright = flag(key, v)?, + "discretionaryLigatures" => s.opentype.discretionary_ligatures = flag(key, v)?, + "contextualAlternates" => s.opentype.contextual_alternates = flag(key, v)?, + "stylisticAlternates" => s.opentype.stylistic_alternates = flag(key, v)?, + "swash" => s.opentype.swash = flag(key, v)?, + "titling" => s.opentype.titling = flag(key, v)?, + "ordinals" => s.opentype.ordinals = flag(key, v)?, + "fractions" => s.opentype.fractions = flag(key, v)?, + "allSmallCaps" => { + s.opentype.all_small_caps = flag(key, v)?; + if s.opentype.all_small_caps { + s.all_caps = false; + } + } + "stylisticSets" => { + s.opentype.stylistic_sets = match v { + J::Array(a) => { + let mut m = 0u32; + for n in a { + let n = n.as_u64().filter(|n| (1..=20).contains(n)).ok_or("stylisticSets: expected set numbers 1–20")?; + m |= 1 << (n - 1); + } + m + } + J::Number(_) => (num(key, v)? as u64 & 0xF_FFFF) as u32, + _ => return Err("stylisticSets: expected [1, 2, …] or a bit mask".into()), + } + } + "figureStyle" => { + s.opentype.figure_style = match norm(v).as_deref() { + Some("default") => FigureStyle::Default, + Some("lining") => FigureStyle::Lining, + Some("oldstyle") => FigureStyle::OldStyle, + _ => return Err("figureStyle: expected default|lining|oldStyle".into()), + } + } + "figureWidth" => { + s.opentype.figure_width = match norm(v).as_deref() { + Some("default") => FigureWidth::Default, + Some("proportional") => FigureWidth::Proportional, + Some("tabular") => FigureWidth::Tabular, + _ => return Err("figureWidth: expected default|proportional|tabular".into()), + } + } + "figures" => { + let (st, w) = match norm(v).as_deref() { + Some("default") => (FigureStyle::Default, FigureWidth::Default), + Some("tabularlining") => (FigureStyle::Lining, FigureWidth::Tabular), + Some("proportionallining") => (FigureStyle::Lining, FigureWidth::Proportional), + Some("tabularoldstyle") => (FigureStyle::OldStyle, FigureWidth::Tabular), + Some("proportionaloldstyle") => (FigureStyle::OldStyle, FigureWidth::Proportional), + _ => return Err("figures: expected default|tabularLining|proportionalLining|tabularOldstyle|proportionalOldstyle".into()), + }; + s.opentype.figure_style = st; + s.opentype.figure_width = w; + } + k if k.len() == 4 && k.starts_with("ss") && k[2..].parse::().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)?); + } _ => return Ok(false), } Ok(true) @@ -915,6 +1118,13 @@ pub fn char_style_json(s: &CharStyle) -> J { "hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic, "allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(), "ligatures": s.ligatures, "tateChuYoko": s.tate_chu_yoko, "verticalRomanUpright": s.vertical_roman_upright, + "discretionaryLigatures": s.opentype.discretionary_ligatures, "contextualAlternates": s.opentype.contextual_alternates, + "stylisticAlternates": s.opentype.stylistic_alternates, + "stylisticSets": (1..=20u32).filter(|n| s.opentype.stylistic_set(*n)).collect::>(), + "swash": s.opentype.swash, "titling": s.opentype.titling, "ordinals": s.opentype.ordinals, "fractions": s.opentype.fractions, + "allSmallCaps": s.opentype.all_small_caps, + "figureStyle": match s.opentype.figure_style { FigureStyle::Default => "default", FigureStyle::Lining => "lining", FigureStyle::OldStyle => "oldStyle" }, + "figureWidth": match s.opentype.figure_width { FigureWidth::Default => "default", FigureWidth::Proportional => "proportional", FigureWidth::Tabular => "tabular" }, }) } @@ -1127,4 +1337,37 @@ mod tests { assert_eq!(word_at(t, 8), 7..10); assert_eq!(word_at(t, 6), 6..7); } + + #[test] + fn opentype_attributes_per_range_and_serde() { + let mut d = TextDoc::plain("Office 1/2 1st"); + // Untouched documents serialize without an OpenType block. + assert!(!serde_json::to_string(&d).unwrap().contains("opentype")); + d.set_attr("ss02", &J::Bool(true), Some(0..6)).unwrap(); + d.set_attr("fractions", &J::Bool(true), Some(7..10)).unwrap(); + d.set_attr("figures", &J::from("tabularOldstyle"), None).unwrap(); + assert!(d.style_at(0).opentype.stylistic_set(2)); + assert!(!d.style_at(8).opentype.stylistic_set(2)); + assert!(d.style_at(8).opentype.fractions); + assert_eq!(d.style_at(12).opentype.figure_width, FigureWidth::Tabular); + assert!(d.set_attr("stylisticSets", &serde_json::json!([1, 20]), Some(0..1)).unwrap()); + assert_eq!(d.style_at(0).opentype.stylistic_sets, 1 | (1 << 19)); + assert!(d.set_attr("stylisticSets", &serde_json::json!([21]), None).is_err()); + let f = d.style_at(8).opentype.feature_settings(); + assert!(f.contains(&(*b"frac", 1)) && f.contains(&(*b"onum", 1)) && f.contains(&(*b"tnum", 1))); + let mut s = d.style_at(0); + s.opentype.contextual_alternates = false; + assert!(s.opentype.feature_settings().contains(&(*b"calt", 0))); + // Round trip, and the JSON form names the attributes as layer.setText does. + let back: TextDoc = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); + assert_eq!(back, d); + let j = char_style_json(&d.style_at(0)); + assert_eq!(j["stylisticSets"], serde_json::json!([1, 20])); + assert_eq!(j["figureStyle"], "oldStyle"); + // All Small Caps and All Caps exclude each other. + let mut s = CharStyle::default(); + apply_char_attr(&mut s, "allCaps", &J::Bool(true)).unwrap(); + apply_char_attr(&mut s, "allSmallCaps", &J::Bool(true)).unwrap(); + assert!(!s.all_caps && s.opentype.all_small_caps); + } } diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 10998031..3db4b1a9 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -16,6 +16,7 @@ effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } effectcraft-psd = { workspace = true } effectcraft-svg = { workspace = true } +effectcraft-pdf = { workspace = true } effectcraft-model = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 204312a5..b2331d81 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -1,5 +1,7 @@ //! Layered and vector stills: Photoshop documents (`effectcraft-psd`: the merged image, or one -//! layer for footage imported as a composition) and SVG (`effectcraft-svg`, rasterised). +//! layer for footage imported as a composition) and vector files rasterised at any scale: SVG +//! (`effectcraft-svg`), PDF / Illustrator / EPS (`effectcraft-pdf`; one layer of the file for +//! footage imported as a composition). use effectcraft_project::{AlphaMode, Footage}; use effectcraft_raster::Image; @@ -11,7 +13,29 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool { path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) } -/// Footage for a Photoshop or SVG file (`None` for other formats). +/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted +/// to the footage's layer when it names one. +pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option> { + if is_svg(path, bytes) { + return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); + } + effectcraft_pdf::sniff(bytes)?; + Some( + effectcraft_pdf::parse(bytes) + .map(|d| match layer { + Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + None => d, + }) + .map_err(|e| format!("{path}: {e}")), + ) +} + +fn is_pdf_like(path: &str, bytes: &[u8]) -> bool { + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF")) +} + +/// Footage for a Photoshop or vector file (`None` for other formats). pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { if effectcraft_psd::is_psd(bytes) { let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; @@ -28,6 +52,14 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { f.alpha = AlphaMode::Straight; return Ok(Some(f)); } + if is_pdf_like(path, bytes) { + let doc = effectcraft_pdf::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + let (w, h) = doc.pixel_size(); + let mut f = crate::probe::still_footage(path, w, h, image::ImageFormat::Png, true); + f.codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF").into(); + f.alpha = AlphaMode::Straight; + return Ok(Some(f)); + } Ok(None) } @@ -35,6 +67,17 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -> Result> { if effectcraft_psd::is_psd(bytes) { let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + // A smart object's embedded file: an embedded document's merged image, or an image. + if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) { + let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?; + if effectcraft_psd::is_psd(data) { + let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + let px = inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); + } + let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + return Ok(Some(crate::convert::dynamic_to_image(&img, op))); + } let px = match &footage.layer { Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size), None => psd.composite(), @@ -42,8 +85,8 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - .map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); } - if is_svg(path, bytes) { - let doc = effectcraft_svg::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + if is_svg(path, bytes) || is_pdf_like(path, bytes) { + let doc = vector_doc(path, bytes, footage.layer.as_ref()).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; let (w, h) = doc.pixel_size(); let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); // The rasteriser produces premultiplied pixels already. @@ -53,9 +96,9 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - Ok(None) } -/// An SVG rasterised at `scale` × its pixel size (Continuously Rasterize). -pub(crate) fn rasterize_svg(bytes: &[u8], scale: f64) -> Option { - let doc = effectcraft_svg::parse(bytes).ok()?; +/// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize). +pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, scale: f64) -> Option { + let doc = vector_doc(path, bytes, layer)?.ok()?; let (w, h) = doc.pixel_size(); let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384)); Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 6c67f50f..5de2eb8c 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -16,6 +16,7 @@ mod convert; pub mod exr_channels; mod layered; +pub use layered::vector_doc; mod pool; mod probe; @@ -47,7 +48,7 @@ impl From for MediaError { } /// File extensions recognised as stills. -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg"]; +pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; /// File extensions recognised as movies. diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index a4c7300d..13010057 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -488,9 +488,10 @@ impl Inner { let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count); Loc { key: key(&footage.sequence[i as usize], 0), media_t: None } } - // A layer of a layered still is keyed by its layer index and size mode. + // A layer of a layered still is keyed by its layer index and size mode (smart objects + // by their embedded file). FootageKind::Still if footage.layer.is_some() => { - let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64); + let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 }); Loc { key: key(&footage.path, l), media_t: None } } FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None }, @@ -621,7 +622,7 @@ impl FootageSource for MediaPool { return None; } let bytes = self.inner.read(&footage.path).ok()?; - crate::layered::rasterize_svg(&bytes, scale).map(Arc::new) + crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), scale).map(Arc::new) } fn aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option> { diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml new file mode 100644 index 00000000..93d082b5 --- /dev/null +++ b/crates/pdf/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "effectcraft-pdf" +description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages and EPS (a PostScript subset) parsed into the SVG render tree (paths, fills, strokes, axial / radial shadings, clipping, optional-content layers)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +effectcraft-svg = { workspace = true } +kurbo = { workspace = true } +miniz_oxide = "0.8" diff --git a/crates/pdf/README.md b/crates/pdf/README.md new file mode 100644 index 00000000..a1a9d0b3 --- /dev/null +++ b/crates/pdf/README.md @@ -0,0 +1,48 @@ +# effectcraft-pdf + +Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, which are PDF +documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG +(`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape +layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one +layer per file layer (File ▸ Import ▸ Composition). + +Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file +structure, filters, content streams, graphics state, colour spaces, functions, shadings and +optional content; the **PostScript Language Reference, third edition** and the **Encapsulated +PostScript File Format Specification 3.0** for EPS. No third-party PDF/PostScript code was read; +test files are generated in code ([`write`](src/write.rs) and the tests). + +## What is read + +- **Structure:** indirect objects found by scanning (damaged or incremental cross-reference + sections do not matter), object streams, the page tree with inherited resources, MediaBox / + CropBox and `/Rotate`. Filters: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85, + RunLength. Encrypted files are refused. +- **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`, + `LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd), + clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by + component count), CalGray / CalRGB, Lab, Indexed, Separation and DeviceN (tint transforms of + function types 0, 2 and 3), axial and radial shadings as pattern fills and through `sh`, form + XObjects (matrix, bounding-box clip, nested resources). +- **Layers:** top-level optional-content marked sequences (`/OC … BDC`, Illustrator's layers) + become the document's layers, named after their OCG; content outside them is gathered into + layers "Layer N". +- **EPS:** a PostScript interpreter for the subset exported vector EPS uses: operand and + dictionary stacks, procedures and `def`/`bind`/`load`/`where`, arithmetic, comparisons, + `if`/`ifelse`/`for`/`repeat`/`loop`/`forall`, arrays and strings, `gsave`/`grestore`, + `save`/`restore`, matrices (`concat`, `translate`, `scale`, `rotate`, `transform` …), path + construction including `arc`/`arcn` and relative operators, `fill`/`eofill`/`stroke`, + `clip`/`eoclip`, `rectfill`/`rectstroke`/`rectclip`, gray / RGB / CMYK / HSB colour. DOS EPS + binary headers and `%%HiResBoundingBox` / `%%BoundingBox` are honoured; `%%BeginData` / + `%%BeginBinary` / preview sections are skipped. + +## Limitations + +- Text is not drawn (embedded fonts are not read); images, tiling patterns, mesh shadings + (types 4–7), PostScript calculator functions, soft masks and blend modes are skipped. What a + document skipped is listed in `Doc::skipped`. +- Illustrator EPS files whose drawing depends on Adobe's procedure-set resources (defined in + the file through constructs beyond this subset) and Level 3 `shfill` gradients in EPS draw + only what the subset understands; save such artwork as PDF-compatible `.ai` or PDF instead. +- Shape-layer conversion has no clipping: clipped artwork converts unclipped. +- Only the first page of a multi-page PDF is imported. diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs new file mode 100644 index 00000000..ca063dee --- /dev/null +++ b/crates/pdf/src/build.rs @@ -0,0 +1,93 @@ +//! The output side shared by the PDF and PostScript interpreters: an open-group stack (clipping +//! groups, optional-content layers) that collects shapes into the SVG render tree. + +use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Plain, + Clip, + Layer, +} + +pub(crate) struct Builder { + stack: Vec<(Group, Kind)>, + /// Which of the finished top-level nodes are optional-content layers. + pub top_layers: Vec, + pub skipped: Vec, + pub shapes: usize, +} + +pub(crate) fn group(name: &str) -> Group { + Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![] } +} + +impl Builder { + pub fn new() -> Builder { + Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0 } + } + + pub fn depth(&self) -> usize { + self.stack.len() + } + + pub fn skip(&mut self, what: &str) { + if !self.skipped.iter().any(|s| s == what) { + self.skipped.push(what.to_string()); + } + } + + fn add(&mut self, n: Node, layer: bool) { + if self.stack.len() == 1 { + self.top_layers.push(layer); + } + if let Some((g, _)) = self.stack.last_mut() { + g.children.push(n); + } + } + + /// Close every group opened above `depth`. + pub fn close_to(&mut self, depth: usize) { + while self.stack.len() > depth.max(1) { + let Some((g, k)) = self.stack.pop() else { break }; + // Empty clipping groups draw nothing. + if g.children.is_empty() { + continue; + } + self.add(Node::Group(g), k == Kind::Layer); + } + } + + /// Children drawn from now on are clipped by `path` (page space) until the group closes. + pub fn clip(&mut self, path: BezPath, rule: FillRule) { + let mut g = group("Clip Group"); + g.clip.push((path, rule)); + self.stack.push((g, Kind::Clip)); + } + + /// Open an optional-content layer (or a named group). + pub fn open(&mut self, name: &str, layer: bool) { + self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain })); + } + + pub fn fill_stroke(&mut self, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option) { + if path.elements().is_empty() || (fill.is_none() && stroke.is_none()) { + return; + } + self.shapes += 1; + let (f, fo, rule) = match fill { + Some((p, o, r)) => (Some(p), o, r), + None => (None, 1.0, FillRule::NonZero), + }; + let s = Shape { name: "Path".into(), geom: Geom::Path(path), transform, fill: f, fill_opacity: fo, fill_rule: rule, stroke, opacity: 1.0 }; + self.add(Node::Shape(s), false); + } + + /// The finished top-level nodes and their layer flags. + pub fn finish(mut self) -> (Vec, Vec, Vec) { + self.close_to(1); + let root = self.stack.pop().map(|(g, _)| g.children).unwrap_or_default(); + let flags = self.top_layers; + (root, flags, self.skipped) + } +} diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs new file mode 100644 index 00000000..b35e9f39 --- /dev/null +++ b/crates/pdf/src/color.rs @@ -0,0 +1,288 @@ +//! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled, +//! exponential and stitching; PostScript calculator functions are not evaluated). + +use crate::object::{Dict, File, Obj, decode_stream}; + +#[derive(Clone, Debug, PartialEq)] +pub enum Cs { + Gray, + Rgb, + Cmyk, + /// CIE L*a*b* (approximated). + Lab, + Indexed { + base: Box, + hival: usize, + lookup: Vec, + }, + /// Separation / DeviceN: components → alternate space through a tint transform. + Tint { + n: usize, + alt: Box, + func: Func, + }, + Pattern, +} + +impl Cs { + pub fn components(&self) -> usize { + match self { + Cs::Gray | Cs::Indexed { .. } => 1, + Cs::Rgb | Cs::Lab => 3, + Cs::Cmyk => 4, + Cs::Tint { n, .. } => *n, + Cs::Pattern => 0, + } + } + + /// The initial colour of the space (black, or tint 1). + pub fn initial(&self) -> Vec { + match self { + Cs::Cmyk => vec![0.0, 0.0, 0.0, 1.0], + Cs::Tint { n, .. } => vec![1.0; *n], + Cs::Lab => vec![0.0, 0.0, 0.0], + c => vec![0.0; c.components()], + } + } + + pub fn to_rgb(&self, c: &[f64]) -> [f64; 3] { + let g = |i: usize| c.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0); + match self { + Cs::Gray => [g(0); 3], + Cs::Rgb | Cs::Pattern => [g(0), g(1), g(2)], + Cs::Cmyk => { + let k = g(3); + [(1.0 - g(0)) * (1.0 - k), (1.0 - g(1)) * (1.0 - k), (1.0 - g(2)) * (1.0 - k)] + } + Cs::Lab => lab_to_rgb(c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0), c.get(2).copied().unwrap_or(0.0)), + Cs::Indexed { base, hival, lookup } => { + let i = (c.first().copied().unwrap_or(0.0).round().max(0.0) as usize).min(*hival); + let n = base.components(); + let v: Vec = (0..n).map(|k| lookup.get(i * n + k).copied().unwrap_or(0) as f64 / 255.0).collect(); + base.to_rgb(&v) + } + Cs::Tint { alt, func, .. } => alt.to_rgb(&func.eval(c)), + } + } +} + +fn lab_to_rgb(l: f64, a: f64, b: f64) -> [f64; 3] { + let fy = (l + 16.0) / 116.0; + let fx = fy + a / 500.0; + let fz = fy - b / 200.0; + let f = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) }; + let (x, y, z) = (0.9505 * f(fx), f(fy), 1.089 * f(fz)); + let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.204 * y + 1.057 * z]; + lin.map(|v| { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + }) +} + +/// Read a colour space (a name, or an array such as `[/ICCBased 5 0 R]`); named resources are +/// looked up in `res` (`/ColorSpace`). +pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs { + let o = file.resolve(o); + match o { + Obj::Name(n) => match n.as_str() { + "DeviceGray" | "G" | "CalGray" => Cs::Gray, + "DeviceRGB" | "RGB" | "CalRGB" => Cs::Rgb, + "DeviceCMYK" | "CMYK" => Cs::Cmyk, + "Pattern" => Cs::Pattern, + other => match res.and_then(|r| file.get_dict(r, "ColorSpace")).and_then(|cs| file.get(cs, other)) { + Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space(file, x, None), + _ => Cs::Gray, + }, + }, + Obj::Array(a) => { + let head = a.first().map(|h| file.resolve(h)).and_then(Obj::name).unwrap_or(""); + match head { + "ICCBased" => { + let n = a.get(1).map(|s| file.resolve(s)).and_then(Obj::dict).and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize; + match n { + 1 => Cs::Gray, + 4 => Cs::Cmyk, + _ => Cs::Rgb, + } + } + "CalRGB" => Cs::Rgb, + "CalGray" => Cs::Gray, + "Lab" => Cs::Lab, + "Pattern" => Cs::Pattern, + "Indexed" | "I" => { + let base = a.get(1).map(|b| color_space(file, b, res)).unwrap_or(Cs::Rgb); + let hival = a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize; + let lookup = match a.get(3).map(|l| file.resolve(l)) { + Some(Obj::Str(s)) => s.clone(), + Some(Obj::Stream(d, raw)) => decode_stream(file, d, raw).unwrap_or_default(), + _ => vec![], + }; + Cs::Indexed { base: Box::new(base), hival, lookup } + } + "Separation" | "DeviceN" => { + let n = if head == "Separation" { 1 } else { a.get(1).map(|x| file.resolve(x)).and_then(Obj::array).map_or(1, <[Obj]>::len) }; + let alt = a.get(2).map(|b| color_space(file, b, res)).unwrap_or(Cs::Gray); + let func = a.get(3).map(|f| Func::read(file, f)).unwrap_or(Func::Unsupported); + // An unreadable tint transform: tint 1 = black, 0 = white. + if func == Func::Unsupported { + return Cs::Tint { n, alt: Box::new(Cs::Gray), func: Func::Exp { domain: [0.0, 1.0], c0: vec![1.0], c1: vec![0.0], n: 1.0 } }; + } + Cs::Tint { n, alt: Box::new(alt), func } + } + _ => Cs::Rgb, + } + } + _ => Cs::Gray, + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Func { + /// Type 2. + Exp { + domain: [f64; 2], + c0: Vec, + c1: Vec, + n: f64, + }, + /// Type 3. + Stitch { + domain: [f64; 2], + funcs: Vec, + bounds: Vec, + encode: Vec, + }, + /// Type 0 with one input. + Sampled { + domain: [f64; 2], + size: usize, + outputs: usize, + encode: [f64; 2], + decode: Vec, + samples: Vec, + }, + /// An array of one-output functions. + Array(Vec), + Unsupported, +} + +impl Func { + pub fn read(file: &File, o: &Obj) -> Func { + let o = file.resolve(o); + if let Obj::Array(a) = o { + return Func::Array(a.iter().map(|f| Func::read(file, f)).collect()); + } + let Some(d) = o.dict() else { return Func::Unsupported }; + let dom = file.get(d, "Domain").map(|x| file.nums(x)).unwrap_or_default(); + let domain = [dom.first().copied().unwrap_or(0.0), dom.get(1).copied().unwrap_or(1.0)]; + match file.get_num(d, "FunctionType").unwrap_or(-1.0) as i32 { + 2 => Func::Exp { + domain, + c0: file.get(d, "C0").map(|x| file.nums(x)).unwrap_or_else(|| vec![0.0]), + c1: file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]), + n: file.get_num(d, "N").unwrap_or(1.0), + }, + 3 => Func::Stitch { + domain, + funcs: file.get(d, "Functions").and_then(Obj::array).map(|a| a.iter().map(|f| Func::read(file, f)).collect()).unwrap_or_default(), + bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(), + encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(), + }, + 0 => { + let Obj::Stream(sd, raw) = o else { return Func::Unsupported }; + let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported }; + let size = file.get(d, "Size").map(|x| file.nums(x)).and_then(|v| v.first().copied()).unwrap_or(0.0) as usize; + let bps = file.get_num(d, "BitsPerSample").unwrap_or(8.0) as usize; + let range = file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default(); + let outputs = range.len() / 2; + if size == 0 || outputs == 0 || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) { + return Func::Unsupported; + } + let enc = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(); + let encode = [enc.first().copied().unwrap_or(0.0), enc.get(1).copied().unwrap_or(size as f64 - 1.0)]; + let decode = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= outputs * 2).unwrap_or_else(|| range.clone()); + let max = ((1u64 << bps) - 1) as f64; + let mut samples = Vec::with_capacity(size * outputs); + let mut bit = 0usize; + for _ in 0..size * outputs { + let mut v = 0u64; + for _ in 0..bps { + let byte = data.get(bit / 8).copied().unwrap_or(0); + v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64; + bit += 1; + } + samples.push(v as f64 / max); + } + Func::Sampled { domain, size, outputs, encode, decode, samples } + } + _ => Func::Unsupported, + } + } + + /// Evaluate at the first input (the functions used by shadings and tints take one input + /// in practice; extra inputs of DeviceN tints are averaged in). + pub fn eval(&self, input: &[f64]) -> Vec { + let t = input.first().copied().unwrap_or(0.0); + self.eval1(t) + } + + pub fn eval1(&self, t: f64) -> Vec { + match self { + Func::Exp { domain, c0, c1, n } => { + let t = t.clamp(domain[0].min(domain[1]), domain[0].max(domain[1])); + let x = if *n == 1.0 { t } else { t.max(0.0).powf(*n) }; + (0..c0.len().max(c1.len())) + .map(|i| c0.get(i).copied().unwrap_or(0.0) + x * (c1.get(i).copied().unwrap_or(1.0) - c0.get(i).copied().unwrap_or(0.0))) + .collect() + } + Func::Stitch { domain, funcs, bounds, encode } => { + if funcs.is_empty() { + return vec![0.0]; + } + let t = t.clamp(domain[0], domain[1]); + let k = bounds.iter().take_while(|b| t >= **b).count().min(funcs.len() - 1); + let lo = if k == 0 { domain[0] } else { bounds[k - 1] }; + let hi = bounds.get(k).copied().unwrap_or(domain[1]); + let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0)); + let u = if hi > lo { e0 + (t - lo) / (hi - lo) * (e1 - e0) } else { e0 }; + funcs[k].eval1(u) + } + Func::Sampled { domain, size, outputs, encode, decode, samples } => { + let t = t.clamp(domain[0], domain[1]); + let e = if domain[1] > domain[0] { encode[0] + (t - domain[0]) / (domain[1] - domain[0]) * (encode[1] - encode[0]) } else { encode[0] }; + let e = e.clamp(0.0, *size as f64 - 1.0); + let (i0, f) = (e.floor() as usize, e.fract()); + let i1 = (i0 + 1).min(size - 1); + (0..*outputs) + .map(|o| { + let s = samples[i0 * outputs + o] * (1.0 - f) + samples[i1 * outputs + o] * f; + let (d0, d1) = (decode.get(2 * o).copied().unwrap_or(0.0), decode.get(2 * o + 1).copied().unwrap_or(1.0)); + d0 + s * (d1 - d0) + }) + .collect() + } + Func::Array(fs) => fs.iter().map(|f| f.eval1(t).first().copied().unwrap_or(0.0)).collect(), + Func::Unsupported => vec![0.5], + } + } + + /// Input values worth sampling exactly (stitching bounds). + pub fn breakpoints(&self) -> Vec { + match self { + Func::Stitch { bounds, .. } => bounds.clone(), + _ => vec![], + } + } +} + +/// PDF text string → Rust string (UTF-16BE with a byte-order mark, else PDFDocEncoding ≈ Latin-1). +pub fn text_string(b: &[u8]) -> String { + if b.starts_with(&[0xFE, 0xFF]) { + let u: Vec = b[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + return String::from_utf16_lossy(&u); + } + if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8_lossy(rest).into_owned(); + } + b.iter().map(|&c| c as char).collect() +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs new file mode 100644 index 00000000..cd835113 --- /dev/null +++ b/crates/pdf/src/lib.rs @@ -0,0 +1,185 @@ +//! Vector footage: PDF, PDF-compatible Illustrator files (`.ai`) and Encapsulated PostScript +//! (see README.md for what is read and the specifications used). +//! +//! Files parse into the same render tree as SVG ([`effectcraft_svg::Doc`]): document pixels are +//! PostScript points (1/72 in), y down, so footage rasterises at any scale (Continuously +//! Rasterize) and converts to shape layers (Create Shapes from Vector Layer). The root group's +//! children are the document's **layers**: one group per top-level optional-content group +//! (Illustrator layers), with content outside them gathered into groups of its own. + +mod build; +mod color; +mod object; +mod page; +mod ps; +pub mod write; + +pub use effectcraft_svg::Doc; +use effectcraft_svg::{Affine, Group, Node}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Error { + NotVector, + Encrypted, + NoPages, + PageOutOfRange(usize), + NoBoundingBox, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotVector => write!(f, "not a PDF, Illustrator or EPS file"), + Error::Encrypted => write!(f, "encrypted PDF files are not supported"), + Error::NoPages => write!(f, "the PDF has no pages"), + Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n + 1), + Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"), + } + } +} + +impl std::error::Error for Error {} + +/// File extensions read as vector footage by this crate. +pub const EXTENSIONS: &[&str] = &["pdf", "ai", "eps", "epsf", "epsi"]; + +/// The format of a file, by its first bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Pdf, + Eps, +} + +/// Sniff a PDF (`%PDF-` near the start, as PDF-compatible `.ai` files are) or an EPS file. +pub fn sniff(bytes: &[u8]) -> Option { + let head = &bytes[..bytes.len().min(1024)]; + if head.windows(5).any(|w| w == b"%PDF-") { + return Some(Format::Pdf); + } + if bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) || bytes.starts_with(b"%!PS") { + return Some(Format::Eps); + } + None +} + +/// A footage codec name for a sniffed file ("PDF", "AI", "EPS"). +pub fn codec(path: &str, bytes: &[u8]) -> Option<&'static str> { + let f = sniff(bytes)?; + let ai = path.to_ascii_lowercase().ends_with(".ai"); + Some(match (f, ai) { + (_, true) => "AI", + (Format::Pdf, false) => "PDF", + (Format::Eps, false) => "EPS", + }) +} + +/// Parse the first page of a PDF / `.ai`, or an EPS file. +pub fn parse(bytes: &[u8]) -> Result { + parse_page(bytes, 0) +} + +/// Parse page `index` of a PDF (EPS files have one page). +pub fn parse_page(bytes: &[u8], index: usize) -> Result { + match sniff(bytes) { + Some(Format::Pdf) => parse_pdf(bytes, index), + Some(Format::Eps) => parse_eps(bytes), + None => Err(Error::NotVector), + } +} + +/// Number of pages (1 for EPS). +pub fn page_count(bytes: &[u8]) -> usize { + match sniff(bytes) { + Some(Format::Pdf) => page::pages(&object::File::parse(bytes)).len(), + Some(Format::Eps) => 1, + None => 0, + } +} + +/// Build the document: `root` children become layer groups (see the crate docs). +fn finish(nodes: Vec, flags: Vec, skipped: Vec, width: f64, height: f64, to_doc: Affine) -> Doc { + let mut layers: Vec = vec![]; + let mut loose: Vec = vec![]; + let flush = |loose: &mut Vec, layers: &mut Vec| { + if !loose.is_empty() { + let mut g = build::group(""); + g.children = std::mem::take(loose); + layers.push(Node::Group(g)); + } + }; + for (n, is_layer) in nodes.into_iter().zip(flags.into_iter().chain(std::iter::repeat(false))) { + if is_layer { + flush(&mut loose, &mut layers); + layers.push(n); + } else { + loose.push(n); + } + } + flush(&mut loose, &mut layers); + // Name unnamed layers "Layer N" (bottom first, as Illustrator numbers them). + for (i, l) in layers.iter_mut().enumerate() { + if let Node::Group(g) = l + && g.name.is_empty() + { + g.name = format!("Layer {}", i + 1); + } + } + Doc { width, height, root: Group { name: "page".into(), transform: to_doc, opacity: 1.0, children: layers, clip: vec![] }, skipped } +} + +fn parse_pdf(bytes: &[u8], index: usize) -> Result { + let file = object::File::parse(bytes); + if file.encrypted() { + return Err(Error::Encrypted); + } + let pages = page::pages(&file); + if pages.is_empty() { + return Err(Error::NoPages); + } + let pg = pages.get(index).ok_or(Error::PageOutOfRange(index))?; + let [x0, y0, x1, y1] = pg.bbox; + let (w, h) = (x1 - x0, y1 - y0); + // Default user space (y up) → document pixels (y down), with the page's /Rotate. + let (to_doc, dw, dh) = match pg.rotate { + 90 => (Affine::new([0.0, 1.0, 1.0, 0.0, -y0, -x0]), h, w), + 180 => (Affine::new([-1.0, 0.0, 0.0, 1.0, x1, -y0]), w, h), + 270 => (Affine::new([0.0, -1.0, -1.0, 0.0, y1, x1]), h, w), + _ => (Affine::new([1.0, 0.0, 0.0, -1.0, -x0, y1]), w, h), + }; + let content = page::page_content(&file, pg); + let mut it = page::Interp::new(&file, pg.bbox); + it.run(&content, &pg.resources, Affine::IDENTITY, 0); + let (nodes, flags, skipped) = it.b.finish(); + Ok(finish(nodes, flags, skipped, dw.max(1.0), dh.max(1.0), to_doc)) +} + +fn parse_eps(bytes: &[u8]) -> Result { + let (ps, bb) = ps::eps_parts(bytes).ok_or(Error::NoBoundingBox)?; + let mut it = ps::Interp::new(bb); + it.run(ps); + let (nodes, flags, skipped) = it.b.finish(); + let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -bb[0], bb[3]]); + Ok(finish(nodes, flags, skipped, (bb[2] - bb[0]).max(1.0), (bb[3] - bb[1]).max(1.0), to_doc)) +} + +/// The document's layer names (the root's children), bottom first. +pub fn layer_names(doc: &Doc) -> Vec { + doc.root + .children + .iter() + .map(|n| match n { + Node::Group(g) => g.name.clone(), + Node::Shape(s) => s.name.clone(), + }) + .collect() +} + +/// The document with only layer `index` (for one layer of a file imported as a composition). +pub fn layer_doc(doc: &Doc, index: usize) -> Doc { + let mut d = doc.clone(); + d.root.children = doc.root.children.get(index).cloned().into_iter().collect(); + d +} + +#[cfg(test)] +mod tests; diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs new file mode 100644 index 00000000..e24f6ec8 --- /dev/null +++ b/crates/pdf/src/object.rs @@ -0,0 +1,708 @@ +//! PDF objects (ISO 32000-1 §7.2–7.5): the lexer, the object model, stream filters and the +//! indirect-object table. +//! +//! The table is built by scanning the file for `n g obj` headers (later definitions win, as +//! incremental updates do) and by unpacking object streams, so damaged or rewritten +//! cross-reference sections do not matter. + +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq)] +pub enum Obj { + Null, + Bool(bool), + Num(f64), + Name(String), + Str(Vec), + Array(Vec), + Dict(Dict), + Ref(u32, u16), + /// A stream: its dictionary and raw (still encoded) data. + Stream(Dict, Vec), + /// A content-stream operator (or any bare keyword). + Op(String), +} + +pub type Dict = HashMap; + +impl Obj { + pub fn num(&self) -> Option { + match self { + Obj::Num(n) => Some(*n), + _ => None, + } + } + pub fn name(&self) -> Option<&str> { + match self { + Obj::Name(n) => Some(n), + _ => None, + } + } + pub fn array(&self) -> Option<&[Obj]> { + match self { + Obj::Array(a) => Some(a), + _ => None, + } + } + pub fn dict(&self) -> Option<&Dict> { + match self { + Obj::Dict(d) | Obj::Stream(d, _) => Some(d), + _ => None, + } + } +} + +pub fn is_white(c: u8) -> bool { + matches!(c, 0 | 9 | 10 | 12 | 13 | 32) +} +pub fn is_delim(c: u8) -> bool { + matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') +} + +/// A byte lexer producing PDF objects (arrays and dictionaries nested). +pub struct Lexer<'a> { + pub data: &'a [u8], + pub pos: usize, +} + +impl<'a> Lexer<'a> { + pub fn new(data: &'a [u8], pos: usize) -> Self { + Lexer { data, pos } + } + + pub fn skip_ws(&mut self) { + while self.pos < self.data.len() { + let c = self.data[self.pos]; + if is_white(c) { + self.pos += 1; + } else if c == b'%' { + while self.pos < self.data.len() && !matches!(self.data[self.pos], b'\n' | b'\r') { + self.pos += 1; + } + } else { + break; + } + } + } + + fn word(&mut self) -> &'a [u8] { + let s = self.pos; + while self.pos < self.data.len() && !is_white(self.data[self.pos]) && !is_delim(self.data[self.pos]) { + self.pos += 1; + } + &self.data[s..self.pos] + } + + /// The next object, `None` at the end. `]` and `>>` come back as `Op`s. + pub fn next(&mut self) -> Option { + self.next_depth(0) + } + + fn next_depth(&mut self, depth: usize) -> Option { + self.skip_ws(); + let c = *self.data.get(self.pos)?; + match c { + b'/' => { + self.pos += 1; + let w = self.word(); + Some(Obj::Name(decode_name(w))) + } + b'(' => Some(Obj::Str(self.literal_string())), + b'<' if self.data.get(self.pos + 1) == Some(&b'<') => { + self.pos += 2; + let mut d = Dict::new(); + loop { + if depth > 64 { + return Some(Obj::Null); + } + let k = self.next_depth(depth + 1); + match k { + None => break, + Some(Obj::Op(o)) if o == ">>" => break, + Some(Obj::Name(k)) => { + let v = self.next_depth(depth + 1).unwrap_or(Obj::Null); + let v = self.maybe_ref(v); + d.insert(k, v); + } + Some(_) => {} + } + } + Some(Obj::Dict(d)) + } + b'<' => { + self.pos += 1; + let mut hex = vec![]; + while self.pos < self.data.len() && self.data[self.pos] != b'>' { + let h = self.data[self.pos]; + if h.is_ascii_hexdigit() { + hex.push(h); + } + self.pos += 1; + } + self.pos += 1; + if hex.len() % 2 == 1 { + hex.push(b'0'); + } + Some(Obj::Str(hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect())) + } + b'>' if self.data.get(self.pos + 1) == Some(&b'>') => { + self.pos += 2; + Some(Obj::Op(">>".into())) + } + b'[' => { + self.pos += 1; + let mut a = vec![]; + loop { + if depth > 64 { + return Some(Obj::Null); + } + match self.next_depth(depth + 1) { + None => break, + Some(Obj::Op(o)) if o == "]" => break, + Some(v) => { + let v = self.maybe_ref(v); + a.push(v); + } + } + } + Some(Obj::Array(a)) + } + b']' | b'{' | b'}' | b')' | b'>' => { + self.pos += 1; + Some(Obj::Op((c as char).to_string())) + } + _ => { + let w = self.word(); + if w.is_empty() { + self.pos += 1; + return Some(Obj::Op(String::new())); + } + Some(match w { + b"true" => Obj::Bool(true), + b"false" => Obj::Bool(false), + b"null" => Obj::Null, + _ => match parse_num(w) { + Some(n) => Obj::Num(n), + None => Obj::Op(String::from_utf8_lossy(w).into_owned()), + }, + }) + } + } + } + + /// `n g R` after a number inside arrays and dictionaries. + fn maybe_ref(&mut self, v: Obj) -> Obj { + let Obj::Num(n) = v else { return v }; + if n.fract() != 0.0 || n < 0.0 { + return v; + } + let save = self.pos; + self.skip_ws(); + let gp = self.pos; + let g = self.word(); + if let Some(gn) = parse_num(g).filter(|g| g.fract() == 0.0 && *g >= 0.0) { + self.skip_ws(); + if self.data.get(self.pos) == Some(&b'R') && self.data.get(self.pos + 1).is_none_or(|c| is_white(*c) || is_delim(*c)) { + self.pos += 1; + return Obj::Ref(n as u32, gn as u16); + } + } + let _ = gp; + self.pos = save; + v + } + + fn literal_string(&mut self) -> Vec { + self.pos += 1; + let mut out = vec![]; + let mut depth = 1; + while self.pos < self.data.len() { + let c = self.data[self.pos]; + self.pos += 1; + match c { + b'\\' => { + let Some(&e) = self.data.get(self.pos) else { break }; + self.pos += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(8), + b'f' => out.push(12), + b'\r' => { + if self.data.get(self.pos) == Some(&b'\n') { + self.pos += 1; + } + } + b'\n' => {} + b'0'..=b'7' => { + let mut v = (e - b'0') as u32; + for _ in 0..2 { + match self.data.get(self.pos) { + Some(d @ b'0'..=b'7') => { + v = v * 8 + (d - b'0') as u32; + self.pos += 1; + } + _ => break, + } + } + out.push(v as u8); + } + other => out.push(other), + } + } + b'(' => { + depth += 1; + out.push(c); + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + out.push(c); + } + _ => out.push(c), + } + } + out + } +} + +fn hexv(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + b'A'..=b'F' => c - b'A' + 10, + _ => 0, + } +} + +fn decode_name(w: &[u8]) -> String { + let mut out = vec![]; + let mut i = 0; + while i < w.len() { + if w[i] == b'#' && w.get(i + 1).is_some_and(u8::is_ascii_hexdigit) && w.get(i + 2).is_some_and(u8::is_ascii_hexdigit) { + out.push((hexv(w[i + 1]) << 4) | hexv(w[i + 2])); + i += 3; + } else { + out.push(w[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +pub fn parse_num(w: &[u8]) -> Option { + if w.is_empty() || !w.iter().all(|c| c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.')) || !w.iter().any(u8::is_ascii_digit) { + return None; + } + let s = std::str::from_utf8(w).ok()?; + // "--5" and similar malformed numbers: take what parses. + s.parse::().ok().or_else(|| s.trim_start_matches(['+', '-']).parse::().ok().map(|v| if s.starts_with('-') { -v } else { v })) +} + +// ------------------------------------------------------------------ filters + +/// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT, +/// JBIG2) give `None`. +pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option> { + let filters: Vec = match d.get("Filter").map(|f| file.resolve(f)) { + None => vec![], + Some(Obj::Name(n)) => vec![n.clone()], + Some(Obj::Array(a)) => a.iter().filter_map(|f| file.resolve(f).name().map(str::to_string)).collect(), + _ => vec![], + }; + let parms: Vec> = match d.get("DecodeParms").or_else(|| d.get("DP")).map(|p| file.resolve(p)) { + Some(Obj::Dict(p)) => vec![Some(p.clone())], + Some(Obj::Array(a)) => a.iter().map(|p| file.resolve(p).dict().cloned()).collect(), + _ => vec![], + }; + let mut data = raw.to_vec(); + for (i, f) in filters.iter().enumerate() { + data = match f.as_str() { + "FlateDecode" | "Fl" => inflate(&data)?, + "ASCIIHexDecode" | "AHx" => ascii_hex(&data), + "ASCII85Decode" | "A85" => ascii85(&data), + "LZWDecode" | "LZW" => { + lzw(&data, parms.get(i).and_then(|p| p.as_ref()).and_then(|p| p.get("EarlyChange")).and_then(Obj::num).unwrap_or(1.0) != 0.0) + } + "RunLengthDecode" | "RL" => run_length(&data), + _ => return None, + }; + if let Some(Some(p)) = parms.get(i) + && matches!(f.as_str(), "FlateDecode" | "Fl" | "LZWDecode" | "LZW") + { + data = predict(&data, p); + } + } + Some(data) +} + +pub fn inflate(data: &[u8]) -> Option> { + miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| { + // Truncated streams: keep what inflates. + let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib); + let mut out = vec![0u8; data.len() * 8 + 1024]; + let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish); + (r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec()) + }) +} + +fn ascii_hex(data: &[u8]) -> Vec { + let mut hex: Vec = data.iter().copied().take_while(|c| *c != b'>').filter(u8::is_ascii_hexdigit).collect(); + if hex.len() % 2 == 1 { + hex.push(b'0'); + } + hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect() +} + +pub fn ascii85(data: &[u8]) -> Vec { + let mut out = vec![]; + let mut group = [0u32; 5]; + let mut n = 0; + let mut i = 0; + if data.starts_with(b"<~") { + i = 2; + } + while i < data.len() { + let c = data[i]; + i += 1; + match c { + b'~' => break, + b'z' if n == 0 => out.extend_from_slice(&[0; 4]), + b'!'..=b'u' => { + group[n] = (c - b'!') as u32; + n += 1; + if n == 5 { + let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d)); + out.extend_from_slice(&v.to_be_bytes()); + n = 0; + } + } + _ => {} + } + } + if n > 1 { + for g in group.iter_mut().skip(n) { + *g = 84; + } + let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d)); + out.extend_from_slice(&v.to_be_bytes()[..n - 1]); + } + out +} + +fn lzw(data: &[u8], early: bool) -> Vec { + let mut out = vec![]; + let mut table: Vec> = (0..=255u16).map(|b| vec![b as u8]).collect(); + table.push(vec![]); + table.push(vec![]); + let (mut bits, mut acc, mut nbits) = (9u32, 0u32, 0u32); + let mut prev: Option> = None; + for &b in data { + acc = (acc << 8) | b as u32; + nbits += 8; + while nbits >= bits { + let code = ((acc >> (nbits - bits)) & ((1 << bits) - 1)) as usize; + nbits -= bits; + match code { + 256 => { + table.truncate(258); + bits = 9; + prev = None; + continue; + } + 257 => return out, + _ => {} + } + let entry = if code < table.len() { + table[code].clone() + } else if let Some(p) = &prev { + let mut e = p.clone(); + e.push(p[0]); + e + } else { + return out; + }; + out.extend_from_slice(&entry); + if let Some(p) = prev { + let mut e = p; + e.push(entry[0]); + table.push(e); + } + prev = Some(entry); + let limit = table.len() + usize::from(early); + if limit >= (1 << bits) && bits < 12 { + bits += 1; + } + } + } + out +} + +fn run_length(data: &[u8]) -> Vec { + let mut out = vec![]; + let mut i = 0; + while i < data.len() { + let l = data[i] as usize; + i += 1; + if l < 128 { + out.extend_from_slice(&data[i.min(data.len())..(i + l + 1).min(data.len())]); + i += l + 1; + } else if l > 128 { + if let Some(&b) = data.get(i) { + out.extend(std::iter::repeat_n(b, 257 - l)); + } + i += 1; + } else { + break; + } + } + out +} + +/// PNG / TIFF predictors (`/Predictor`, `/Colors`, `/BitsPerComponent`, `/Columns`). +fn predict(data: &[u8], p: &Dict) -> Vec { + let g = |k: &str, d: f64| p.get(k).and_then(Obj::num).unwrap_or(d) as usize; + let pred = g("Predictor", 1.0); + if pred < 10 { + return data.to_vec(); + } + let bpp = (g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8).max(1); + let row = (g("Columns", 1.0) * g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8); + let mut out = Vec::with_capacity(data.len()); + let mut prev = vec![0u8; row]; + for chunk in data.chunks(row + 1) { + if chunk.len() < row + 1 { + break; + } + let ft = chunk[0]; + let mut cur = chunk[1..].to_vec(); + for i in 0..row { + let a = if i >= bpp { cur[i - bpp] as i32 } else { 0 }; + let b = prev[i] as i32; + let c = if i >= bpp { prev[i - bpp] as i32 } else { 0 }; + let add = match ft { + 1 => a, + 2 => b, + 3 => (a + b) / 2, + 4 => { + let pp = a + b - c; + let (pa, pb, pc) = ((pp - a).abs(), (pp - b).abs(), (pp - c).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } + } + _ => 0, + }; + cur[i] = (cur[i] as i32 + add) as u8; + } + out.extend_from_slice(&cur); + prev = cur; + } + out +} + +// ------------------------------------------------------------------ the file + +/// Every indirect object of a PDF file, plus the trailer dictionaries found. +pub struct File { + pub objects: HashMap, + pub trailers: Vec, +} + +fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + if from >= hay.len() || needle.is_empty() { + return None; + } + hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from) +} + +impl File { + pub fn parse(data: &[u8]) -> File { + let mut f = File { objects: HashMap::new(), trailers: vec![] }; + let mut i = 0; + while let Some(p) = find(data, b"obj", i) { + i = p + 3; + if p >= 3 && &data[p - 3..p] == b"end" { + continue; + } + if data.get(p + 3).is_some_and(|c| !is_white(*c) && !is_delim(*c)) { + continue; + } + // Walk back over "num gen ". + let mut q = p; + let back_ws = |q: &mut usize| { + let s = *q; + while *q > 0 && is_white(data[*q - 1]) { + *q -= 1; + } + *q < s + }; + let back_digits = |q: &mut usize| { + let e = *q; + while *q > 0 && data[*q - 1].is_ascii_digit() { + *q -= 1; + } + (*q < e).then(|| std::str::from_utf8(&data[*q..e]).ok()?.parse::().ok()).flatten() + }; + if !back_ws(&mut q) { + continue; + } + let Some(_gen) = back_digits(&mut q) else { continue }; + if !back_ws(&mut q) { + continue; + } + let Some(num) = back_digits(&mut q) else { continue }; + if q > 0 && !is_white(data[q - 1]) && !is_delim(data[q - 1]) { + continue; + } + let mut lx = Lexer::new(data, p + 3); + let Some(obj) = lx.next() else { continue }; + let obj = lx.maybe_ref(obj); + let obj = match obj { + Obj::Dict(d) => { + let save = lx.pos; + lx.skip_ws(); + if data[lx.pos..].starts_with(b"stream") { + let mut s = lx.pos + 6; + if data.get(s) == Some(&b'\r') { + s += 1; + } + if data.get(s) == Some(&b'\n') { + s += 1; + } + let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize); + let end = match declared.filter(|l| { + s + l <= data.len() && { + let mut k = s + l; + while k < data.len() && is_white(data[k]) { + k += 1; + } + data[k..].starts_with(b"endstream") + } + }) { + Some(l) => s + l, + None => { + let mut e = find(data, b"endstream", s).unwrap_or(data.len()); + if e > s && data[e - 1] == b'\n' { + e -= 1; + } + if e > s && data[e - 1] == b'\r' { + e -= 1; + } + e + } + }; + i = end; + Obj::Stream(d, data[s..end].to_vec()) + } else { + lx.pos = save; + Obj::Dict(d) + } + } + o => o, + }; + if let Obj::Stream(d, _) = &obj + && d.get("Type").and_then(Obj::name) == Some("XRef") + { + f.trailers.push(d.clone()); + } + f.objects.insert(num, obj); + } + // Trailer dictionaries of classic cross-reference sections. + let mut i = 0; + while let Some(p) = find(data, b"trailer", i) { + i = p + 7; + let mut lx = Lexer::new(data, i); + if let Some(Obj::Dict(d)) = lx.next() { + f.trailers.push(d); + } + } + // Object streams. + let streams: Vec<(Dict, Vec)> = f + .objects + .values() + .filter_map(|o| match o { + Obj::Stream(d, raw) if d.get("Type").and_then(Obj::name) == Some("ObjStm") => Some((d.clone(), raw.clone())), + _ => None, + }) + .collect(); + for (d, raw) in streams { + let Some(dec) = decode_stream(&f, &d, &raw) else { continue }; + let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize; + let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize; + let mut lx = Lexer::new(&dec, 0); + let mut heads = vec![]; + for _ in 0..n.min(100_000) { + let (Some(Obj::Num(num)), Some(Obj::Num(off))) = (lx.next(), lx.next()) else { break }; + heads.push((num as u32, off as usize)); + } + for (num, off) in heads { + if f.objects.contains_key(&num) || first + off >= dec.len() { + continue; + } + let mut lx = Lexer::new(&dec, first + off); + if let Some(o) = lx.next() { + let o = lx.maybe_ref(o); + f.objects.insert(num, o); + } + } + } + f + } + + /// Follow references (bounded). + pub fn resolve<'a>(&'a self, o: &'a Obj) -> &'a Obj { + let mut o = o; + for _ in 0..32 { + match o { + Obj::Ref(n, _) => match self.objects.get(n) { + Some(x) => o = x, + None => return &Obj::Null, + }, + _ => return o, + } + } + &Obj::Null + } + + pub fn get<'a>(&'a self, d: &'a Dict, key: &str) -> Option<&'a Obj> { + d.get(key).map(|o| self.resolve(o)).filter(|o| !matches!(o, Obj::Null)) + } + + pub fn get_dict<'a>(&'a self, d: &'a Dict, key: &str) -> Option<&'a Dict> { + self.get(d, key)?.dict() + } + + pub fn get_num(&self, d: &Dict, key: &str) -> Option { + self.get(d, key)?.num() + } + + /// Numbers of an array (references resolved). + pub fn nums(&self, o: &Obj) -> Vec { + self.resolve(o).array().map(|a| a.iter().filter_map(|x| self.resolve(x).num()).collect()).unwrap_or_default() + } + + /// The document catalog. + pub fn catalog(&self) -> Option<&Dict> { + for t in self.trailers.iter().rev() { + if let Some(Obj::Dict(d)) = t.get("Root").map(|r| self.resolve(r)) { + return Some(d); + } + } + let mut cats: Vec<(&u32, &Obj)> = + self.objects.iter().filter(|(_, o)| matches!(o, Obj::Dict(d) if d.get("Type").and_then(Obj::name) == Some("Catalog"))).collect(); + cats.sort_by_key(|(n, _)| **n); + cats.last().and_then(|(_, o)| o.dict()) + } + + pub fn encrypted(&self) -> bool { + self.trailers.iter().any(|t| t.contains_key("Encrypt")) + } +} diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs new file mode 100644 index 00000000..9a67f85c --- /dev/null +++ b/crates/pdf/src/page.rs @@ -0,0 +1,573 @@ +//! Pages and content streams (ISO 32000-1 §7.7.3, §8): the page tree, the graphics state, +//! path construction and painting, clipping, colour, shading patterns and `sh`, form XObjects +//! and optional-content marked sequences. Text, images and tiling patterns are skipped (listed +//! in [`effectcraft_svg::Doc::skipped`]). + +use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Gradient, GradientKind, Join, Paint, Spread, Stroke}; +use kurbo::Point; + +use crate::build::Builder; +use crate::color::{Cs, Func, color_space, text_string}; +use crate::object::{Dict, File, Lexer, Obj, decode_stream}; + +/// A page: its dictionary with inherited attributes resolved. +pub struct Page { + pub dict: Dict, + pub resources: Dict, + /// CropBox (else MediaBox) `[x0, y0, x1, y1]`. + pub bbox: [f64; 4], + pub rotate: i32, +} + +/// The pages in order. +pub fn pages(file: &File) -> Vec { + let mut out = vec![]; + let Some(cat) = file.catalog() else { return out }; + let Some(root) = file.get_dict(cat, "Pages") else { return out }; + fn walk(file: &File, node: &Dict, inherited: (Option, Option>, Option>, i32), out: &mut Vec, depth: usize) { + if depth > 32 || out.len() > 10_000 { + return; + } + let res = file.get_dict(node, "Resources").cloned().or(inherited.0); + let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.1); + let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.2); + let rot = file.get_num(node, "Rotate").map(|r| r as i32).unwrap_or(inherited.3); + match file.get(node, "Kids").and_then(Obj::array) { + Some(kids) if file.get(node, "Type").and_then(Obj::name) != Some("Page") => { + for k in kids { + if let Some(d) = file.resolve(k).dict() { + walk(file, d, (res.clone(), media.clone(), crop.clone(), rot), out, depth + 1); + } + } + } + _ => { + let b = crop.or(media).unwrap_or_else(|| vec![0.0, 0.0, 612.0, 792.0]); + let bbox = [b[0].min(b[2]), b[1].min(b[3]), b[0].max(b[2]), b[1].max(b[3])]; + out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, rotate: rot.rem_euclid(360) }); + } + } + } + walk(file, root, (None, None, None, 0), &mut out, 0); + out +} + +/// The page's content streams, decoded and joined. +pub fn page_content(file: &File, page: &Page) -> Vec { + let mut out = vec![]; + let parts: Vec<&Obj> = match page.dict.get("Contents").map(|c| file.resolve(c)) { + Some(Obj::Array(a)) => a.iter().map(|x| file.resolve(x)).collect(), + Some(o) => vec![o], + None => vec![], + }; + for p in parts { + if let Obj::Stream(d, raw) = p + && let Some(data) = decode_stream(file, d, raw) + { + out.extend_from_slice(&data); + out.push(b'\n'); + } + } + out +} + +#[derive(Clone)] +enum Fill { + Color([f64; 3]), + Paint(Paint), + None, +} + +#[derive(Clone)] +struct GState { + ctm: Affine, + fill_cs: Cs, + stroke_cs: Cs, + fill: Fill, + stroke: Fill, + lw: f64, + cap: Cap, + join: Join, + miter: f64, + dash: Option<(Vec, f64)>, + fill_alpha: f64, + stroke_alpha: f64, +} + +impl Default for GState { + fn default() -> Self { + GState { + ctm: Affine::IDENTITY, + fill_cs: Cs::Gray, + stroke_cs: Cs::Gray, + fill: Fill::Color([0.0; 3]), + stroke: Fill::Color([0.0; 3]), + lw: 1.0, + cap: Cap::Butt, + join: Join::Miter, + miter: 10.0, + dash: None, + fill_alpha: 1.0, + stroke_alpha: 1.0, + } + } +} + +pub(crate) struct Interp<'a> { + file: &'a File, + pub b: Builder, + gs: GState, + saved: Vec<(GState, usize)>, + path: BezPath, + cur: Option, + start: Option, + pending_clip: Option, + marked: Vec>, + /// The page box in default user space (for `sh`). + page_box: [f64; 4], + budget: usize, +} + +fn affine(v: &[f64]) -> Affine { + if v.len() < 6 { + return Affine::IDENTITY; + } + Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]) +} + +impl<'a> Interp<'a> { + pub fn new(file: &'a File, page_box: [f64; 4]) -> Self { + Interp { + file, + b: Builder::new(), + gs: GState::default(), + saved: vec![], + path: BezPath::new(), + cur: None, + start: None, + pending_clip: None, + marked: vec![], + page_box, + budget: 20_000_000, + } + } + + /// Run a content stream with resources `res`; `pattern_base` maps pattern space to the page + /// (the CTM where the stream's default coordinate system starts). + pub fn run(&mut self, content: &[u8], res: &Dict, pattern_base: Affine, depth: usize) { + let mut lx = Lexer::new(content, 0); + let mut args: Vec = Vec::with_capacity(8); + let mut in_text = false; + while let Some(o) = lx.next() { + let Obj::Op(op) = o else { + if args.len() < 64 { + args.push(o); + } + continue; + }; + if self.budget == 0 { + self.b.skip("content (operation limit)"); + return; + } + self.budget -= 1; + if in_text { + if op == "ET" { + in_text = false; + } + args.clear(); + continue; + } + let n = |i: usize| args.get(i).and_then(Obj::num).unwrap_or(0.0); + let nums = || args.iter().filter_map(Obj::num).collect::>(); + match op.as_str() { + "q" => self.saved.push((self.gs.clone(), self.b.depth())), + "Q" => { + if let Some((g, d)) = self.saved.pop() { + self.gs = g; + self.b.close_to(d); + } + } + "cm" => self.gs.ctm *= affine(&nums()), + "w" => self.gs.lw = n(0), + "J" => self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(n(0) as usize).min(2)], + "j" => self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(n(0) as usize).min(2)], + "M" => self.gs.miter = n(0), + "d" => { + let arr = args.first().map(|a| self.file.nums(a)).unwrap_or_default(); + self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then(|| (arr, n(1))); + } + "gs" => { + if let Some(name) = args.first().and_then(Obj::name) + && let Some(g) = self.file.get_dict(res, "ExtGState").and_then(|e| self.file.get_dict(e, name)) + { + self.ext_gstate(g); + } + } + // Path construction. + "m" => { + let p = Point::new(n(0), n(1)); + self.path.move_to(p); + self.cur = Some(p); + self.start = Some(p); + } + "l" => { + let p = Point::new(n(0), n(1)); + self.ensure_start(); + self.path.line_to(p); + self.cur = Some(p); + } + "c" => { + self.ensure_start(); + let p = Point::new(n(4), n(5)); + self.path.curve_to(Point::new(n(0), n(1)), Point::new(n(2), n(3)), p); + self.cur = Some(p); + } + "v" => { + self.ensure_start(); + let c0 = self.cur.unwrap_or_default(); + let p = Point::new(n(2), n(3)); + self.path.curve_to(c0, Point::new(n(0), n(1)), p); + self.cur = Some(p); + } + "y" => { + self.ensure_start(); + let p = Point::new(n(2), n(3)); + self.path.curve_to(Point::new(n(0), n(1)), p, p); + self.cur = Some(p); + } + "h" => { + if self.cur.is_some() { + self.path.close_path(); + self.cur = self.start; + } + } + "re" => { + let (x, y, w, h) = (n(0), n(1), n(2), n(3)); + self.path.move_to((x, y)); + self.path.line_to((x + w, y)); + self.path.line_to((x + w, y + h)); + self.path.line_to((x, y + h)); + self.path.close_path(); + self.cur = Some(Point::new(x, y)); + self.start = self.cur; + } + // Painting. + "S" => self.paint(false, None, true), + "s" => { + self.path.close_path(); + self.paint(false, None, true) + } + "f" | "F" => self.paint(true, Some(FillRule::NonZero), false), + "f*" => self.paint(true, Some(FillRule::EvenOdd), false), + "B" => self.paint(true, Some(FillRule::NonZero), true), + "B*" => self.paint(true, Some(FillRule::EvenOdd), true), + "b" => { + self.path.close_path(); + self.paint(true, Some(FillRule::NonZero), true) + } + "b*" => { + self.path.close_path(); + self.paint(true, Some(FillRule::EvenOdd), true) + } + "n" => self.paint(false, None, false), + "W" => self.pending_clip = Some(FillRule::NonZero), + "W*" => self.pending_clip = Some(FillRule::EvenOdd), + // Colour. + "g" => self.gs.fill_cs = Cs::Gray, + "G" => self.gs.stroke_cs = Cs::Gray, + "rg" => self.gs.fill_cs = Cs::Rgb, + "RG" => self.gs.stroke_cs = Cs::Rgb, + "k" => self.gs.fill_cs = Cs::Cmyk, + "K" => self.gs.stroke_cs = Cs::Cmyk, + "cs" | "CS" => { + let cs = args.first().map(|a| color_space(self.file, a, Some(res))).unwrap_or(Cs::Gray); + let init = Fill::Color(cs.to_rgb(&cs.initial())); + if op == "cs" { + self.gs.fill_cs = cs; + self.gs.fill = init; + } else { + self.gs.stroke_cs = cs; + self.gs.stroke = init; + } + } + "sc" | "scn" | "SC" | "SCN" => { + let fill = op.starts_with('s'); + let cs = if fill { self.gs.fill_cs.clone() } else { self.gs.stroke_cs.clone() }; + let v = if let (Cs::Pattern, Some(name)) = (&cs, args.last().and_then(Obj::name)) { + self.pattern(res, name, pattern_base) + } else { + Fill::Color(cs.to_rgb(&nums())) + }; + if fill { + self.gs.fill = v; + } else { + self.gs.stroke = v; + } + } + "sh" => { + if let Some(name) = args.first().and_then(Obj::name) { + self.shade(res, name); + } + } + "Do" => { + if let Some(name) = args.first().and_then(Obj::name) { + self.xobject(res, name, depth); + } + } + "BT" => { + in_text = true; + self.b.skip("text"); + } + "BI" => { + // Inline image: skip to EI. + let d = lx.data; + let mut p = lx.pos; + while p + 2 < d.len() && !(d[p] == b'I' && d[p - 1] == b'D' && crate::object::is_white(d[p + 1])) { + p += 1; + } + while p + 2 < d.len() + && !(crate::object::is_white(d[p - 1]) && d[p] == b'E' && d[p + 1] == b'I' && (p + 2 == d.len() || crate::object::is_white(d[p + 2]))) + { + p += 1; + } + lx.pos = (p + 2).min(d.len()); + self.b.skip("image"); + } + "BMC" => self.marked.push(None), + "BDC" => { + let layer = match (args.first().and_then(Obj::name), args.get(1)) { + (Some("OC"), Some(Obj::Name(prop))) => self + .file + .get_dict(res, "Properties") + .and_then(|p| self.file.get_dict(p, prop)) + .map(|ocg| self.ocg_name(ocg).unwrap_or_else(|| prop.clone())), + (Some("OC"), Some(Obj::Dict(ocg))) => self.ocg_name(ocg), + _ => None, + }; + match layer { + Some(name) => { + self.marked.push(Some(self.b.depth())); + self.b.open(&name, true); + } + None => self.marked.push(None), + } + } + "EMC" => { + if let Some(Some(d)) = self.marked.pop() { + self.b.close_to(d); + } + } + _ => {} + } + // Colour operands set by the device-space shorthands. + match op.as_str() { + "g" | "rg" | "k" => self.gs.fill = Fill::Color(self.gs.fill_cs.to_rgb(&nums())), + "G" | "RG" | "K" => self.gs.stroke = Fill::Color(self.gs.stroke_cs.to_rgb(&nums())), + _ => {} + } + args.clear(); + } + } + + fn ocg_name(&self, ocg: &Dict) -> Option { + // An optional-content membership dictionary names its groups in /OCGs. + let d = match self.file.get(ocg, "OCGs") { + Some(Obj::Dict(g)) => g, + Some(Obj::Array(a)) => a.first().map(|x| self.file.resolve(x)).and_then(Obj::dict).unwrap_or(ocg), + _ => ocg, + }; + match self.file.get(d, "Name") { + Some(Obj::Str(s)) => Some(text_string(s)), + _ => None, + } + } + + fn ensure_start(&mut self) { + if self.cur.is_none() { + self.path.move_to((0.0, 0.0)); + self.cur = Some(Point::ZERO); + self.start = self.cur; + } + } + + fn ext_gstate(&mut self, g: &Dict) { + let f = self.file; + if let Some(v) = f.get_num(g, "LW") { + self.gs.lw = v; + } + if let Some(v) = f.get_num(g, "LC") { + self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(v as usize).min(2)]; + } + if let Some(v) = f.get_num(g, "LJ") { + self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(v as usize).min(2)]; + } + if let Some(v) = f.get_num(g, "ML") { + self.gs.miter = v; + } + if let Some(v) = f.get_num(g, "CA") { + self.gs.stroke_alpha = v.clamp(0.0, 1.0); + } + if let Some(v) = f.get_num(g, "ca") { + self.gs.fill_alpha = v.clamp(0.0, 1.0); + } + if let Some(Obj::Array(d)) = f.get(g, "D") { + let arr = d.first().map(|a| f.nums(a)).unwrap_or_default(); + let ph = d.get(1).and_then(|p| f.resolve(p).num()).unwrap_or(0.0); + self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then_some((arr, ph)); + } + if f.get(g, "SMask").is_some_and(|s| !matches!(s, Obj::Name(n) if n == "None")) { + self.b.skip("soft mask"); + } + } + + fn paint_of(f: &Fill) -> Option { + match f { + Fill::Color(c) => Some(Paint::Color(*c)), + Fill::Paint(p) => Some(p.clone()), + Fill::None => None, + } + } + + fn paint(&mut self, fill: bool, rule: Option, stroke: bool) { + let path = std::mem::take(&mut self.path); + self.cur = None; + self.start = None; + let ctm = self.gs.ctm; + let fill = if fill { Self::paint_of(&self.gs.fill).map(|p| (p, self.gs.fill_alpha, rule.unwrap_or(FillRule::NonZero))) } else { None }; + let stroke = if stroke { + Self::paint_of(&self.gs.stroke).map(|p| { + // Width 0: the thinnest line the device can draw (one pixel). + let s = { (ctm.determinant().abs()).sqrt() }; + let width = if self.gs.lw <= 0.0 { if s > 0.0 { 1.0 / s } else { 1.0 } } else { self.gs.lw }; + Stroke { + paint: p, + opacity: self.gs.stroke_alpha, + width, + cap: self.gs.cap, + join: self.gs.join, + miter: self.gs.miter.max(1.0), + dash: self.gs.dash.clone(), + } + }) + } else { + None + }; + if fill.is_some() || stroke.is_some() { + self.b.fill_stroke(path.clone(), ctm, fill, stroke); + } + if let Some(r) = self.pending_clip.take() { + self.b.clip(ctm * path, r); + } + } + + /// A shading dictionary → gradient paint in shading space. + fn shading_gradient(&mut self, sh: &Dict) -> Option { + let f = self.file; + let ty = f.get_num(sh, "ShadingType").unwrap_or(0.0) as i32; + let cs = f.get(sh, "ColorSpace").map(|c| color_space(f, c, None)).unwrap_or(Cs::Rgb); + let coords = f.get(sh, "Coords").map(|c| f.nums(c)).unwrap_or_default(); + let func = f.get(sh, "Function").map(|x| Func::read(f, x)).unwrap_or(Func::Unsupported); + let dom = f.get(sh, "Domain").map(|d| f.nums(d)).filter(|d| d.len() == 2).unwrap_or_else(|| vec![0.0, 1.0]); + let kind = match (ty, coords.len()) { + (2, 4) => GradientKind::Linear { x1: coords[0], y1: coords[1], x2: coords[2], y2: coords[3] }, + (3, 6) => GradientKind::Radial { cx: coords[3], cy: coords[4], r: coords[5], fx: coords[0], fy: coords[1] }, + _ => { + self.b.skip(&format!("shading type {ty}")); + return None; + } + }; + if func == Func::Unsupported { + self.b.skip("shading function"); + } + // Sample the function: evenly, plus exactly at stitching bounds. + let mut ts: Vec = (0..=24).map(|i| i as f64 / 24.0).collect(); + for b in func.breakpoints() { + if dom[1] > dom[0] { + ts.push(((b - dom[0]) / (dom[1] - dom[0])).clamp(0.0, 1.0)); + } + } + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + let stops = ts + .into_iter() + .map(|t| { + let c = cs.to_rgb(&func.eval1(dom[0] + t * (dom[1] - dom[0]))); + (t, [c[0], c[1], c[2], 1.0]) + }) + .collect(); + Some(Gradient { kind, stops, bbox_units: false, transform: Affine::IDENTITY, spread: Spread::Pad }) + } + + fn pattern(&mut self, res: &Dict, name: &str, base: Affine) -> Fill { + let f = self.file; + let Some(p) = f.get_dict(res, "Pattern").and_then(|pp| f.get_dict(pp, name)) else { return Fill::None }; + match f.get_num(p, "PatternType").unwrap_or(0.0) as i32 { + 2 => { + let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); + let Some(sh) = f.get(p, "Shading").and_then(Obj::dict) else { return Fill::None }; + let sh = sh.clone(); + match self.shading_gradient(&sh) { + // Gradient space → shape user space (shapes carry the CTM). + Some(mut g) => { + g.transform = self.gs.ctm.inverse() * base * m; + Fill::Paint(Paint::Gradient(g)) + } + None => Fill::None, + } + } + _ => { + self.b.skip("tiling pattern"); + Fill::Color([0.5; 3]) + } + } + } + + fn shade(&mut self, res: &Dict, name: &str) { + let f = self.file; + let Some(sh) = f.get_dict(res, "Shading").and_then(|s| f.get_dict(s, name)).cloned() else { return }; + let Some(g) = self.shading_gradient(&sh) else { return }; + // Fill everything (the current clip limits it): the page box in user space. + let inv = self.gs.ctm.inverse(); + let b = self.page_box; + let mut path = BezPath::new(); + for (i, (x, y)) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].into_iter().enumerate() { + let p = inv * Point::new(x, y); + if i == 0 { + path.move_to(p); + } else { + path.line_to(p); + } + } + path.close_path(); + self.b.fill_stroke(path, self.gs.ctm, Some((Paint::Gradient(g), self.gs.fill_alpha, FillRule::NonZero)), None); + } + + fn xobject(&mut self, res: &Dict, name: &str, depth: usize) { + let f = self.file; + let Some(x) = f.get_dict(res, "XObject").and_then(|xs| xs.get(name)).map(|x| f.resolve(x)) else { return }; + let Obj::Stream(d, raw) = x else { return }; + match f.get(d, "Subtype").and_then(Obj::name) { + Some("Form") if depth < 12 => { + let Some(content) = decode_stream(f, d, raw) else { return }; + let m = f.get(d, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); + let form_res = f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone()); + self.saved.push((self.gs.clone(), self.b.depth())); + self.gs.ctm *= m; + let bb = f.get(d, "BBox").map(|b| f.nums(b)).unwrap_or_default(); + if bb.len() == 4 { + let r = kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]); + self.b.clip(self.gs.ctm * kurbo::Shape::to_path(&r, 0.1), FillRule::NonZero); + } + let base = self.gs.ctm; + let path = std::mem::take(&mut self.path); + self.run(&content, &form_res, base, depth + 1); + self.path = path; + if let Some((g, dd)) = self.saved.pop() { + self.gs = g; + self.b.close_to(dd); + } + } + Some("Image") => self.b.skip("image"), + _ => {} + } + } +} diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs new file mode 100644 index 00000000..b9b0bff4 --- /dev/null +++ b/crates/pdf/src/ps.rs @@ -0,0 +1,1462 @@ +//! Encapsulated PostScript: a small PostScript interpreter (PostScript Language Reference, 3rd +//! edition, and the EPSF 3.0 specification) covering what exported vector EPS files use: the +//! operand and dictionary stacks, procedures and `def`, arithmetic, control flow, arrays and +//! dictionaries, the graphics state, path construction (including arcs) and painting, clipping +//! and device colours. Text is skipped, as are images (their inline data is stepped over) and +//! Level 3 smooth shading (`shfill`). + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke}; +use kurbo::{PathEl, Point}; + +use crate::build::Builder; +use crate::object::{ascii85, is_white, parse_num}; + +type DictRef = Rc>>; + +#[derive(Clone)] +enum V { + Num(f64), + Bool(bool), + /// An executable name. + Name(Rc), + /// A literal name (`/name`). + Lit(Rc), + Str(Rc>>), + Proc(Rc>), + Array(Rc>>), + Dict(DictRef), + Mark, + Null, + /// `currentfile`. + File, +} + +impl V { + fn num(&self) -> Option { + match self { + V::Num(n) => Some(*n), + _ => None, + } + } + fn key(&self) -> String { + match self { + V::Name(n) | V::Lit(n) => n.to_string(), + V::Str(s) => String::from_utf8_lossy(&s.borrow()).into_owned(), + V::Num(n) => format!("{n}"), + V::Bool(b) => format!("{b}"), + _ => String::new(), + } + } + fn items(&self) -> Option> { + match self { + V::Array(a) => Some(a.borrow().clone()), + V::Proc(p) => Some(p.as_ref().clone()), + _ => None, + } + } +} + +enum Flow { + Exit, + Stop, + Quit, +} + +#[derive(Clone)] +struct GState { + ctm: Affine, + color: [f64; 3], + lw: f64, + cap: Cap, + join: Join, + miter: f64, + dash: Option<(Vec, f64)>, + path: BezPath, + cur: Option, + start: Option, + depth: usize, +} + +/// Source tokenizer (the data source for `currentfile`). +struct Src<'a> { + d: &'a [u8], + pos: usize, +} + +impl Src<'_> { + fn skip_ws(&mut self) { + while self.pos < self.d.len() { + let c = self.d[self.pos]; + if is_white(c) { + self.pos += 1; + } else if c == b'%' { + let s = self.pos; + while self.pos < self.d.len() && !matches!(self.d[self.pos], b'\n' | b'\r') { + self.pos += 1; + } + // DSC: skip embedded binary / data sections. + let line = &self.d[s..self.pos]; + if line.starts_with(b"%%BeginData:") || line.starts_with(b"%%BeginBinary:") || line.starts_with(b"%%BeginPreview") { + let end: &[u8] = if line.starts_with(b"%%BeginData:") { + b"%%EndData" + } else if line.starts_with(b"%%BeginBinary:") { + b"%%EndBinary" + } else { + b"%%EndPreview" + }; + match self.d[self.pos..].windows(end.len()).position(|w| w == end) { + Some(p) => self.pos += p, + None => self.pos = self.d.len(), + } + } + } else { + break; + } + } + } + + fn word(&mut self) -> &[u8] { + let s = self.pos; + while self.pos < self.d.len() { + let c = self.d[self.pos]; + if is_white(c) || matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') { + break; + } + self.pos += 1; + } + &self.d[s..self.pos] + } + + /// The next token; `{ … }` comes back as a whole procedure. + fn token(&mut self, depth: usize) -> Option { + self.skip_ws(); + let c = *self.d.get(self.pos)?; + Some(match c { + b'{' => { + self.pos += 1; + let mut body = vec![]; + loop { + self.skip_ws(); + match self.d.get(self.pos) { + None => break, + Some(b'}') => { + self.pos += 1; + break; + } + _ => { + if depth > 100 { + return None; + } + match self.token(depth + 1) { + Some(t) => body.push(t), + None => break, + } + } + } + } + V::Proc(Rc::new(body)) + } + b'}' => { + self.pos += 1; + V::Null + } + b'/' => { + self.pos += 1; + let imm = self.d.get(self.pos) == Some(&b'/'); + if imm { + self.pos += 1; + } + let w = String::from_utf8_lossy(self.word()).into_owned(); + // `//name` (immediately evaluated) is treated like a plain name. + if imm { V::Name(w.into()) } else { V::Lit(w.into()) } + } + b'(' => { + self.pos += 1; + let mut out = vec![]; + let mut depth = 1; + while self.pos < self.d.len() { + let c = self.d[self.pos]; + self.pos += 1; + match c { + b'\\' => { + let e = self.d.get(self.pos).copied().unwrap_or(b'\\'); + self.pos += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'0'..=b'7' => { + let mut v = (e - b'0') as u32; + for _ in 0..2 { + match self.d.get(self.pos) { + Some(d @ b'0'..=b'7') => { + v = v * 8 + (d - b'0') as u32; + self.pos += 1; + } + _ => break, + } + } + out.push(v as u8); + } + b'\n' | b'\r' => {} + o => out.push(o), + } + } + b'(' => { + depth += 1; + out.push(c); + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + out.push(c); + } + _ => out.push(c), + } + } + V::Str(Rc::new(RefCell::new(out))) + } + b'<' if self.d.get(self.pos + 1) == Some(&b'<') => { + self.pos += 2; + V::Name("<<".into()) + } + b'>' if self.d.get(self.pos + 1) == Some(&b'>') => { + self.pos += 2; + V::Name(">>".into()) + } + b'<' if self.d.get(self.pos + 1) == Some(&b'~') => { + let s = self.pos; + while self.pos + 1 < self.d.len() && !(self.d[self.pos] == b'~' && self.d[self.pos + 1] == b'>') { + self.pos += 1; + } + self.pos = (self.pos + 2).min(self.d.len()); + V::Str(Rc::new(RefCell::new(ascii85(&self.d[s..self.pos])))) + } + b'<' => { + self.pos += 1; + let mut hex = vec![]; + while self.pos < self.d.len() && self.d[self.pos] != b'>' { + if self.d[self.pos].is_ascii_hexdigit() { + hex.push(self.d[self.pos]); + } + self.pos += 1; + } + self.pos += 1; + if hex.len() % 2 == 1 { + hex.push(b'0'); + } + let h = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8; + V::Str(Rc::new(RefCell::new(hex.chunks(2).map(|p| (h(p[0]) << 4) | h(p[1])).collect()))) + } + b'[' | b']' => { + self.pos += 1; + V::Name(if c == b'[' { "[" } else { "]" }.into()) + } + b')' | b'>' => { + self.pos += 1; + V::Null + } + _ => { + let w = self.word(); + if w.is_empty() { + self.pos += 1; + return Some(V::Null); + } + match parse_num(w) { + Some(n) => V::Num(n), + None => { + // Radix numbers (16#FF). + let s = String::from_utf8_lossy(w).into_owned(); + match s.split_once('#').and_then(|(r, v)| i64::from_str_radix(v, r.parse().ok()?).ok()) { + Some(n) => V::Num(n as f64), + None => V::Name(s.into()), + } + } + } + } + }) + } + + /// Step over inline image data that follows an `image` operator reading `currentfile`. + fn skip_image_data(&mut self) { + self.skip_ws(); + if self.d[self.pos..].starts_with(b"<~") || self.d.get(self.pos).is_some_and(|c| (b'!'..=b'u').contains(c) && !c.is_ascii_hexdigit()) { + match self.d[self.pos..].windows(2).position(|w| w == b"~>") { + Some(p) => self.pos += p + 2, + None => self.pos = self.d.len(), + } + return; + } + while self.pos < self.d.len() && (self.d[self.pos].is_ascii_hexdigit() || is_white(self.d[self.pos])) { + self.pos += 1; + } + if self.d.get(self.pos) == Some(&b'>') { + self.pos += 1; + } + } +} + +pub(crate) struct Interp { + pub b: Builder, + st: Vec, + dicts: Vec, + gs: GState, + saved: Vec, + budget: usize, + page: [f64; 4], + pub unknown: Vec, +} + +fn new_dict() -> DictRef { + Rc::new(RefCell::new(HashMap::new())) +} + +fn array(v: Vec) -> V { + V::Array(Rc::new(RefCell::new(v))) +} + +fn matrix_of(v: &V) -> Option { + let a: Vec = v.items()?.iter().filter_map(V::num).collect(); + (a.len() == 6).then(|| Affine::new([a[0], a[1], a[2], a[3], a[4], a[5]])) +} + +fn matrix_val(m: Affine) -> V { + array(m.as_coeffs().iter().map(|c| V::Num(*c)).collect()) +} + +impl Interp { + pub fn new(page: [f64; 4]) -> Interp { + Interp { + b: Builder::new(), + st: vec![], + dicts: vec![new_dict()], + gs: GState { + ctm: Affine::IDENTITY, + color: [0.0; 3], + lw: 1.0, + cap: Cap::Butt, + join: Join::Miter, + miter: 10.0, + dash: None, + path: BezPath::new(), + cur: None, + start: None, + depth: 1, + }, + saved: vec![], + budget: 8_000_000, + page, + unknown: vec![], + } + } + + pub fn run(&mut self, data: &[u8]) { + let mut src = Src { d: data, pos: 0 }; + while let Some(t) = src.token(0) { + if let Err(Flow::Quit) = self.exec_token(t, &mut src) { + break; + } + } + } + + fn pop(&mut self) -> V { + self.st.pop().unwrap_or(V::Null) + } + fn popn(&mut self) -> f64 { + self.pop().num().unwrap_or(0.0) + } + fn push(&mut self, v: V) { + if self.st.len() < 100_000 { + self.st.push(v); + } + } + + fn lookup(&self, k: &str) -> Option { + self.dicts.iter().rev().find_map(|d| d.borrow().get(k).cloned()) + } + + /// A token read from the file: procedures are pushed, names executed. + fn exec_token(&mut self, t: V, src: &mut Src) -> Result<(), Flow> { + match t { + V::Name(n) => self.exec_name(&n, src), + other => { + self.push(other); + Ok(()) + } + } + } + + /// Execute a value as an object (procedures run). + fn exec_obj(&mut self, v: V, src: &mut Src) -> Result<(), Flow> { + match v { + V::Proc(p) => { + for x in p.iter() { + match x { + V::Name(n) => self.exec_name(n, src)?, + other => self.push(other.clone()), + } + } + Ok(()) + } + V::Name(n) => self.exec_name(&n, src), + other => { + self.push(other); + Ok(()) + } + } + } + + fn exec_name(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { + if self.budget == 0 { + self.b.skip("PostScript (operation limit)"); + return Err(Flow::Quit); + } + self.budget -= 1; + if let Some(v) = self.lookup(n) { + return match v { + V::Proc(_) => self.exec_obj(v, src), + other => { + self.push(other); + Ok(()) + } + }; + } + self.builtin(n, src) + } + + fn device_scale(&self) -> f64 { + self.gs.ctm.determinant().abs().sqrt() + } + + fn user_point(&mut self) -> Point { + let y = self.popn(); + let x = self.popn(); + Point::new(x, y) + } + + fn move_to(&mut self, p: Point) { + let q = self.gs.ctm * p; + self.gs.path.move_to(q); + self.gs.cur = Some(q); + self.gs.start = Some(q); + } + fn line_to(&mut self, p: Point) { + let q = self.gs.ctm * p; + if self.gs.cur.is_none() { + self.gs.path.move_to(q); + self.gs.start = Some(q); + } + self.gs.path.line_to(q); + self.gs.cur = Some(q); + } + fn curve_to(&mut self, a: Point, b: Point, c: Point) { + let m = self.gs.ctm; + if self.gs.cur.is_none() { + self.gs.path.move_to(m * a); + } + self.gs.path.curve_to(m * a, m * b, m * c); + self.gs.cur = Some(m * c); + } + fn current_user(&self) -> Point { + self.gs.ctm.inverse() * self.gs.cur.unwrap_or_default() + } + + fn arc(&mut self, neg: bool) { + let a2 = self.popn().to_radians(); + let a1 = self.popn().to_radians(); + let r = self.popn(); + let c = self.user_point(); + let mut sweep = a2 - a1; + if neg { + while sweep > 0.0 { + sweep -= std::f64::consts::TAU; + } + } else { + while sweep < 0.0 { + sweep += std::f64::consts::TAU; + } + } + let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin()); + if self.gs.cur.is_some() { + self.line_to(start); + } else { + self.move_to(start); + } + let arc = kurbo::Arc::new(c, (r, r), a1, sweep, 0.0); + let m = self.gs.ctm; + arc.to_cubic_beziers(0.1, |p1, p2, p3| { + self.gs.path.curve_to(m * p1, m * p2, m * p3); + self.gs.cur = Some(m * p3); + }); + } + + fn paint(&mut self, fill: Option, stroke: bool) { + let path = std::mem::take(&mut self.gs.path); + self.gs.cur = None; + self.gs.start = None; + let s = self.device_scale(); + let st = stroke.then(|| Stroke { + paint: Paint::Color(self.gs.color), + opacity: 1.0, + width: if self.gs.lw <= 0.0 { 1.0 } else { self.gs.lw * s }, + cap: self.gs.cap, + join: self.gs.join, + miter: self.gs.miter.max(1.0), + dash: self.gs.dash.as_ref().map(|(a, o)| (a.iter().map(|v| v * s).collect(), o * s)), + }); + self.b.fill_stroke(path, Affine::IDENTITY, fill.map(|r| (Paint::Color(self.gs.color), 1.0, r)), st); + } + + fn rect_path(&mut self) -> Vec { + // x y w h, or an array / string of numbers. + let top = self.pop(); + let nums: Vec = match &top { + V::Array(_) | V::Proc(_) => top.items().unwrap_or_default().iter().filter_map(V::num).collect(), + V::Num(h) => { + let w = self.popn(); + let y = self.popn(); + let x = self.popn(); + vec![x, y, w, *h] + } + _ => vec![], + }; + let m = self.gs.ctm; + nums.chunks_exact(4) + .map(|r| { + let mut p = BezPath::new(); + p.move_to(m * Point::new(r[0], r[1])); + p.line_to(m * Point::new(r[0] + r[2], r[1])); + p.line_to(m * Point::new(r[0] + r[2], r[1] + r[3])); + p.line_to(m * Point::new(r[0], r[1] + r[3])); + p.close_path(); + p + }) + .collect() + } + + fn grestore(&mut self) { + if let Some(g) = self.saved.pop() { + let d = g.depth; + self.gs = g; + self.b.close_to(d); + } + } + + fn builtin(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { + match n { + // Stack. + "pop" => { + self.pop(); + } + "exch" => { + let b = self.pop(); + let a = self.pop(); + self.push(b); + self.push(a); + } + "dup" => { + let a = self.st.last().cloned().unwrap_or(V::Null); + self.push(a); + } + "copy" => match self.pop() { + V::Num(k) => { + let k = (k.max(0.0) as usize).min(self.st.len()); + let tail: Vec = self.st[self.st.len() - k..].to_vec(); + self.st.extend(tail); + } + V::Array(dst) => { + let s = self.pop().items().unwrap_or_default(); + let mut d = dst.borrow_mut(); + for (i, v) in s.into_iter().enumerate() { + if i < d.len() { + d[i] = v; + } + } + drop(d); + self.push(V::Array(dst)); + } + other => self.push(other), + }, + "index" => { + let k = self.popn().max(0.0) as usize; + let v = if k < self.st.len() { self.st[self.st.len() - 1 - k].clone() } else { V::Null }; + self.push(v); + } + "roll" => { + let j = self.popn() as i64; + let k = (self.popn().max(0.0) as usize).min(self.st.len()); + if k > 0 { + let s = self.st.len() - k; + let r = j.rem_euclid(k as i64) as usize; + self.st[s..].rotate_right(r); + } + } + "clear" => self.st.clear(), + "count" => self.push(V::Num(self.st.len() as f64)), + "mark" | "[" | "<<" => self.push(V::Mark), + "cleartomark" => { + while let Some(v) = self.st.pop() { + if matches!(v, V::Mark) { + break; + } + } + } + "counttomark" => { + let k = self.st.iter().rev().position(|v| matches!(v, V::Mark)).unwrap_or(self.st.len()); + self.push(V::Num(k as f64)); + } + "]" => { + let k = self.st.iter().rev().position(|v| matches!(v, V::Mark)).unwrap_or(self.st.len()); + let items = self.st.split_off(self.st.len() - k); + self.st.pop(); + self.push(array(items)); + } + ">>" => { + let k = self.st.iter().rev().position(|v| matches!(v, V::Mark)).unwrap_or(self.st.len()); + let items = self.st.split_off(self.st.len() - k); + self.st.pop(); + let d = new_dict(); + for kv in items.chunks_exact(2) { + d.borrow_mut().insert(kv[0].key(), kv[1].clone()); + } + self.push(V::Dict(d)); + } + // Arithmetic. + "add" | "sub" | "mul" | "div" | "idiv" | "mod" | "atan" | "exp" | "max" | "min" => { + let b = self.popn(); + let a = self.popn(); + let v = match n { + "add" => a + b, + "sub" => a - b, + "mul" => a * b, + "div" => { + if b == 0.0 { + 0.0 + } else { + a / b + } + } + "idiv" => { + if b as i64 == 0 { + 0.0 + } else { + ((a as i64) / (b as i64)) as f64 + } + } + "mod" => { + if b as i64 == 0 { + 0.0 + } else { + ((a as i64) % (b as i64)) as f64 + } + } + "atan" => a.atan2(b).to_degrees().rem_euclid(360.0), + "exp" => a.powf(b), + "max" => a.max(b), + _ => a.min(b), + }; + self.push(V::Num(if v.is_finite() { v } else { 0.0 })); + } + "neg" | "abs" | "sqrt" | "sin" | "cos" | "ln" | "log" | "cvi" | "cvr" | "round" | "floor" | "ceiling" | "truncate" => { + let a = match self.pop() { + V::Str(s) => parse_num(&s.borrow()).unwrap_or(0.0), + v => v.num().unwrap_or(0.0), + }; + let v = match n { + "neg" => -a, + "abs" => a.abs(), + "sqrt" => a.max(0.0).sqrt(), + "sin" => a.to_radians().sin(), + "cos" => a.to_radians().cos(), + "ln" => a.ln(), + "log" => a.log10(), + "cvi" | "truncate" => a.trunc(), + "round" => (a + 0.5).floor(), + "floor" => a.floor(), + "ceiling" => a.ceil(), + _ => a, + }; + self.push(V::Num(if v.is_finite() { v } else { 0.0 })); + } + // Comparison and logic. + "eq" | "ne" => { + let b = self.pop(); + let a = self.pop(); + let eq = match (&a, &b) { + (V::Num(x), V::Num(y)) => x == y, + (V::Bool(x), V::Bool(y)) => x == y, + (V::Null, V::Null) => true, + (V::Array(x), V::Array(y)) => Rc::ptr_eq(x, y), + (V::Dict(x), V::Dict(y)) => Rc::ptr_eq(x, y), + (x, y) => !x.key().is_empty() && x.key() == y.key(), + }; + self.push(V::Bool(eq == (n == "eq"))); + } + "gt" | "ge" | "lt" | "le" => { + let b = self.popn(); + let a = self.popn(); + self.push(V::Bool(match n { + "gt" => a > b, + "ge" => a >= b, + "lt" => a < b, + _ => a <= b, + })); + } + "not" => match self.pop() { + V::Bool(b) => self.push(V::Bool(!b)), + v => self.push(V::Num(!(v.num().unwrap_or(0.0) as i64) as f64)), + }, + "and" | "or" | "xor" => { + let b = self.pop(); + let a = self.pop(); + match (a, b) { + (V::Bool(x), V::Bool(y)) => self.push(V::Bool(match n { + "and" => x && y, + "or" => x || y, + _ => x ^ y, + })), + (x, y) => { + let (x, y) = (x.num().unwrap_or(0.0) as i64, y.num().unwrap_or(0.0) as i64); + self.push(V::Num(match n { + "and" => x & y, + "or" => x | y, + _ => x ^ y, + } as f64)); + } + } + } + "true" => self.push(V::Bool(true)), + "false" => self.push(V::Bool(false)), + "null" => self.push(V::Null), + // Control. + "exec" => { + let v = self.pop(); + self.exec_obj(v, src)?; + } + "if" => { + let p = self.pop(); + if let V::Bool(true) = self.pop() { + self.exec_obj(p, src)?; + } + } + "ifelse" => { + let f = self.pop(); + let t = self.pop(); + let c = matches!(self.pop(), V::Bool(true)); + self.exec_obj(if c { t } else { f }, src)?; + } + "repeat" => { + let p = self.pop(); + let k = self.popn().clamp(0.0, 1e6) as usize; + for _ in 0..k { + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + "for" => { + let p = self.pop(); + let lim = self.popn(); + let inc = self.popn(); + let mut i = self.popn(); + let mut guard = 0; + while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { + self.push(V::Num(i)); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + i += inc; + guard += 1; + } + } + "loop" => { + let p = self.pop(); + for _ in 0..1_000_000 { + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + "forall" => { + let p = self.pop(); + match self.pop() { + V::Dict(d) => { + let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + for (k, v) in items { + self.push(V::Lit(k.into())); + self.push(v); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + V::Str(s) => { + let bytes = s.borrow().clone(); + for c in bytes { + self.push(V::Num(c as f64)); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + a => { + for v in a.items().unwrap_or_default() { + self.push(v); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + } + } + "exit" => return Err(Flow::Exit), + "stop" => return Err(Flow::Stop), + "stopped" => { + let p = self.pop(); + let stopped = match self.exec_obj(p, src) { + Err(Flow::Quit) => return Err(Flow::Quit), + Err(_) => true, + Ok(()) => false, + }; + self.push(V::Bool(stopped)); + } + "quit" => return Err(Flow::Quit), + "showpage" | "copypage" => {} + // Dictionaries. + "def" => { + let v = self.pop(); + let k = self.pop().key(); + if let Some(d) = self.dicts.last() { + d.borrow_mut().insert(k, v); + } + } + "store" => { + let v = self.pop(); + let k = self.pop().key(); + let d = self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).or(self.dicts.last()).cloned(); + if let Some(d) = d { + d.borrow_mut().insert(k, v); + } + } + "load" => { + let k = self.pop().key(); + let v = self.lookup(&k).unwrap_or(V::Name(k.into())); + self.push(v); + } + "undef" => { + let k = self.pop().key(); + if let V::Dict(d) = self.pop() { + d.borrow_mut().remove(&k); + } + } + "dict" => { + self.pop(); + self.push(V::Dict(new_dict())); + } + "begin" => { + if let V::Dict(d) = self.pop() + && self.dicts.len() < 64 + { + self.dicts.push(d); + } + } + "end" => { + if self.dicts.len() > 1 { + self.dicts.pop(); + } + } + "currentdict" => { + let d = self.dicts.last().cloned().unwrap_or_else(new_dict); + self.push(V::Dict(d)); + } + "userdict" | "globaldict" | "systemdict" | "statusdict" | "errordict" | "$error" => { + let d = self.dicts[0].clone(); + self.push(V::Dict(d)); + } + "where" => { + let k = self.pop().key(); + match self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).cloned() { + Some(d) => { + self.push(V::Dict(d)); + self.push(V::Bool(true)); + } + None => self.push(V::Bool(false)), + } + } + "known" => { + let k = self.pop().key(); + let v = match self.pop() { + V::Dict(d) => d.borrow().contains_key(&k), + _ => false, + }; + self.push(V::Bool(v)); + } + "countdictstack" => self.push(V::Num(self.dicts.len() as f64)), + "cleardictstack" => self.dicts.truncate(1), + "findresource" | "findfont" | "findencoding" => { + if n == "findresource" { + self.pop(); + } + self.pop(); + self.push(V::Dict(new_dict())); + } + "defineresource" => { + self.pop(); + let v = self.pop(); + self.pop(); + self.push(v); + } + "resourcestatus" => { + self.pop(); + self.pop(); + self.push(V::Bool(false)); + } + // Arrays and strings. + "array" => { + let k = self.popn().clamp(0.0, 1e6) as usize; + self.push(array(vec![V::Null; k])); + } + "string" => { + let k = self.popn().clamp(0.0, 1e7) as usize; + self.push(V::Str(Rc::new(RefCell::new(vec![0; k])))); + } + "length" => { + let l = match self.pop() { + V::Str(s) => s.borrow().len(), + V::Dict(d) => d.borrow().len(), + V::Name(s) | V::Lit(s) => s.len(), + v => v.items().map_or(0, |i| i.len()), + }; + self.push(V::Num(l as f64)); + } + "get" => { + let k = self.pop(); + let v = match self.pop() { + V::Dict(d) => d.borrow().get(&k.key()).cloned().unwrap_or(V::Null), + V::Str(s) => V::Num(s.borrow().get(k.num().unwrap_or(0.0) as usize).copied().unwrap_or(0) as f64), + a => a.items().and_then(|i| i.get(k.num().unwrap_or(0.0) as usize).cloned()).unwrap_or(V::Null), + }; + self.push(v); + } + "put" => { + let v = self.pop(); + let k = self.pop(); + match self.pop() { + V::Dict(d) => { + d.borrow_mut().insert(k.key(), v); + } + V::Array(a) => { + let i = k.num().unwrap_or(0.0) as usize; + if let Some(slot) = a.borrow_mut().get_mut(i) { + *slot = v; + } + } + V::Str(s) => { + let i = k.num().unwrap_or(0.0) as usize; + if let Some(slot) = s.borrow_mut().get_mut(i) { + *slot = v.num().unwrap_or(0.0) as u8; + } + } + _ => {} + } + } + "getinterval" => { + let c = self.popn().max(0.0) as usize; + let i = self.popn().max(0.0) as usize; + match self.pop() { + V::Str(s) => { + let b = s.borrow(); + let part = b.get(i.min(b.len())..(i + c).min(b.len())).unwrap_or(&[]).to_vec(); + drop(b); + self.push(V::Str(Rc::new(RefCell::new(part)))); + } + a => { + let items = a.items().unwrap_or_default(); + let part = items.get(i.min(items.len())..(i + c).min(items.len())).unwrap_or(&[]).to_vec(); + self.push(array(part)); + } + } + } + "putinterval" => { + self.pop(); + self.pop(); + self.pop(); + } + "aload" => { + let a = self.pop(); + for v in a.items().unwrap_or_default() { + self.push(v); + } + self.push(a); + } + "astore" => { + let a = self.pop(); + if let V::Array(arr) = &a { + let k = arr.borrow().len().min(self.st.len()); + let items = self.st.split_off(self.st.len() - k); + *arr.borrow_mut() = items; + } + self.push(a); + } + "cvx" => match self.pop() { + V::Array(a) => self.push(V::Proc(Rc::new(a.borrow().clone()))), + V::Lit(s) => self.push(V::Name(s)), + V::Str(s) => { + // Executable string: tokenize it as a procedure. + let bytes = s.borrow().clone(); + let mut sub = Src { d: &bytes, pos: 0 }; + let mut body = vec![]; + while let Some(t) = sub.token(0) { + body.push(t); + } + self.push(V::Proc(Rc::new(body))); + } + v => self.push(v), + }, + "cvlit" => match self.pop() { + V::Proc(p) => self.push(array(p.as_ref().clone())), + V::Name(s) => self.push(V::Lit(s)), + v => self.push(v), + }, + "cvn" => { + let k = self.pop().key(); + self.push(V::Lit(k.into())); + } + "cvs" => { + self.pop(); + let k = self.pop().key(); + self.push(V::Str(Rc::new(RefCell::new(k.into_bytes())))); + } + "xcheck" => { + let v = self.pop(); + self.push(V::Bool(matches!(v, V::Proc(_) | V::Name(_)))); + } + "type" => { + let t = match self.pop() { + V::Num(_) => "realtype", + V::Bool(_) => "booleantype", + V::Name(_) | V::Lit(_) => "nametype", + V::Str(_) => "stringtype", + V::Proc(_) | V::Array(_) => "arraytype", + V::Dict(_) => "dicttype", + V::Mark => "marktype", + V::Null => "nulltype", + V::File => "filetype", + }; + self.push(V::Name(t.into())); + } + "bind" | "readonly" | "executeonly" | "noaccess" => {} + "languagelevel" => self.push(V::Num(3.0)), + "version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))), + "save" => { + self.saved.push(self.gs.clone()); + self.gs.depth = self.b.depth(); + self.push(V::Null); + } + "restore" => { + self.pop(); + self.grestore(); + } + "currentfile" => self.push(V::File), + "readstring" | "readhexstring" | "readline" => { + self.pop(); + self.pop(); + self.push(V::Str(Rc::new(RefCell::new(vec![])))); + self.push(V::Bool(false)); + } + "flushfile" | "closefile" | "print" | "==" | "=" | "pstack" | "setpagedevice" | "setglobal" | "setpacking" | "setshared" | "setobjectformat" + | "setsmoothness" => { + if !matches!(n, "pstack") { + self.pop(); + } + } + "currentglobal" | "currentpacking" | "currentshared" => self.push(V::Bool(false)), + "vmstatus" => { + for _ in 0..3 { + self.push(V::Num(0.0)); + } + } + // Graphics state. + "gsave" => { + let mut g = self.gs.clone(); + g.depth = self.b.depth(); + self.saved.push(g); + } + "grestore" => self.grestore(), + "grestoreall" => { + while !self.saved.is_empty() { + self.grestore(); + } + } + "initgraphics" => { + self.gs.ctm = Affine::IDENTITY; + self.gs.lw = 1.0; + self.gs.color = [0.0; 3]; + } + "setlinewidth" => self.gs.lw = self.popn(), + "currentlinewidth" => self.push(V::Num(self.gs.lw)), + "setlinecap" => self.gs.cap = [Cap::Butt, Cap::Round, Cap::Square][(self.popn().max(0.0) as usize).min(2)], + "setlinejoin" => self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(self.popn().max(0.0) as usize).min(2)], + "setmiterlimit" => self.gs.miter = self.popn(), + "setdash" => { + let off = self.popn(); + let a: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); + self.gs.dash = (!a.is_empty() && a.iter().any(|v| *v > 0.0)).then_some((a, off)); + } + "setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "setscreen" | "settransfer" => { + self.pop(); + } + "setgray" => { + let g = self.popn().clamp(0.0, 1.0); + self.gs.color = [g; 3]; + } + "setrgbcolor" => { + let b = self.popn(); + let g = self.popn(); + let r = self.popn(); + self.gs.color = [r, g, b].map(|v| v.clamp(0.0, 1.0)); + } + "sethsbcolor" => { + let v = self.popn().clamp(0.0, 1.0); + let s = self.popn().clamp(0.0, 1.0); + let h = self.popn().rem_euclid(1.0) * 6.0; + let (i, f) = (h.floor() as i32, h.fract()); + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + self.gs.color = match i { + 0 => [v, t, p], + 1 => [q, v, p], + 2 => [p, v, t], + 3 => [p, q, v], + 4 => [t, p, v], + _ => [v, p, q], + }; + } + "setcmykcolor" => { + let k = self.popn().clamp(0.0, 1.0); + let y = self.popn().clamp(0.0, 1.0); + let m = self.popn().clamp(0.0, 1.0); + let c = self.popn().clamp(0.0, 1.0); + self.gs.color = [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]; + } + "setcolorspace" => { + self.pop(); + } + "setcolor" => { + // Device colour by operand count (patterns are not drawn). + let k = self.st.iter().rev().take_while(|v| matches!(v, V::Num(_))).count().min(4); + let c: Vec = self.st.split_off(self.st.len() - k).iter().filter_map(V::num).collect(); + self.gs.color = match c.len() { + 1 => [c[0]; 3], + 3 => [c[0], c[1], c[2]], + 4 => [(1.0 - c[0]) * (1.0 - c[3]), (1.0 - c[1]) * (1.0 - c[3]), (1.0 - c[2]) * (1.0 - c[3])], + _ => self.gs.color, + }; + } + "currentgray" => self.push(V::Num((self.gs.color[0] + self.gs.color[1] + self.gs.color[2]) / 3.0)), + "currentrgbcolor" => { + for c in self.gs.color { + self.push(V::Num(c)); + } + } + // Coordinates. + "matrix" => self.push(matrix_val(Affine::IDENTITY)), + "identmatrix" | "defaultmatrix" => { + self.pop(); + self.push(matrix_val(Affine::IDENTITY)); + } + "currentmatrix" => { + self.pop(); + self.push(matrix_val(self.gs.ctm)); + } + "setmatrix" => { + let m = self.pop(); + self.gs.ctm = matrix_of(&m).unwrap_or(Affine::IDENTITY); + } + "concat" => { + let m = self.pop(); + if let Some(m) = matrix_of(&m) { + self.gs.ctm *= m; + } + } + "concatmatrix" => { + self.pop(); + let b = self.pop(); + let a = self.pop(); + let (a, b) = (matrix_of(&a).unwrap_or(Affine::IDENTITY), matrix_of(&b).unwrap_or(Affine::IDENTITY)); + self.push(matrix_val(b * a)); + } + "invertmatrix" => { + self.pop(); + let a = self.pop(); + self.push(matrix_val(matrix_of(&a).unwrap_or(Affine::IDENTITY).inverse())); + } + "translate" | "scale" => { + let top = self.pop(); + let (m_out, y) = match top { + V::Array(_) => (Some(top), self.popn()), + v => (None, v.num().unwrap_or(0.0)), + }; + let x = self.popn(); + let t = if n == "translate" { Affine::translate((x, y)) } else { Affine::scale_non_uniform(x, y) }; + match m_out { + Some(_) => self.push(matrix_val(t)), + None => self.gs.ctm *= t, + } + } + "rotate" => { + let top = self.pop(); + match top { + V::Array(_) => { + let a = self.popn(); + self.push(matrix_val(Affine::rotate(a.to_radians()))); + } + v => self.gs.ctm *= Affine::rotate(v.num().unwrap_or(0.0).to_radians()), + } + } + "transform" | "itransform" | "dtransform" | "idtransform" => { + let top = self.pop(); + let (m, y) = match top { + V::Array(_) | V::Proc(_) => (matrix_of(&top).unwrap_or(Affine::IDENTITY), self.popn()), + v => (self.gs.ctm, v.num().unwrap_or(0.0)), + }; + let x = self.popn(); + let m = if n.starts_with('i') { m.inverse() } else { m }; + let p = if n.contains('d') { + let c = m.as_coeffs(); + Point::new(c[0] * x + c[2] * y, c[1] * x + c[3] * y) + } else { + m * Point::new(x, y) + }; + self.push(V::Num(p.x)); + self.push(V::Num(p.y)); + } + // Paths. + "newpath" => { + self.gs.path = BezPath::new(); + self.gs.cur = None; + self.gs.start = None; + } + "moveto" => { + let p = self.user_point(); + self.move_to(p); + } + "rmoveto" => { + let d = self.user_point(); + let c = self.current_user(); + self.move_to(Point::new(c.x + d.x, c.y + d.y)); + } + "lineto" => { + let p = self.user_point(); + self.line_to(p); + } + "rlineto" => { + let d = self.user_point(); + let c = self.current_user(); + self.line_to(Point::new(c.x + d.x, c.y + d.y)); + } + "curveto" | "rcurveto" => { + let p3 = self.user_point(); + let p2 = self.user_point(); + let p1 = self.user_point(); + if n == "rcurveto" { + let c = self.current_user(); + let o = |p: Point| Point::new(c.x + p.x, c.y + p.y); + self.curve_to(o(p1), o(p2), o(p3)); + } else { + self.curve_to(p1, p2, p3); + } + } + "closepath" => { + if self.gs.cur.is_some() { + self.gs.path.close_path(); + self.gs.cur = self.gs.start; + } + } + "arc" => self.arc(false), + "arcn" => self.arc(true), + "currentpoint" => { + let c = self.current_user(); + self.push(V::Num(c.x)); + self.push(V::Num(c.y)); + } + "pathbbox" => { + let r = kurbo::Shape::bounding_box(&self.gs.path); + let inv = self.gs.ctm.inverse(); + let (a, b) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1)); + for v in [a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)] { + self.push(V::Num(v)); + } + } + "flattenpath" | "reversepath" | "strokepath" | "initclip" => {} + "clippath" => { + let p = self.page; + self.gs.path = kurbo::Shape::to_path(&kurbo::Rect::new(p[0], p[1], p[2], p[3]), 0.1); + } + // Painting. + "fill" => self.paint(Some(FillRule::NonZero), false), + "eofill" => self.paint(Some(FillRule::EvenOdd), false), + "stroke" => self.paint(None, true), + "clip" | "eoclip" => { + let rule = if n == "clip" { FillRule::NonZero } else { FillRule::EvenOdd }; + let p = self.gs.path.clone(); + if !p.elements().iter().all(|e| matches!(e, PathEl::MoveTo(_))) { + self.b.clip(p, rule); + } + } + "rectfill" | "rectstroke" | "rectclip" => { + let paths = self.rect_path(); + if n == "rectstroke" && matches!(self.st.last(), Some(V::Array(_))) { + self.pop(); + } + let mut all = BezPath::new(); + for p in paths { + all.extend(p); + } + match n { + "rectclip" => self.b.clip(all, FillRule::NonZero), + _ => { + let saved = std::mem::replace(&mut self.gs.path, all); + self.paint(if n == "rectfill" { Some(FillRule::NonZero) } else { None }, n == "rectstroke"); + self.gs.path = saved; + } + } + } + "shfill" => { + self.pop(); + self.b.skip("smooth shading (shfill)"); + } + // Text (skipped). + "scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "setcachedevice" | "setcharwidth" => { + match n { + "scalefont" | "makefont" => { + self.pop(); + } + "selectfont" => { + self.pop(); + self.pop(); + } + "charpath" => { + self.pop(); + self.pop(); + } + "setcachedevice" => { + for _ in 0..6 { + self.pop(); + } + } + "setcharwidth" => { + self.pop(); + self.pop(); + } + "rootfont" => self.push(V::Dict(new_dict())), + _ => { + self.pop(); + } + } + if matches!(n, "show" | "charpath" | "glyphshow") { + self.b.skip("text"); + } + } + "currentfont" => self.push(V::Dict(new_dict())), + "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" => { + let k = match n { + "ashow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" => 3, + "widthshow" => 4, + _ => 6, + }; + for _ in 0..k.min(self.st.len()) { + self.pop(); + } + self.b.skip("text"); + } + "stringwidth" => { + self.pop(); + self.push(V::Num(0.0)); + self.push(V::Num(0.0)); + } + // Images: operands popped, inline data skipped. + "image" | "imagemask" | "colorimage" => { + let mut reads_file = false; + let check = |v: &V, rf: &mut bool| { + if matches!(v, V::File) || matches!(v, V::Proc(p) if p.iter().any(|x| matches!(x, V::Name(n) if &**n == "currentfile"))) { + *rf = true; + } + }; + if n == "colorimage" { + let ncomp = self.popn().max(1.0) as usize; + let multi = matches!(self.pop(), V::Bool(true)); + for _ in 0..if multi { ncomp.min(4) } else { 1 } { + let v = self.pop(); + check(&v, &mut reads_file); + } + for _ in 0..4 { + self.pop(); + } + } else { + match self.pop() { + V::Dict(d) => { + if let Some(ds) = d.borrow().get("DataSource") { + check(ds, &mut reads_file); + } + } + top => { + check(&top, &mut reads_file); + for _ in 0..4 { + self.pop(); + } + } + } + } + if reads_file { + src.skip_image_data(); + } + self.b.skip("image"); + } + _ => { + if self.unknown.len() < 32 && !self.unknown.iter().any(|u| u == n) { + self.unknown.push(n.to_string()); + } + } + } + Ok(()) + } +} + +/// The PostScript section of an EPS file (DOS EPS binary header handled) and its bounding box +/// (`%%HiResBoundingBox` preferred). +pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> { + let ps = if bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && bytes.len() >= 12 { + let off = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; + let len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize; + bytes.get(off..off.checked_add(len)?.min(bytes.len()))? + } else { + bytes + }; + let find_box = |key: &[u8]| -> Option<[f64; 4]> { + let mut from = 0; + while let Some(p) = ps[from..].windows(key.len()).position(|w| w == key) { + let s = from + p + key.len(); + let e = ps[s..].iter().position(|c| matches!(c, b'\n' | b'\r')).map_or(ps.len(), |q| s + q); + let v: Vec = String::from_utf8_lossy(&ps[s..e]).split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if v.len() == 4 && v[2] > v[0] && v[3] > v[1] { + return Some([v[0], v[1], v[2], v[3]]); + } + from = s; + } + None + }; + let bbox = find_box(b"%%HiResBoundingBox:").or_else(|| find_box(b"%%BoundingBox:"))?; + Some((ps, bbox)) +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs new file mode 100644 index 00000000..b4e91f2b --- /dev/null +++ b/crates/pdf/src/tests.rs @@ -0,0 +1,150 @@ +//! Test files are generated here (no third-party samples). + +use super::*; + +use crate::write::pdf; + +const CONTENT: &str = "/OC /MC0 BDC\n0 1 0 rg 0 0 200 100 re f\nEMC\n\ +/OC /MC1 BDC\nq 10 10 80 80 re W n\n1 0 0 rg 0 0 50 100 re f\nQ\n\ +/Pattern cs /P0 scn 100 0 100 50 re f\n\ +0 0 1 RG 4 w 120 80 m 180 80 l S\n\ +BT /F1 12 Tf (ignored) Tj ET\nEMC\n"; + +pub(crate) fn sample_pdf(object_stream: bool) -> Vec { + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Properties << /MC0 5 0 R /MC1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>"; + let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [100 0 200 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> >>"; + let mut objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), None), + (3, page.to_string(), None), + (4, "<< >>".to_string(), Some(CONTENT.as_bytes().to_vec())), + (7, pattern.to_string(), None), + ]; + let ocg5 = "<< /Type /OCG /Name (Background) >>"; + let ocg6 = "<< /Type /OCG /Name >>"; // "Art" in UTF-16BE + if object_stream { + let body = format!("{ocg5} {ocg6}"); + let head = format!("5 0 6 {} ", ocg5.len() + 1); + let data = format!("{head}{body}"); + objs.push((8, format!("<< /Type /ObjStm /N 2 /First {} >>", head.len()), Some(data.into_bytes()))); + } else { + objs.push((5, ocg5.to_string(), None)); + objs.push((6, ocg6.to_string(), None)); + } + pdf(&objs, 1) +} + +fn px(doc: &Doc, x: i64, y: i64) -> [f32; 4] { + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(doc, w, h, 1.0); + img.get(x, y) +} + +#[test] +fn pdf_paths_clip_gradient_and_layers() { + for objstm in [false, true] { + let bytes = sample_pdf(objstm); + assert_eq!(sniff(&bytes), Some(Format::Pdf)); + assert_eq!(page_count(&bytes), 1); + let doc = parse(&bytes).unwrap(); + assert_eq!((doc.width, doc.height), (200.0, 100.0)); + assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}"); + assert!(doc.skipped.contains(&"text".to_string())); + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + let at = |x: i64, y: i64| img.get(x, y); + // Red rectangle clipped to x ≥ 10, over the green background. + assert!(at(30, 50)[0] > 0.99 && at(30, 50)[1] < 0.01, "{:?}", at(30, 50)); + assert!(at(5, 50)[1] > 0.99 && at(5, 50)[0] < 0.01, "clipped out: {:?}", at(5, 50)); + assert!(at(70, 50)[1] > 0.99, "{:?}", at(70, 50)); + // Axial shading, red → blue left to right (in the lower half: PDF y is up). + let (l, r) = (at(110, 75), at(190, 75)); + assert!(l[0] > 0.8 && l[2] < 0.2 && r[2] > 0.8 && r[0] < 0.2, "{l:?} {r:?}"); + assert!(at(150, 25)[1] > 0.99, "the gradient stays in its rectangle: {:?}", at(150, 25)); + // Stroked line (PDF y = 80 → 20 px from the top). + assert!(at(150, 20)[2] > 0.99 && at(150, 20)[1] < 0.01, "{:?}", at(150, 20)); + // One layer at a time. + let bg = layer_doc(&doc, 0); + assert!(px(&bg, 30, 50)[1] > 0.99); + let art = layer_doc(&doc, 1); + assert_eq!(px(&art, 5, 50)[3], 0.0); + // Continuous rasterisation: twice the size stays sharp at the clip edge. + let big = effectcraft_svg::rasterize(&doc, 400, 200, 2.0); + assert!(big.get(21, 100)[0] > 0.99 && big.get(18, 100)[1] > 0.99); + } +} + +#[test] +fn pdf_forms_rotation_cmyk_and_alpha() { + let content = "q 2 0 0 2 0 0 cm /Fm0 Do Q\n/GS0 gs 0 0 0 1 k 0 0 10 10 re f\n"; + let form = "0 1 1 0 k 0 0 20 20 re f"; + let objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 /Rotate 90 >>".to_string(), None), + ( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /XObject << /Fm0 5 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >> /Contents 4 0 R >>" + .to_string(), + None, + ), + (4, "<< >>".to_string(), Some(content.as_bytes().to_vec())), + (5, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>".to_string(), Some(form.as_bytes().to_vec())), + ]; + let doc = parse(&pdf(&objs, 1)).unwrap(); + // Rotated a quarter turn: 50 × 100. + assert_eq!((doc.width, doc.height), (50.0, 100.0)); + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + // The form (CMYK red, clipped to its 10×10 box, scaled ×2) covers PDF (0..20, 0..20); the + // half-transparent black square covers PDF (0..10, 0..10). /Rotate 90: PDF (x, y) → (y, x). + let red = img.get(15, 15); + assert!(red[0] > 0.99 && red[1] < 0.01 && red[3] > 0.99, "{red:?}"); + let dark = img.get(5, 5); + assert!((dark[0] - 0.5).abs() < 0.02 && dark[3] > 0.99, "{dark:?}"); + assert_eq!(img.get(30, 30)[3], 0.0, "form clipped to its bounding box"); +} + +const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\ +/m {moveto} bind def /l {lineto} bind def\n\ +/cm { 6 array astore concat } bind def\n\ +/box { 4 dict begin /h exch def /w exch def /y exch def /x exch def x y m w 0 rlineto 0 h rlineto w neg 0 rlineto closepath end } bind def\n\ +1 0 0 setrgbcolor\n10 10 30 30 box fill\n\ +gsave 0 0 1 setrgbcolor 70 50 20 0 360 arc fill grestore\n\ +gsave 1 0 0 1 50 0 cm 0 1 0 setrgbcolor 0 0 10 10 box fill grestore\n\ +0 setgray 2 setlinewidth 0 90 m 100 90 l stroke\n\ +/Helvetica findfont 12 scalefont setfont 5 5 moveto (text) show\n\ +showpage\n%%EOF\n"; + +#[test] +fn eps_postscript_subset() { + let doc = parse(EPS.as_bytes()).unwrap(); + assert_eq!((doc.width, doc.height), (100.0, 100.0)); + assert!(doc.skipped.contains(&"text".to_string())); + let at = |x, y| px(&doc, x, y); + assert!(at(25, 75)[0] > 0.99 && at(25, 75)[3] > 0.99, "red box {:?}", at(25, 75)); + assert!(at(70, 50)[2] > 0.99, "blue disc {:?}", at(70, 50)); + assert!(at(55, 95)[1] > 0.99, "translated green box {:?}", at(55, 95)); + let line = at(50, 10); + assert!(line[3] > 0.99 && line[0] < 0.01, "black line {line:?}"); + assert_eq!(at(95, 70)[3], 0.0); + // DOS EPS binary header (with a fake TIFF preview after the PostScript). + let ps = EPS.as_bytes(); + let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6]; + dos.extend_from_slice(&30u32.to_le_bytes()); + dos.extend_from_slice(&(ps.len() as u32).to_le_bytes()); + dos.extend_from_slice(&[0; 18]); + dos.extend_from_slice(ps); + dos.extend_from_slice(b"II*\0 not a real preview"); + let d2 = parse(&dos).unwrap(); + assert_eq!(d2.root.children.len(), doc.root.children.len()); + assert_eq!(codec("art.eps", &dos), Some("EPS")); + assert_eq!(codec("art.ai", &sample_pdf(false)), Some("AI")); + assert_eq!(parse(b"hello"), Err(Error::NotVector)); +} + +#[test] +fn filters_decode() { + assert_eq!(object::ascii85(b"<~87cURD]i,\"Ebo80~>"), b"Hello World!".to_vec()); + let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6); + assert_eq!(object::inflate(&z).unwrap(), b"abc"); +} diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs new file mode 100644 index 00000000..9e54bdc3 --- /dev/null +++ b/crates/pdf/src/write.rs @@ -0,0 +1,32 @@ +//! A minimal PDF writer for generating test files (EffectCraft does not export PDF). + +/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed), a +/// cross-reference table and a trailer naming object `root` as the catalog. +pub fn pdf(objects: &[(u32, String, Option>)], root: u32) -> Vec { + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); + let mut offsets = vec![]; + for (n, dict, stream) in objects { + offsets.push((*n, out.len())); + match stream { + Some(data) => { + let z = miniz_oxide::deflate::compress_to_vec_zlib(data, 6); + let d = format!("{} /Filter /FlateDecode /Length {} >>", dict.trim_end().trim_end_matches(">>"), z.len()); + out.extend_from_slice(format!("{n} 0 obj\n{d}\nstream\n").as_bytes()); + out.extend_from_slice(&z); + out.extend_from_slice(b"\nendstream\nendobj\n"); + } + None => out.extend_from_slice(format!("{n} 0 obj\n{dict}\nendobj\n").as_bytes()), + } + } + let xref = out.len(); + let max = objects.iter().map(|o| o.0).max().unwrap_or(0); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", max + 1).as_bytes()); + for i in 1..=max { + match offsets.iter().find(|o| o.0 == i) { + Some((_, off)) => out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()), + None => out.extend_from_slice(b"0000000000 65535 f \n"), + } + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root {root} 0 R >>\nstartxref\n{xref}\n%%EOF\n", max + 1).as_bytes()); + out +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index e0cdb4cb..dc1dd8ab 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -830,9 +830,9 @@ pub struct Proxy { pub enabled: bool, } -/// A layer of a layered still (a Photoshop document) used as footage -/// (File ▸ Import ▸ Composition / Composition – Retain Layer Sizes, or Choose Layer). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// A layer of a layered still (a Photoshop document, or a PDF / Illustrator / EPS file) used as +/// footage (File ▸ Import ▸ Composition / Composition – Retain Layer Sizes, or Choose Layer). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SourceLayer { /// Layer record index in the file (bottom of the stack = 0). pub index: u32, @@ -840,6 +840,10 @@ pub struct SourceLayer { /// The footage is the layer's own bounds (Retain Layer Sizes) rather than the document size. #[serde(default)] pub layer_size: bool, + /// A Photoshop smart object: the unique id of its embedded file (linked layer data), which + /// is the footage instead of the layer's pixels. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub embedded: Option, } fn one() -> u32 { @@ -856,6 +860,7 @@ pub struct Solid { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type")] +#[allow(clippy::large_enum_variant)] pub enum ItemKind { Folder, Comp(Arc), diff --git a/crates/psd/README.md b/crates/psd/README.md index e238b9b2..b0b19ba3 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -8,10 +8,14 @@ Implemented from Adobe's public **Adobe Photoshop File Formats Specification** ( edition): file header, colour mode data, image resources, layer and mask information (layer records, channel image data, layer mask data, additional layer information: `luni`, `lsct`/`lsdk`, `lyid`, `iOpa`, `TySh`, `lfx2`, `SoCo`, `nvrt`, `brit`, `hue2`, `levl`, `expA`, `vmsk`/`vsms`, -`Lr16`/`Lr32`), the descriptor (action) structure, and the merged image data. Raw, RLE (PackBits) +`Lr16`/`Lr32`, smart objects: `SoLd`/`SoLE`/`PlLd` placement and `lnk2`/`lnkD`/`lnk3`/`lnkE` +linked layer data with embedded files), the descriptor (action) structure, and the merged image data. Raw, RLE (PackBits) and ZIP (with and without prediction, inflated with `miniz_oxide`) compression; 1/8/16/32-bit; Bitmap, Grayscale, Indexed, RGB, CMYK (naive conversion) and Lab colour modes. Text engine data (`EngineData`) is read best-effort for the font, size, fill colour and -justification of the first style run. No Adobe code, sample files or assets were used; test +justification of the first style run. Smart objects import as footage of their embedded file +(a Photoshop document's merged image, or an image) placed by the smart object's corner quad +(position, scale, rotation; perspective and warps are not applied). Externally linked files +fall back to the layer's rendered pixels. No Adobe code, sample files or assets were used; test fixtures are generated in-test by the writer in this crate ([`write`](src/write.rs)). diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 885ab3c2..61cadc7d 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -204,6 +204,46 @@ pub struct SubPath { pub knots: Vec<[[f64; 2]; 3]>, } +/// A placed layer (smart object): `SoLd` / `SoLE` (placed layer data) or the older `PlLd`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SmartObject { + /// Unique id of the linked file ([`Psd::linked`]) the layer shows. + pub uuid: String, + /// Where the content's corners sit in document pixels: top-left, top-right, bottom-right, + /// bottom-left. + pub quad: [[f64; 2]; 4], + /// The content's size in pixels (`Sz `), when recorded. + pub size: Option<[f64; 2]>, +} + +impl SmartObject { + /// The quad as an affine placement of a `w`×`h` content rectangle: (centre, scale x, scale + /// y, rotation in degrees). Perspective and skew are dropped. + pub fn placement(&self, w: f64, h: f64) -> ([f64; 2], f64, f64, f64) { + let q = self.quad; + let centre = [(q[0][0] + q[1][0] + q[2][0] + q[3][0]) / 4.0, (q[0][1] + q[1][1] + q[2][1] + q[3][1]) / 4.0]; + let top = [q[1][0] - q[0][0], q[1][1] - q[0][1]]; + let left = [q[3][0] - q[0][0], q[3][1] - q[0][1]]; + let sx = top[0].hypot(top[1]) / w.max(1e-9); + let sy = left[0].hypot(left[1]) / h.max(1e-9); + // Mirrored content (left edge turning the other way) keeps a negative y scale. + let cross = top[0] * left[1] - top[1] * left[0]; + (centre, sx, if cross < 0.0 { -sy } else { sy }, top[1].atan2(top[0]).to_degrees()) + } +} + +/// A file embedded in (or linked from) the document: linked layer data (`lnk2`, `lnkD`, +/// `lnk3`, `lnkE`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LinkedFile { + pub uuid: String, + pub name: String, + /// File type code (`8BPS`, `PNGf`, `JPEG`…), may be blank. + pub file_type: String, + /// Embedded data (absent for externally linked files). + pub data: Option>, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct Layer { /// Index in [`Psd::layers`] (file order: bottom of the stack first). @@ -230,6 +270,8 @@ pub struct Layer { /// Layer effects (`lfx2` descriptor). pub effects: Option, pub vector_mask: Option, + /// Smart object placement. + pub smart_object: Option, /// Keys of every additional-information block the layer carries. pub keys: Vec<[u8; 4]>, } @@ -277,6 +319,8 @@ pub struct Psd { pub merged_alpha: bool, /// Indexed colour palette (256 R, then G, then B). pub palette: Vec, + /// Files embedded for smart objects. + pub linked: Vec, data: Arc<[u8]>, merged_offset: usize, } @@ -314,7 +358,20 @@ impl Psd { let n = r.u32()? as usize; r.skip(n)?; // Layer and mask information. - let mut psd = Psd { psb, width, height, channels, depth, mode, layers: vec![], merged_alpha: false, palette, data: data.clone(), merged_offset: 0 }; + let mut psd = Psd { + psb, + width, + height, + channels, + depth, + mode, + layers: vec![], + merged_alpha: false, + palette, + linked: vec![], + data: data.clone(), + merged_offset: 0, + }; let lm_len = r.length(psb)?; let lm_end = r.pos.checked_add(lm_len).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?; if lm_len > 0 { @@ -330,6 +387,9 @@ impl Psd { } // Global additional layer information (16/32-bit layer info lives here). while let Some((key, start, end)) = next_block(&data, &mut r.pos, lm_end, psb) { + if matches!(&key, b"lnk2" | b"lnkD" | b"lnk3" | b"lnkE") { + psd.linked_files(&data, start, end); + } if matches!(&key, b"Lr16" | b"Lr32" | b"Layr") && psd.layers.is_empty() && end > start { // The block holds the layer info, normally without its length field; accept // both layouts. @@ -348,6 +408,67 @@ impl Psd { Ok(psd) } + /// The embedded bytes of linked file `uuid`. + pub fn linked_data(&self, uuid: &str) -> Option<&[u8]> { + let f = self.linked.iter().find(|f| f.uuid == uuid)?; + self.data.get(f.data.clone()?) + } + + /// Linked layer data records (spec: "Linked Layer"): length, kind (`liFD` embedded data, + /// `liFE` external file, `liFA` alias), version, unique id, original file name, type, + /// creator, data length, optional descriptors, then the data. A damaged record ends the list. + fn linked_files(&mut self, data: &[u8], start: usize, end: usize) { + let mut pos = start; + while pos + 8 <= end { + let mut r = Reader::at(&data[..end], pos); + let Ok(len) = r.u64() else { break }; + let body = r.pos; + let Some(rec_end) = body.checked_add(len as usize).filter(|e| *e <= end) else { break }; + let mut r = Reader::at(&data[..rec_end], body); + let rec = (|| -> Result { + let kind = r.tag()?; + let version = r.u32()?; + let n = r.u8()? as usize; + let uuid = String::from_utf8_lossy(r.bytes(n)?).into_owned(); + let name = descriptor::read_unicode(&mut r)?; + let file_type = String::from_utf8_lossy(&r.tag()?).trim().to_string(); + let _creator = r.tag()?; + let dlen = r.u64()? as usize; + if r.u8()? != 0 { + let _v = r.u32()?; + descriptor::read_descriptor(&mut r)?; + } + let mut out = LinkedFile { uuid, name, file_type, data: None }; + match &kind { + b"liFD" => { + let s = r.pos; + r.skip(dlen)?; + out.data = Some(s..s + dlen); + } + b"liFE" => { + let _v = r.u32()?; + descriptor::read_descriptor(&mut r)?; + if version > 3 { + r.skip(4 + 4 + 8)?; + } + let _size = r.u64()?; + if version > 2 && r.remaining() >= dlen && dlen > 0 { + let s = r.pos; + out.data = Some(s..s + dlen); + } + } + _ => {} + } + Ok(out) + })(); + match rec { + Ok(f) => self.linked.push(f), + Err(_) => break, + } + pos = rec_end.div_ceil(4) * 4; + } + } + fn layer_info(&mut self, data: &[u8], start: usize, end: usize) -> Result<()> { let mut r = Reader::at(&data[..end], start); let count = r.i16()?; @@ -522,6 +643,46 @@ impl Psd { l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned())); } } + b"SoLd" | b"SoLE" => { + let _id = r.tag()?; + let _ver = r.u32()?; + let _dver = r.u32()?; + let d = descriptor::read_descriptor(&mut r)?; + let uuid = d.text("Idnt").unwrap_or_default().to_string(); + let nums = |k: &str| -> Option> { + match d.get(k)? { + DValue::List(l) => Some( + l.iter() + .filter_map(|v| match v { + DValue::Double(x) | DValue::UnitFloat(_, x) => Some(*x), + DValue::Integer(i) => Some(*i as f64), + _ => None, + }) + .collect(), + ), + DValue::UnitFloats(_, v) => Some(v.clone()), + _ => None, + } + }; + let t = nums("Trnf").filter(|t| t.len() == 8).unwrap_or_else(|| vec![0.0; 8]); + let size = d.obj("Sz ").and_then(|s| Some([s.num("Wdth")?, s.num("Hght")?])); + l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size }); + } + b"PlLd" if l.smart_object.is_none() => { + let _id = r.tag()?; + let _ver = r.u32()?; + let n = r.u8()? as usize; + let uuid = String::from_utf8_lossy(r.bytes(n)?).into_owned(); + let _page = r.u32()?; + let _pages = r.u32()?; + let _aa = r.u32()?; + let _ty = r.u32()?; + let mut t = [0.0; 8]; + for v in &mut t { + *v = r.f64()?; + } + l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None }); + } b"vmsk" | b"vsms" => { let _ver = r.u32()?; let flags = r.u32()?; diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index 68680fe7..cd2f9293 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -187,3 +187,34 @@ fn truncated_and_garbage_input_errors() { assert!(Psd::parse(b"8BPS\0\x09garbage".to_vec()).is_err()); assert!(Psd::parse(b"PNG".to_vec()).is_err()); } + +#[test] +fn smart_objects_and_embedded_files() { + let mut inner = WDoc::new(20, 10); + inner.layers = vec![WLayer::solid("Red", Rect::new(0, 0, 20, 10), [1.0, 0.0, 0.0, 1.0])]; + let inner_bytes = write(&inner); + let mut d = WDoc::new(64, 48); + // The content scaled 2× and rotated 90° clockwise: top-left at (40, 4). + let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]]; + d.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]), + WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("uuid-1", quad, [20.0, 10.0])), + ]; + d.linked = vec![("uuid-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())]; + let p = Psd::parse(write(&d)).unwrap(); + assert_eq!(p.linked.len(), 1); + assert_eq!(p.linked[0].name, "inner.psd"); + assert_eq!(p.linked[0].file_type, "8BPS"); + assert_eq!(p.linked_data("uuid-1").unwrap(), &inner_bytes[..]); + let so = p.layers[1].smart_object.clone().unwrap(); + assert_eq!(so.uuid, "uuid-1"); + assert_eq!(so.quad, quad); + assert_eq!(so.size, Some([20.0, 10.0])); + let (c, sx, sy, rot) = so.placement(20.0, 10.0); + assert_eq!(c, [30.0, 24.0]); + assert!((sx - 2.0).abs() < 1e-9 && (sy - 2.0).abs() < 1e-9 && (rot - 90.0).abs() < 1e-9, "{sx} {sy} {rot}"); + // The embedded document parses on its own. + let e = Psd::parse(p.linked_data("uuid-1").unwrap().to_vec()).unwrap(); + assert_eq!((e.width, e.height), (20, 10)); + assert!(p.layers[0].smart_object.is_none()); +} diff --git a/crates/psd/src/write.rs b/crates/psd/src/write.rs index fb78cc78..160e81d3 100644 --- a/crates/psd/src/write.rs +++ b/crates/psd/src/write.rs @@ -103,11 +103,14 @@ pub struct WDoc { /// Merged image (straight RGBA, document-sized); `None` composites visible pixel layers /// with Normal blending. pub composite: Option>, + /// Embedded files for smart objects: (unique id, file name, type code, bytes), written as + /// linked layer data (`lnk2`). + pub linked: Vec<(String, String, [u8; 4], Vec)>, } impl WDoc { pub fn new(width: u32, height: u32) -> WDoc { - WDoc { width, height, depth: 8, mode: ColorMode::Rgb, rle: true, layers: vec![], composite: None } + WDoc { width, height, depth: 8, mode: ColorMode::Rgb, rle: true, layers: vec![], composite: None, linked: vec![] } } } @@ -339,6 +342,13 @@ pub fn write(doc: &WDoc) -> Vec { lm.extend_from_slice(&li); u32b(&mut lm, 0); } + if !doc.linked.is_empty() { + let d = linked_block(&doc.linked); + lm.extend_from_slice(b"8BIM"); + lm.extend_from_slice(b"lnk2"); + u32b(&mut lm, d.len() as u32); + lm.extend_from_slice(&d); + } } u32b(&mut out, lm.len() as u32); out.extend_from_slice(&lm); @@ -372,8 +382,53 @@ pub fn write(doc: &WDoc) -> Vec { out } +/// Linked layer data records (`liFD`, version 7) for embedded files. +fn linked_block(files: &[(String, String, [u8; 4], Vec)]) -> Vec { + let mut out = vec![]; + for (uuid, name, ty, data) in files { + let mut rec = vec![]; + rec.extend_from_slice(b"liFD"); + u32b(&mut rec, 7); + rec.push(uuid.len() as u8); + rec.extend_from_slice(uuid.as_bytes()); + rec.extend_from_slice(&unicode_plain(name)); + rec.extend_from_slice(ty); + rec.extend_from_slice(b"8BIM"); + rec.extend_from_slice(&(data.len() as u64).to_be_bytes()); + rec.push(0); // no file-open descriptor + rec.extend_from_slice(data); + u32b(&mut rec, 0); // child document id (empty unicode string) + rec.extend_from_slice(&0f64.to_be_bytes()); // asset modification time + rec.push(0); // asset locked state + out.extend_from_slice(&(rec.len() as u64).to_be_bytes()); + out.extend_from_slice(&rec); + while !out.len().is_multiple_of(4) { + out.push(0); + } + } + out +} + // ---------------------------------------------------------------- block helpers +/// `SoLd`: a placed layer showing linked file `uuid` (content `size` pixels) with its corners at +/// `quad` (top-left, top-right, bottom-right, bottom-left). +pub fn smart_object_block(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2]) -> ([u8; 4], Vec) { + 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") + .with("Idnt", DValue::Text(uuid.to_string())) + .with("placed", DValue::Text(uuid.to_string())) + .with("Trnf", list(&t)) + .with("nonAffineTransform", list(&t)) + .with("Sz ", DValue::Descriptor(Descriptor::new("Pnt ").with("Wdth", DValue::Double(size[0])).with("Hght", DValue::Double(size[1])))); + let mut out = b"soLD".to_vec(); + u32b(&mut out, 4); + u32b(&mut out, 16); + descriptor::write_descriptor(&mut out, &d); + (*b"SoLd", out) +} + /// `TySh`: a type layer with one style run; the text origin sits at `origin`. pub fn text_block(text: &str, origin: [f64; 2], font: &str, size: f64, color: [f64; 3], justification: u8) -> ([u8; 4], Vec) { let mut d = vec![]; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2aedf5ea..6ede490b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -69,7 +69,7 @@ pub trait FootageSource: Send + Sync { /// Footage that can be rasterised at any scale (SVG). pub fn is_vector_footage(f: &Footage) -> bool { - f.codec == "SVG" + matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") } /// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs index d3cf954c..0da0288f 100644 --- a/crates/render/src/tests_collapse.rs +++ b/crates/render/src/tests_collapse.rs @@ -200,3 +200,37 @@ fn wireframe_quality_draws_the_layer_bounds() { let img = render_frame(&p, cid, Tick::ZERO, 1.0); assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0)); } + +#[test] +fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() { + // A 1000×600 scene comp with a blue 3D solid at its centre, collapsed into a 300×200 comp + // whose camera looks at the precomp layer (at the parent's centre). The nested layers must + // be projected with the parent comp's view (its centre and size), not the scene comp's. + let run = |three_d_precomp: bool, camera: bool| { + let mut p = project(); + let outer = Comp::new(300, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner = Comp::new(1000, 600, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (40, 40), [500.0, 300.0, 0.0]); + blue.switches.three_d = true; + let mut inner = inner; + inner.layers = vec![blue]; + let iid = p.add_item("Scene", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut pre = build::layer(&mut p, &outer, "Scene", LayerSource::Comp { item: iid }, (1000, 600), None); + pre.switches.three_d = three_d_precomp; + pre.switches.collapse = true; + let mut c = outer.clone(); + c.layers = vec![pre]; + if camera { + let cam = build::layer(&mut p, &outer, "Camera", LayerSource::Camera, (300, 200), None); + c.layers.insert(0, cam); + } + let cid = p.add_item("Face", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + for (three_d, camera) in [(true, true), (true, false), (false, true)] { + let img = run(three_d, camera); + let c = img.get(150, 100); + assert!(c[2] > 0.99 && c[3] > 0.99, "3D precomp {three_d}, camera {camera}: centre {c:?}"); + assert!(img.get(100, 100)[3] < 0.01, "only the 40 px solid is drawn"); + } +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index a4bf7f20..25755a47 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -161,6 +161,13 @@ pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState { } } +/// The size of the comp the camera looks through: a collapsed precomp's layers are projected +/// into the outermost comp they collapse into (its centre and size), not their own comp's. +pub(crate) fn view_size(r: &Renderer, ctx: &EvalCtx) -> (f64, f64) { + let c = r.collapse3d.map_or(ctx.comp, |c| c.parent.comp); + (c.width as f64, c.height as f64) +} + /// Number of motion-blur sub-samples for a layer. fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) { @@ -202,7 +209,7 @@ fn prepare_with<'a>( if buf.img.is_empty() { return None; } - let comp = (ctx.comp.width as f64, ctx.comp.height as f64); + let comp = view_size(r, ctx); let s = r.opts.scale; let cam = camera_for(r, ctx); let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); @@ -919,7 +926,7 @@ pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut let cam = camera_for(r, ctx); let (fwd, right, down) = (cam.forward(), cam.right(), cam.down()); let s = r.opts.scale.max(1e-9); - let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64); + let (cw, ch) = view_size(r, ctx); let (ox, oy) = r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y)); let rot = environment_rotation(ctx); let (iw, ih) = (img.width as f64, img.height as f64); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 22b5fead..0536480c 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -170,6 +170,9 @@ pub struct Group { pub transform: Affine, pub opacity: f64, pub children: Vec, + /// Clipping paths in the group's space (after `transform`); the children show only where + /// every one of them covers (PDF / EPS clipping; SVG `clipPath` is not read yet). + pub clip: Vec<(BezPath, FillRule)>, } #[derive(Clone, Debug, PartialEq)] @@ -666,7 +669,7 @@ impl<'a, 'i> Parser<'a, 'i> { for c in n.children() { self.node(c, &st, &mut children, depth + 1); } - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); } "svg" => { // Nested viewport. @@ -685,7 +688,7 @@ impl<'a, 'i> Parser<'a, 'i> { self.node(c, &st, &mut children, depth + 1); } (self.vw, self.vh) = saved; - out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children, clip: vec![] })); } "use" => { let Some(target) = self.href(n) else { return }; @@ -706,11 +709,11 @@ impl<'a, 'i> Parser<'a, 'i> { for c in target.children() { self.node(c, &sst, &mut inner, depth + 1); } - children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner })); + children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner, clip: vec![] })); } else { self.node(target, &st, &mut children, depth + 1); } - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); } "path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => { if !st.visible { @@ -750,7 +753,7 @@ impl<'a, 'i> Parser<'a, 'i> { self.node(c, &st, &mut children, depth + 1); } if !children.is_empty() { - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); } } } @@ -942,7 +945,7 @@ pub fn parse(bytes: &[u8]) -> Result { p.node(c, &rst, &mut children, 1); } let transform = viewbox_transform(root, dw, dh); - Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children }, skipped: std::mem::take(&mut p.skipped) }) + Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children, clip: vec![] }, skipped: std::mem::take(&mut p.skipped) }) } #[cfg(test)] diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index 2cfb3dd3..de82695b 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -31,6 +31,27 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { if o <= 0.0 { return; } + if !g.clip.is_empty() { + // Draw the children on their own, keep what every clip path covers, composite. + let mut tmp = Image::new(dst.width, dst.height); + for c in &g.children { + draw_node(c, m, 1.0, &mut tmp); + } + for (path, rule) in &g.clip { + let rule = match rule { + FillRule::NonZero => effectcraft_path::FillRule::NonZero, + FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd, + }; + let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), dst.width, dst.height, rule); + tmp.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(p, c)| { + for v in p.iter_mut() { + *v *= c; + } + }); + } + over(dst, &tmp, o as f32); + return; + } if g.opacity < 1.0 && g.children.len() > 1 { let mut tmp = Image::new(dst.width, dst.height); for c in &g.children { diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 009f2522..c70fedc4 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -214,6 +214,21 @@ impl Face { underline_thickness: m.underline.map_or(px * 0.06, |u| u.thickness.max(px * 0.03)), } } + /// The OpenType layout features the face offers (GSUB and GPOS tags, sorted, deduplicated). + pub fn features(&self) -> Vec { + use skrifa::raw::TableProvider; + let Some(f) = self.font() else { return vec![] }; + let mut out: Vec = vec![]; + if let Ok(list) = f.gsub().and_then(|g| g.feature_list()) { + out.extend(list.feature_records().iter().map(|r| String::from_utf8_lossy(&r.feature_tag().to_be_bytes()).into_owned())); + } + if let Ok(list) = f.gpos().and_then(|g| g.feature_list()) { + out.extend(list.feature_records().iter().map(|r| String::from_utf8_lossy(&r.feature_tag().to_be_bytes()).into_owned())); + } + out.sort(); + out.dedup(); + out + } /// Whether the face's GSUB offers an OpenType feature (e.g. `smcp`). pub fn has_feature(&self, tag: &[u8; 4]) -> bool { use skrifa::raw::TableProvider; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 824b7373..3333cdd1 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -18,6 +18,8 @@ use std::sync::{Arc, Mutex, OnceLock}; use harfrust::{Direction, Feature, Tag, UnicodeBuffer}; use unicode_bidi::{BidiInfo, Level}; +use effectcraft_keyframe::OpenType; + use crate::fonts::{self, FaceId, Resolved}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -81,6 +83,8 @@ pub struct TextStyle { pub script: Script, /// Baseline-to-baseline distance in pixels; None = auto (120% of the size). pub leading: Option, + /// OpenType features (stylistic sets, figures, fractions, all small caps…). + pub opentype: OpenType, } impl Default for TextStyle { @@ -104,6 +108,7 @@ impl Default for TextStyle { tsume: 0.0, script: Script::Normal, leading: None, + opentype: OpenType::default(), } } } @@ -188,6 +193,7 @@ impl Hash for TextStyle { self.manual_kern.map(f32::to_bits).hash(h); self.leading.map(f32::to_bits).hash(h); (self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h); + self.opentype.hash(h); } } @@ -342,6 +348,8 @@ struct ShapedGlyph { dy: f32, size: f32, run: usize, + /// Upward baseline offset (baseline shift, plus a synthesized superscript / subscript's). + rise: f32, } struct Item { @@ -360,6 +368,50 @@ fn upper_single(c: char) -> char { const SMALL_CAPS_SCALE: f32 = 0.78; +/// How a character is set: as itself, as a synthesized small capital (a scaled-down capital), +/// with the font's small-capital glyph (`smcp` / `c2sc`), or with the font's superior / inferior +/// glyph (`sups` / `subs`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Form { + Normal, + FauxSmall, + TrueSmall, + TrueScript, +} + +/// Whether OpenType feature `tag` gives character `c` its own glyph in `face` (cached): the +/// true small caps / superscripts / subscripts test, with synthesized forms as the fallback. +pub fn feature_substitutes(face: FaceId, tag: &[u8; 4], c: char) -> bool { + static C: OnceLock>> = OnceLock::new(); + let cache = C.get_or_init(Default::default); + if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, *tag, c)) { + return *v; + } + let f = fonts::face(face); + let v = f.has_feature(tag) + && match (f.font(), f.shaper_data()) { + (Some(font), Some(data)) => { + let shaper = data.shaper(&font).build(); + let shape = |feats: &[Feature]| -> Vec { + let mut buf = UnicodeBuffer::new(); + buf.add(c, 0); + buf.guess_segment_properties(); + shaper.shape(buf, harfrust::ShapeOptions::new().features(feats)).glyph_infos().iter().map(|i| i.glyph_id).collect() + }; + let plain = shape(&[]); + let with = shape(&[Feature::new(Tag::new(tag), 1, ..)]); + plain != with && !with.contains(&0) + } + _ => false, + }; + let mut m = cache.lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 100_000 { + m.clear(); + } + m.insert((face, *tag, c), v); + v +} + /// Horizontal ink extent `(x0, x1)` of a glyph in font units. fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> { let p = crate::outline_units(face, gid)?; @@ -370,7 +422,7 @@ fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> { Some((r.x0 as f32, r.x1 as f32)) } -fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize) -> Vec { +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(); @@ -388,6 +440,30 @@ fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, feats.push(Feature::new(Tag::new(b"liga"), 0, ..)); feats.push(Feature::new(Tag::new(b"clig"), 0, ..)); } + // OpenType options; small caps go per character (true where the font has them). + for (tag, v) in style.opentype.feature_settings() { + if &tag != b"smcp" && &tag != b"c2sc" { + feats.push(Feature::new(Tag::new(&tag), v, ..)); + } + } + if style.caps == Caps::All { + // Case-sensitive forms (punctuation raised to sit with capitals). + feats.push(Feature::new(Tag::new(b"case"), 1, ..)); + } + let rise = match form { + Form::TrueScript => style.baseline_shift, + _ => style.rise(), + }; + match form { + Form::TrueSmall => { + feats.push(Feature::new(Tag::new(b"smcp"), 1, ..)); + if style.opentype.all_small_caps { + feats.push(Feature::new(Tag::new(b"c2sc"), 1, ..)); + } + } + Form::TrueScript => feats.push(Feature::new(Tag::new(if style.script == Script::Sub { b"subs" } else { b"sups" }), 1, ..)), + _ => {} + } let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats)); let k = size / f.units_per_em(); let hs = style.h_scale; @@ -404,6 +480,7 @@ fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, dy: p.y_offset as f32 * k, size, run, + rise, }) .collect() } @@ -539,38 +616,54 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let bidi = BidiInfo::new(text, default_level); let pinfo = &bidi.paragraphs[0]; let base_rtl = pinfo.level.is_rtl(); - // per char: (byte, char, shaped char, face, rtl, small caps, run) - let chars: Vec<(usize, char, char, FaceId, bool, bool, usize)> = text + // per char: (byte, char, shaped char, face, rtl, form, run) + let chars: Vec<(usize, char, char, FaceId, bool, Form, usize)> = text .char_indices() .map(|(b, c)| { let si = run_at(runs, base + b); let style = &runs[si].1; - let (sc, small) = match style.caps { - Caps::Normal => (c, false), - Caps::All => (upper_single(c), false), - Caps::Small if c.is_lowercase() => (upper_single(c), true), - Caps::Small => (c, false), - }; let primary = primaries[si].face; - let face = if sc.is_whitespace() { primary } else { fonts::fallback_for(sc, primary) }; - (b, c, sc, face, bidi.levels[b].is_rtl(), small, si) + let face_of = |ch: char| if ch.is_whitespace() { primary } else { fonts::fallback_for(ch, primary) }; + let all_small = style.opentype.all_small_caps && style.caps != Caps::All; + let small = (style.caps == Caps::Small || all_small) && c.is_lowercase(); + let small_cap = all_small && c.is_uppercase(); + let (sc, mut form) = match style.caps { + Caps::All => (upper_single(c), Form::Normal), + _ if small && feature_substitutes(face_of(c), b"smcp", c) => (c, Form::TrueSmall), + _ if small_cap && feature_substitutes(face_of(c), b"c2sc", c) => (c, Form::TrueSmall), + _ if small => (upper_single(c), Form::FauxSmall), + _ if small_cap => (c, Form::FauxSmall), + _ => (c, Form::Normal), + }; + let face = face_of(sc); + if form == Form::Normal && style.script != Script::Normal && !sc.is_whitespace() { + let tag = if style.script == Script::Sub { b"subs" } else { b"sups" }; + if feature_substitutes(face, tag, sc) { + form = Form::TrueScript; + } + } + (b, c, sc, face, bidi.levels[b].is_rtl(), form, si) }) .collect(); // items: runs of equal (face, rtl, small, style) let mut items: Vec = Vec::new(); let mut i = 0; while i < chars.len() { - let key = |c: &(usize, char, char, FaceId, bool, bool, usize)| (c.3, c.4, c.5, c.6); + let key = |c: &(usize, char, char, FaceId, bool, Form, usize)| (c.3, c.4, c.5, c.6); let k0 = key(&chars[i]); let mut j = i + 1; while j < chars.len() && key(&chars[j]) == k0 { j += 1; } - let (face, rtl, small, si) = k0; + let (face, rtl, form, si) = k0; let style = &runs[si].1; let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect(); - let size = style.glyph_size() * if small { SMALL_CAPS_SCALE } else { 1.0 }; - let mut glyphs = shape_item(&sub, rtl, face, size, style, si); + let size = match form { + Form::FauxSmall => style.glyph_size() * SMALL_CAPS_SCALE, + Form::TrueScript => style.size, + _ => style.glyph_size(), + }; + let mut glyphs = shape_item(&sub, rtl, face, size, style, si, form); space_item(&mut glyphs, style, text, true); let end = if j < chars.len() { chars[j].0 } else { text.len() }; items.push(Item { range: chars[i].0..end, rtl, glyphs }); @@ -718,7 +811,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar face: g.face, id: g.id, x: pen + g.dx, - y: -g.dy - st.rise(), + y: -g.dy - g.rise, size: g.size, cluster: base + g.cluster, synth_bold: primaries[g.run].synth_bold || st.faux_bold, @@ -960,6 +1053,82 @@ mod tests { TextStyle { size, ..Default::default() } } + fn ids(text: &str, s: &TextStyle) -> Vec { + layout_uncached(text, s, &ParagraphStyle::default()).glyphs.iter().map(|g| g.id).collect() + } + + fn ot(f: impl FnOnce(&mut OpenType)) -> TextStyle { + let mut s = st(100.0); + f(&mut s.opentype); + s + } + + fn serif() -> TextStyle { + TextStyle { family: "Noto Serif".into(), ..st(100.0) } + } + + #[test] + fn bundled_faces_list_their_features() { + let inter = fonts::face(fonts::resolve("Inter", "Regular").face).features(); + for t in ["ss01", "dlig", "frac", "tnum", "sups", "ordn"] { + assert!(inter.iter().any(|f| f == t), "{t} in {inter:?}"); + } + let serif = fonts::face(fonts::resolve("Noto Serif", "Regular").face).features(); + for t in ["smcp", "c2sc", "onum", "lnum"] { + assert!(serif.iter().any(|f| f == t), "{t} in {serif:?}"); + } + } + + #[test] + fn opentype_features_substitute_glyphs() { + let plain = st(100.0); + // Stylistic sets, fractions, ordinals, figure widths and discretionary ligatures. + assert_ne!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(1, true))), "ss01 alternate digits"); + assert_eq!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(20, true))), "Inter has no ss20"); + assert_ne!(ids("1/2", &plain), ids("1/2", &ot(|o| o.fractions = true)), "frac"); + assert_ne!(ids("1a 2o No", &plain), ids("1a 2o No", &ot(|o| o.ordinals = true)), "ordn"); + let tab = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Tabular)); + let prop = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Proportional)); + assert!((tab - prop).abs() > 1.0, "tabular {tab} vs proportional {prop}"); + // Old-style figures (Noto Serif). + let old = TextStyle { opentype: OpenType { figure_style: effectcraft_keyframe::FigureStyle::OldStyle, ..Default::default() }, ..serif() }; + assert_ne!(ids("123", &serif()), ids("123", &old), "onum"); + // Contextual alternates are on by default; turning them off can only keep or change glyphs. + let no_calt = ot(|o| o.contextual_alternates = false); + assert_eq!(ids("Hello", &plain).len(), ids("Hello", &no_calt).len()); + } + + #[test] + fn true_small_caps_and_superscripts_with_faux_fallback() { + // Noto Serif has smcp: lowercase letters take their own small-capital glyphs at full size. + let sc = TextStyle { caps: Caps::Small, ..serif() }; + let l = layout_uncached("Ab", &sc, &ParagraphStyle::default()); + let upper_b = ids("B", &serif())[0]; + assert_ne!(l.glyphs[1].id, upper_b, "true small cap glyph, not a scaled capital"); + assert_eq!(l.glyphs[1].size, 100.0); + // Inter has no smcp: a capital scaled down. + let faux = layout_uncached("Ab", &TextStyle { caps: Caps::Small, ..st(100.0) }, &ParagraphStyle::default()); + assert_eq!(faux.glyphs[1].id, ids("B", &st(100.0))[0]); + assert!((faux.glyphs[1].size - 78.0).abs() < 0.01); + // All Small Caps: capitals become small capitals too (c2sc). + let all = TextStyle { opentype: OpenType { all_small_caps: true, ..Default::default() }, ..serif() }; + let a = layout_uncached("AB", &all, &ParagraphStyle::default()); + assert_ne!(a.glyphs[0].id, ids("A", &serif())[0]); + // Superscript: Inter's sups figures at full size on the baseline; letters it lacks are + // synthesized (smaller and raised). + let sup = TextStyle { script: Script::Super, ..st(100.0) }; + let s2 = layout_uncached("2", &sup, &ParagraphStyle::default()); + assert_ne!(s2.glyphs[0].id, ids("2", &st(100.0))[0]); + assert_eq!(s2.glyphs[0].size, 100.0); + assert_eq!(s2.glyphs[0].y, 0.0); + let sq = layout_uncached("q", &sup, &ParagraphStyle::default()); + if !feature_substitutes(sq.glyphs[0].face, b"sups", 'q') { + assert!(sq.glyphs[0].size < 60.0 && sq.glyphs[0].y < -20.0, "{:?}", sq.glyphs[0]); + } + let sub = layout_uncached("2", &TextStyle { script: Script::Sub, ..st(100.0) }, &ParagraphStyle::default()); + assert_ne!(sub.glyphs[0].id, s2.glyphs[0].id); + } + #[test] fn kerning_and_ligatures_change_advances() { let kern = measure("AVAVAV", &st(100.0)); @@ -1110,7 +1279,11 @@ mod tests { let base = st(100.0); let sup = TextStyle { script: Script::Super, ..base.clone() }; let sub = TextStyle { script: Script::Sub, ..base.clone() }; - let l = rich("x2y2", &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default()); + // A letter the font has no superior / inferior glyph for: synthesized. + let inter = fonts::resolve("Inter", "Regular").face; + let c = "qwkzvjxyQWK".chars().find(|&c| !feature_substitutes(inter, b"sups", c) && !feature_substitutes(inter, b"subs", c)).unwrap(); + let text: String = ['x', c, 'y', c].iter().collect(); + let l = rich(&text, &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default()); let g = &l.glyphs; assert!((g[1].size - 58.3).abs() < 0.1); assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size"); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 26a659bd..beef0382 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -161,6 +161,7 @@ pub fn text_style(s: &CharStyle) -> TextStyle { BaselineOption::Subscript => Script::Sub, }, leading: s.leading.map(|l| l as f32), + opentype: s.opentype, } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 69c02433..28b42605 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -846,7 +846,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option None, diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 6f72af12..81b9d077 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -2,7 +2,7 @@ //! Character and Paragraph panels show and change the selected text (creating style runs); //! otherwise they apply to the whole selected text layer. -use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, Justify, Kerning, TextDoc}; +use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, TextDoc}; use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; @@ -102,7 +102,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(json!({"font": f})); } y += 28.0; - let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); + let sr = Rect::from_min_size(pos2(x0, y), vec2(w - 84.0, 22.0)); let spop = egui::Id::new("char-style-pop"); if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("char-style")).clicked() && enabled { widgets::open_popup(ui, spop); @@ -231,6 +231,16 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add("character.ligatures", lr, "Ligatures"); p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Ligatures", Tokens::ui(11.5), t.text_dim); + // OpenType: a popup with the font's layout features (like the Character panel's OpenType menu). + let or = Rect::from_min_size(pos2(sr.max.x + 6.0, sr.min.y), vec2(78.0, 22.0)); + let opop = egui::Id::new("char-opentype-pop"); + let n_on = doc.opentype.feature_settings().len(); + let olabel = if n_on > 0 { format!("OT ({n_on})") } else { "OpenType".to_string() }; + if widgets::dropdown(ui, or, &olabel, &t, egui::Id::new("char-opentype")).clicked() && enabled { + widgets::open_popup(ui, opop); + } + app.auto.add("character.opentype", or, "OpenType"); + opentype_popup(app, ui, opop, pos2((or.max.x - 300.0).max(rect.min.x), or.max.y), &doc, &mut actions); // Vertical type: Tate-Chu-Yoko and Standard Vertical Roman Alignment (Character panel menu). if doc.vertical { y += 22.0; @@ -266,6 +276,100 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// The Character panel's OpenType popup: feature toggles (dimmed when the font lacks them), +/// figure styles and the twenty stylistic sets. Every row is an automation target +/// (`character.opentype.`), and every change is a `layer.setText` attribute. +fn opentype_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, doc: &TextDoc, actions: &mut Vec) { + let open_id = id.with("open"); + if !ui.data(|d| d.get_temp::(open_id).unwrap_or(false)) { + return; + } + let t = app.tokens; + let feats = effectcraft_engine::font_features(&doc.font, &doc.style); + let has = |tag: &str| feats.iter().any(|f| f == tag); + let o = doc.opentype; + let mut rects: Vec<(String, Rect, String)> = vec![]; + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(230.0); + ui.label(egui::RichText::new(format!("{} {}", doc.font, doc.style)).small().color(t.text_dim)); + let toggles: [(&str, &str, bool, &[&str]); 12] = [ + ("ligatures", "Standard Ligatures", doc.ligatures, &["liga", "clig"]), + ("discretionaryLigatures", "Discretionary Ligatures", o.discretionary_ligatures, &["dlig"]), + ("contextualAlternates", "Contextual Alternates", o.contextual_alternates, &["calt"]), + ("stylisticAlternates", "Stylistic Alternates", o.stylistic_alternates, &["salt"]), + ("swash", "Swash", o.swash, &["swsh"]), + ("titling", "Titling Alternates", o.titling, &["titl"]), + ("ordinals", "Ordinals", o.ordinals, &["ordn"]), + ("fractions", "Fractions", o.fractions, &["frac"]), + ("smallCaps", "Small Caps", doc.small_caps, &["smcp"]), + ("allSmallCaps", "All Small Caps", o.all_small_caps, &["c2sc"]), + ("superscript", "Superscript / Superior", doc.baseline == BaselineOption::Superscript, &["sups"]), + ("subscript", "Subscript / Inferior", doc.baseline == BaselineOption::Subscript, &["subs"]), + ]; + for (key, label, on, tags) in toggles { + let avail = tags.iter().any(|g| has(g)); + // Unavailable features stay clickable: small caps and scripts are synthesized. + let synth = matches!(key, "smallCaps" | "allSmallCaps" | "superscript" | "subscript"); + let text = match (avail, synth) { + (true, _) => label.to_string(), + (false, true) => format!("{label} (synthesized)"), + (false, false) => format!("{label} (not in font)"), + }; + let r = ui.selectable_label(on, egui::RichText::new(text).color(if avail { t.text } else { t.text_faint })); + rects.push((format!("character.opentype.{key}"), r.rect, label.to_string())); + if r.clicked() { + actions.push(json!({key: !on})); + } + } + ui.separator(); + ui.label(egui::RichText::new("Figures").small().color(t.text_dim)); + let figs = [ + ("default", "Default Figure Style", FigureStyle::Default, FigureWidth::Default), + ("tabularLining", "Tabular Lining", FigureStyle::Lining, FigureWidth::Tabular), + ("proportionalOldstyle", "Proportional Oldstyle", FigureStyle::OldStyle, FigureWidth::Proportional), + ("proportionalLining", "Proportional Lining", FigureStyle::Lining, FigureWidth::Proportional), + ("tabularOldstyle", "Tabular Oldstyle", FigureStyle::OldStyle, FigureWidth::Tabular), + ]; + for (key, label, st, fw) in figs { + let on = o.figure_style == st && o.figure_width == fw; + let r = ui.selectable_label(on, label); + rects.push((format!("character.opentype.figures.{key}"), r.rect, label.to_string())); + if r.clicked() { + actions.push(json!({"figures": key})); + } + } + ui.separator(); + ui.label(egui::RichText::new("Stylistic Sets").small().color(t.text_dim)); + egui::Grid::new(id.with("sets")).spacing(vec2(3.0, 3.0)).show(ui, |ui| { + for n in 1..=20u32 { + let key = format!("ss{n:02}"); + let on = o.stylistic_set(n); + let avail = has(&key); + let r = ui.add_sized( + vec2(38.0, 20.0), + egui::Button::selectable(on, egui::RichText::new(format!("{n}")).color(if avail || on { t.text } else { t.text_faint })), + ); + rects.push((format!("character.opentype.{key}"), r.rect, format!("Stylistic Set {n}"))); + if r.clicked() { + actions.push(json!({key: !on})); + } + if n % 5 == 0 { + ui.end_row(); + } + } + }); + }); + }); + for (aid, r, label) in rects { + app.auto.add(&aid, r, &label); + } + let outside = ui.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer() && !area.response.hovered(); + if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(open_id, false)); + } +} + /// The seven Paragraph panel alignment buttons: (justify, `layer.setText` key). const ALIGNS: [(Justify, &str); 7] = [ (Justify::Left, "left"), diff --git a/docs/architecture.md b/docs/architecture.md index e5a6a836..7c341818 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,6 +26,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L2 | `text` | Fonts, shaping, layout, per-glyph geometry, text animators and selectors | | L2 | `effects` | The effect registry (241 effects) and their CPU implementations | | L2 | `svg` | SVG import (W3C SVG 1.1/2 static subset: shapes, paths, transforms, `use`, CSS, gradients) and rasterisation at any scale | +| L2 | `pdf` | PDF, PDF-compatible Illustrator (`.ai`) and EPS (PostScript subset) vector footage into the `svg` render tree: paths, fills, strokes, shadings, clipping, optional-content layers | | L2 | `model` | 3D models for Advanced 3D: glTF 2.0 (`.gltf`/`.glb`) and OBJ/MTL import (meshes, PBR metallic-roughness materials and textures, node hierarchy, skins, animations), parametric primitives, extruded/bevelled outline meshes and polygon triangulation | | L2 | `track` | Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation, Lucas–Kanade sub-pixel refinement), confidence, homography/affine/similarity solves | | L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown | diff --git a/docs/parity.md b/docs/parity.md index 9c2b2d47..3974630f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,8 +21,8 @@ Tracking 88%, Effects 85%, Web 65%. What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; Render Queue field render, crop/resize and templates; -approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; OpenType features and variable -font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; face tracking; and the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7). +approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; text in imported +PDF/AI files and Illustrator procset EPS; more codecs; 59 preferences not yet wired; ScriptUI; face tracking; and the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7). The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for history. @@ -109,14 +109,14 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit | | Output | 90% | 3.0 | multiple output modules, pre-render (WebM with VP9 alpha + Opus and WAV/AIFF audio-only landed: VP9 is intra-only, Opus CELT-only) | | Audio | 85% | 0.5 | audio to keyframes | -| Import | 88% | 2.0 | AI/EPS/PDF vector footage, PSD smart objects and 3D layers (PSD as footage/composition/retain layer sizes, SVG footage and Create Shapes from Vector Layer landed) | +| Import | 94% | 1.0 | text and images inside PDF/AI files, Illustrator EPS relying on Adobe procsets, smart-object perspective/warps, PSD 3D layers (PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) | | Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets | | Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) | | Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | | Animation | 67% | 10.0 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | -| Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | +| Text | ≈ 94% | 0.5 | no extruded strokes, variable-axis animation changes outlines but not advances (OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | -| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | +| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | | Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 74% | 5.0 | a richer Learn area (Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 21928b90..ad904eba 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -35,6 +35,7 @@ const LAYERS: &[(&str, u8)] = &[ ("effects", 2), ("track", 2), ("svg", 2), + ("pdf", 2), ("model", 2), ("render", 3), ("media", 3), @@ -58,6 +59,7 @@ const SAME_LAYER: &[(&str, &str)] = &[ ("path", "keyframe"), ("path", "raster"), ("text", "path"), + ("pdf", "svg"), ("effects", "project"), ("effects", "text"), ("effects", "path"),