Merge branch 'worktree-agent-afff1c93f51d0a61b'

This commit is contained in:
Brandon Thomas 2026-10-03 08:33:38 -04:00
commit f14b575e13
50 changed files with 5977 additions and 242 deletions

5
Cargo.lock generated
View File

@ -1410,9 +1410,14 @@ dependencies = [
name = "effectcraft-pdf"
version = "0.1.0"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
"effectcraft-text",
"image",
"kurbo",
"miniz_oxide 0.8.9",
"skrifa",
"zune-jpeg",
]
[[package]]

View File

@ -33,7 +33,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
continue;
};
let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?;
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref())
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)
.unwrap_or_else(|| Err(format!("{}: not a vector file", f.path)))
.map_err(EngineError::Other)?;
let id = s.edit("Create Shapes from Vector Layer", None, |proj, st| {

View File

@ -102,6 +102,14 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
"compositionLayerSizes" | "compositionRetainLayerSizes" | "layerSizes" => Some(true),
other => return Err(bad("file.import", format!("importAs: footage|composition|compositionLayerSizes, not `{other}`"))),
};
// PDF / Illustrator: which page (1-based, as in the Import dialog).
let page = match p.get("page") {
None | Some(Value::Null) => 0,
Some(v) => match v.as_u64().or_else(|| v.as_f64().filter(|x| x.fract() == 0.0 && *x >= 1.0).map(|x| x as u64)) {
Some(n) if n >= 1 => (n - 1) as u32,
_ => return Err(bad("file.import", "page: a page number from 1")),
},
};
let mut paths = paths;
let mut out_comps = vec![];
let mut ids = vec![];
@ -113,7 +121,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
Ok(b) if effectcraft_psd::is_psd(&b) => b,
Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => {
// PDF / Illustrator / EPS: one layer per file layer.
match import_vector_comp(s, &path, &b) {
match import_vector_comp(s, &path, &b, page) {
Ok((comp, items)) => {
out_comps.push(comp);
ids.extend(items);
@ -185,13 +193,31 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
f.frame_rate = r;
f.duration = r.tick_of(frames.max(1));
}
// Page: another page of a PDF / Illustrator file.
if page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") {
match s
.services
.read_file(path)
.map_err(|e| e.to_string())
.and_then(|b| effectcraft_pdf::parse_page(&b, page as usize).map_err(|e| e.to_string()))
{
Ok(doc) => {
(f.width, f.height) = doc.pixel_size();
f.page = page;
}
Err(e) => {
errors.push(format!("{path}: {e}"));
continue;
}
}
}
// Choose Layer: one layer of a Photoshop document (document-sized).
if let Some(sel) = &psd_layer
&& f.codec == "PSD"
{
match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) {
Some((index, name)) => {
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None })
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false })
}
None => {
errors.push(format!("{path}: no layer {sel}"));
@ -210,6 +236,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
let name = match &f.layer {
Some(l) => format!("{}/{name}", l.name),
None if f.page > 0 => format!("{name} (Page {})", f.page + 1),
None => name,
};
let label = match f.kind {
@ -298,14 +325,18 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retai
Ok((r.comp.0, items, r.warnings))
}
/// Import a PDF / Illustrator / EPS file as a composition (one undo step). Returns (comp, items).
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8]) -> Result<(u64, Vec<u64>)> {
let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into());
/// Import page `page` of a PDF / Illustrator / EPS file as a composition (one undo step).
/// Returns (comp, items).
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> Result<(u64, Vec<u64>)> {
let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into());
if page > 0 {
name = format!("{name} (Page {})", page + 1);
}
let rate = effectcraft_time::FrameRate::FPS_29_97;
let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs));
let (comp, folder, items) = s.edit("Import", None, |proj, st| {
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, rate, duration).map_err(EngineError::Other)?;
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?;
st.project_selection = vec![r.0];
Ok(r)
})?;
@ -478,7 +509,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["File", "Import"],
Some("Cmd+I"),
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
always,
import
),

View File

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

View File

@ -158,7 +158,7 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (
missing: false,
sequence: vec![],
color_profile: None,
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None }),
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None, placed: false }),
..Default::default()
};
let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
@ -191,8 +191,20 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz
let pl = &cx.psd.layers[index];
let so = pl.smart_object.clone()?;
let data = cx.psd.linked_data(&so.uuid)?;
let (w, h) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
let (cw, ch) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default();
// Perspective quads and warps are baked over the placed bounds, at up to 4× the document
// resolution when the embedded content has that much detail.
let bake = so.needs_bake();
let (w, h, k, bounds) = if bake {
let bb = so.placed_bounds(cw as f64, ch as f64);
let area = ((bb[2] - bb[0]) * (bb[3] - bb[1])).max(1.0);
let k = ((cw as f64 * ch as f64) / area).sqrt().clamp(1.0, 4.0);
let (w, h) = so.placed_size(cw as f64, ch as f64, k);
(w, h, k, bb)
} else {
(cw, ch, 1.0, [0.0; 4])
};
let f = Footage {
path: cx.path.to_string(),
kind: FootageKind::Still,
@ -204,13 +216,20 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz
alpha: AlphaMode::Straight,
loop_count: 1,
codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() },
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()) }),
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()), placed: bake }),
..Default::default()
};
let label = if file.is_empty() { name.to_string() } else { file };
let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
cx.items.push(id);
let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None);
if bake {
// The baked pixels cover the placed bounds: centred there, scaled back to 1×.
let c = [bounds[0] + w as f64 / k / 2.0, bounds[1] + h as f64 / k / 2.0];
set_vec2(&mut l, "transform/position", c);
set_vec2(&mut l, "transform/scale", [100.0 / k, 100.0 / k]);
return Some(l);
}
let (c, sx, sy, rot) = so.placement(w as f64, h as f64);
set_vec2(&mut l, "transform/position", c);
set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]);

View File

@ -326,6 +326,51 @@ fn variable_font_axes_animator() {
let _ = info;
}
#[test]
fn variable_font_axes_in_the_character_style() {
let mut s = comp(600, 200);
let t = s.execute("layer.newText", json!({"text": "Hamburg", "size": 60})).unwrap()["layer"].as_u64().unwrap();
// The attribute round-trips and is undoable even for static fonts (it is kept, unused).
s.execute("layer.setText", json!({"layer": t, "variations": {"wght": 650}})).unwrap();
let d = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap();
assert_eq!(d.style_at(0).variations, vec![("wght".to_string(), 650.0)]);
assert_eq!(effectcraft_keyframe::text_doc::char_style_json(&d.style_at(0))["variations"], json!({"wght": 650.0}));
s.execute("layer.setText", json!({"layer": t, "range": [0, 3], "variations": {"wght": null}})).unwrap();
let d = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap();
assert!(d.style_at(0).variations.is_empty() && !d.style_at(5).variations.is_empty());
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(0).variations.is_empty());
assert!(s.execute("layer.setText", json!({"layer": t, "variations": 3})).is_err());
let Some((face, axes)) = effectcraft_text::variable::find_variable_face() else {
eprintln!("no variable font installed: rendering check skipped");
return;
};
let info = effectcraft_text::fonts::face(face).info.clone();
if effectcraft_text::resolve(&info.family, &info.style).face != face {
eprintln!("variable face not reachable by family/style: skipped");
return;
}
s.execute("layer.setText", json!({"layer": t, "font": info.family, "style": info.style})).unwrap();
let a = axes.iter().find(|a| a.tag == "wght" && a.max > a.min).or_else(|| axes.iter().find(|a| a.max > a.min)).unwrap().clone();
let geom = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
let l = comp.layer(LayerId(t)).unwrap();
let paths = effectcraft_render::text::glyph_paths(&ctx, l);
let area: f64 = paths.iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum();
let right = paths.iter().map(|(p, _)| effectcraft_text::kurbo::Shape::bounding_box(p).x1).fold(f64::MIN, f64::max);
(area, right)
};
s.execute("layer.setText", json!({"layer": t, "variations": {a.tag.trim_end(): a.min}})).unwrap();
let lo = geom(&s);
s.execute("layer.setText", json!({"layer": t, "variations": {a.tag.trim_end(): a.max}})).unwrap();
let hi = geom(&s);
assert!((hi.0 - lo.0).abs() > 1.0, "{} outlines {lo:?} → {hi:?}", a.tag);
assert!((hi.1 - lo.1).abs() > 0.5, "{} advances {lo:?} → {hi:?}", a.tag);
}
#[test]
fn camera_focus_commands_undo_and_serde() {
let mut s = comp(400, 300);

View File

@ -122,48 +122,147 @@ fn group(ids: &mut Ids, name: &str, items: Vec<PropGroup>, opacity: f64) -> Prop
g
}
/// A Merge Paths item in `mode` (0 Merge, 3 Intersect).
fn merge_item(ids: &mut Ids, mode: u32) -> Option<PropGroup> {
let mut g = build::shape_simple_op(ids, "merge")?;
set(&mut g, "mode", Value::Enum(mode));
Some(g)
}
/// A shape inside clipping groups: its geometry merged into one compound path (an inner group
/// with Merge Paths), the clip paths, and Merge Paths ▸ Intersect before its paint, so the fill
/// is exactly the clipped area. Open stroked paths stay unclipped (an intersection would close
/// them).
fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
let items = shape_items(ids, s, m);
let path = s.geom.to_path();
let closed = path.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)) || s.fill.is_some();
if clips.is_empty() || !closed {
return items;
}
let (geom, paint): (Vec<PropGroup>, Vec<PropGroup>) =
items.into_iter().partition(|g| !matches!(g.match_id.as_str(), "fill" | "stroke" | "gfill" | "gstroke"));
let mut inner = geom;
inner.extend(merge_item(ids, 0));
let mut out = vec![build::shape_group(ids, "Clipped Path", inner)];
for (i, c) in clips.iter().enumerate() {
let mut parts = shape_paths(ids, c);
parts.extend(merge_item(ids, 0));
out.push(build::shape_group(ids, &format!("Clip Path {}", i + 1), parts));
}
out.extend(merge_item(ids, 3));
out.extend(paint);
out
}
/// Shape layer contents for an SVG document, in document pixels (transforms are baked into the
/// geometry; group and element opacity go to the group transforms). Top of the paint order first.
/// geometry; group and element opacity go to the group transforms, blend modes to the groups'
/// Blend Mode). Top of the paint order first.
///
/// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with
/// the clip paths through Merge Paths ▸ Intersect (see [`clipped_items`]). Clips enclosing the
/// whole document are layer masks instead ([`vector_clip_masks`], used by
/// [`shapes_from_vector`]). Images and soft masks are not converted.
pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec<PropGroup> {
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine) -> Vec<PropGroup> {
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
let mut out = vec![];
for n in nodes.iter().rev() {
match n {
Node::Group(g) => {
let items = walk(ids, &g.children, m * g.transform);
let gm = m * g.transform;
let mut inner_clips = clips.to_vec();
inner_clips.extend(g.clip.iter().map(|(p, _)| gm * p.clone()));
let items = walk(ids, &g.children, gm, &inner_clips);
if !items.is_empty() {
out.push(group(ids, &g.name, items, g.opacity));
let mut grp = group(ids, &g.name, items, g.opacity);
if g.blend != effectcraft_svg::BlendMode::Normal {
set(&mut grp, "blend", Value::Enum(blend_index(g.blend)));
}
out.push(grp);
}
}
Node::Shape(s) => {
let items = shape_items(ids, s, m * s.transform);
let sm = m * s.transform;
let items = if clips.is_empty() { shape_items(ids, s, sm) } else { clipped_items(ids, s, sm, clips) };
if !items.is_empty() {
out.push(group(ids, &s.name, items, s.opacity));
}
}
Node::Image(_) => {}
}
}
out
}
let root = &doc.root;
let items = walk(ids, &root.children, root.transform);
let mut clips = vec![];
clips.extend(root.clip.iter().map(|(p, _)| root.transform * p.clone()));
let items = walk(ids, &root.children, root.transform, &clips);
if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items }
}
/// The shape-layer Blend Mode index of an SVG / PDF blend mode.
fn blend_index(b: effectcraft_svg::BlendMode) -> u32 {
use effectcraft_color::BlendMode as P;
use effectcraft_svg::BlendMode as S;
let p = match b {
S::Normal => P::Normal,
S::Multiply => P::Multiply,
S::Screen => P::Screen,
S::Overlay => P::Overlay,
S::Darken => P::Darken,
S::Lighten => P::Lighten,
S::ColorDodge => P::ColorDodge,
S::ColorBurn => P::ColorBurn,
S::HardLight => P::HardLight,
S::SoftLight => P::SoftLight,
S::Difference => P::Difference,
S::Exclusion => P::Exclusion,
S::Hue => P::Hue,
S::Saturation => P::Saturation,
S::Color => P::Color,
S::Luminosity => P::Luminosity,
};
P::ALL.iter().position(|x| *x == p).unwrap_or(0) as u32
}
/// Clips that enclose the whole document (on a chain of single-child groups from the root, as
/// an Illustrator artboard clip or a PDF page clip), in document pixels, and the document with
/// those clips removed: Create Shapes from Vector Layer makes them layer masks.
pub fn vector_clip_masks(doc: &Doc) -> (Vec<BezPath>, Doc) {
let mut out = doc.clone();
let mut masks = vec![];
let mut m = Affine::IDENTITY;
let mut g = &mut out.root;
loop {
m *= g.transform;
for (p, _) in g.clip.drain(..) {
masks.push(m * p);
}
if g.children.len() != 1 {
break;
}
match &mut g.children[0] {
Node::Group(sub) if sub.mask.is_none() && sub.blend == effectcraft_svg::BlendMode::Normal => g = sub,
_ => break,
}
}
(masks, out)
}
/// Whether footage is a vector file (SVG / PDF / AI / EPS).
pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
}
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted
/// to one layer when `layer` names it.
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option<Result<Doc, String>> {
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page
/// `page` of a PDF, restricted to one layer when `layer` names it.
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32) -> Option<Result<Doc, String>> {
if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) {
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
}
effectcraft_pdf::sniff(bytes)?;
Some(
effectcraft_pdf::parse(bytes)
effectcraft_pdf::parse_page(bytes, page as usize)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
None => d,
@ -172,20 +271,21 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::
)
}
/// Import a PDF / Illustrator / EPS document as a composition the size of its page: one footage
/// item per file layer (each showing only that layer, continuously rasterisable), top layer
/// first. Returns (comp, folder, items).
/// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the
/// page: one footage item per file layer (each showing only that layer, continuously
/// rasterisable), top layer first. Returns (comp, folder, items).
pub fn import_vector_comp(
proj: &mut Project,
path: &str,
bytes: &[u8],
name: &str,
page: u32,
rate: effectcraft_time::FrameRate,
duration: effectcraft_time::Tick,
) -> Result<(ItemId, ItemId, Vec<ItemId>), String> {
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer};
let doc = effectcraft_pdf::parse(bytes).map_err(|e| format!("{path}: {e}"))?;
let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?;
let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF");
let (w, h) = doc.pixel_size();
let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
@ -206,6 +306,7 @@ pub fn import_vector_comp(
loop_count: 1,
codec: codec.into(),
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }),
page,
..Default::default()
};
let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f));
@ -239,13 +340,28 @@ fn like_source(dst: &mut Layer, src: &Layer) {
dst.switches.three_d = src.switches.three_d;
}
/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`.
/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. A clip
/// enclosing the whole document becomes the layer's masks (Add, then Intersect); nested clips
/// are kept with Merge Paths (see [`svg_contents`]).
pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer {
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
let items = svg_contents(&mut Ids(&mut proj.next_id), doc);
let (clips, doc) = vector_clip_masks(doc);
let mut ids = Ids(&mut proj.next_id);
let items = svg_contents(&mut ids, &doc);
if let Some(c) = l.props.sub_mut("contents") {
c.children.extend(items.into_iter().map(Into::into));
}
let mut n = 0;
for (k, clip) in clips.iter().enumerate() {
for sp in effectcraft_path::from_kurbo(clip) {
n += 1;
let mode = if k == 0 { MaskMode::Add } else { MaskMode::Intersect };
let mk = build::mask(&mut ids, &format!("Mask {n}"), sp, mode, [255, 255, 0]);
if let Some(masks) = l.props.sub_mut("masks") {
masks.children.push(mk.into());
}
}
}
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));

59
crates/gpu/src/bokeh.rs Normal file
View File

@ -0,0 +1,59 @@
//! Classic 3D depth of field on the GPU (`bokeh_*` kernels): the plane buffer padded, its
//! highlights boosted, row prefix sums, then one span gather per blur level
//! ([`effectcraft_render::three_d::bokeh::kernel_spans`]) accumulated with each pixel's level
//! weight — the CPU's `progressive_blur` step for step.
use effectcraft_render::three_d::PlaneDof;
use effectcraft_render::three_d::bokeh::kernel_spans;
use crate::context::{Enc, GpuImage, Params};
/// Blur `src` (an unpadded plane buffer) as `d` says. Returns the padded, blurred image.
pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option<GpuImage> {
let (w, h) = (src.width + 2 * d.pad, src.height + 2 * d.pad);
if !e.g.fits(w + 1, h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() {
return None;
}
let padded = e.image(w, h);
e.copy_into(src, &padded, d.pad, d.pad);
let boosted = if d.highlight.gain > 0.0 {
let out = e.image(w, h);
let mut p = Params::default();
p.f[0] = [d.highlight.gain as f32, d.highlight.threshold as f32, d.highlight.saturation as f32, 0.0];
e.pixels("bokeh_boost", &p, &padded, None, &out, None);
out
} else {
padded
};
let prefix = e.image(w + 1, h);
e.dispatch("bokeh_prefix", &Params::default(), &boosted, None, &prefix, None, (h.div_ceil(64), 1));
// Radius map, then every level's spans.
let mut data: Vec<f32> = d.radius.as_ref().clone();
let mut levels = vec![];
for &r in &d.levels {
let (kernels, norm) = kernel_spans(&d.iris, r as f64).unwrap_or_else(|| (vec![(1.0, vec![(0, -0.5, 0.5)])], 1.0));
let off = data.len() as u32;
let mut count = 0u32;
for (wt, spans) in &kernels {
for &(dy, x0, x1) in spans {
data.extend_from_slice(&[dy as f32, x0 as f32, x1 as f32, *wt]);
count += 1;
}
}
levels.push((off, count, norm));
}
let buf = e.data(&data);
let n = d.levels.len() as u32;
let (lo, hi) = (d.levels[0], d.levels[d.levels.len() - 1]);
let mut acc = e.image(w, h);
for (k, (off, count, norm)) in levels.into_iter().enumerate() {
let out = e.image(w, h);
let mut p = Params::default();
p.u[0] = [off, count, k as u32, n];
p.u[1] = [u32::from(k == 0), 0, 0, 0];
p.f[0] = [1.0 / norm, lo, hi, 0.0];
e.pixels("bokeh_gather", &p, &prefix, Some(&acc), &out, Some(&buf));
acc = out;
}
Some(acc)
}

View File

@ -136,11 +136,18 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option<Gp
{
return None;
}
// Plane textures: the layer uploads (cached per buffer; bokeh depth of field is already in
// them).
// Plane textures: the layer uploads (cached per buffer), with bokeh depth of field blurred
// here when the plane has it.
let mut texs: Vec<(GpuImage, [f64; 2])> = Vec::with_capacity(run.planes.len());
for p in &run.planes {
texs.push((e.g.upload_buf(&p.buf)?, p.buf.offset));
let up = e.g.upload_buf(&p.buf)?;
match &p.dof {
Some(d) => {
let pad = d.pad as f64;
texs.push((crate::bokeh::apply(e, &up, d)?, [p.buf.offset[0] + pad, p.buf.offset[1] + pad]));
}
None => texs.push((up, p.buf.offset)),
}
}
let sizes: Vec<(u32, u32)> = texs.iter().map(|(t, _)| (t.width, t.height)).collect();
let (aw, ah, pos) = pack(&sizes, e.g.max_dim)?;

View File

@ -27,6 +27,9 @@ const ENTRIES: &[&str] = &[
"shadow_combine",
"pointwise",
"adjust_mix",
"bokeh_boost",
"bokeh_prefix",
"bokeh_gather",
];
/// Entry points that also bind group 1 (four read-only storage buffers; see

View File

@ -28,6 +28,7 @@
//! egui-wgpu to draw.
mod adv3d;
mod bokeh;
mod classic3d;
mod context;
mod effects;

View File

@ -844,3 +844,118 @@ fn adjust_mix(@builtin(global_invocation_id) gid: vec3<u32>) {
let k = data[(gid.y * P.u[0].x + gid.x) * 4u + 3u] * P.f[0].x;
textureStore(out, p, c + (a - c) * k);
}
// ---------------------------------------------------------------- Classic 3D bokeh depth of field
// (render::three_d::bokeh: boost_highlights, row prefix sums, span gathers per blur level
// blended by each pixel's radius.)
// Highlight boost: f[0] = (gain, threshold, saturation).
@compute @workgroup_size(16, 16)
fn bokeh_boost(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
var c = textureLoad(src, p, 0);
let gain = P.f[0].x;
let thr = clamp(P.f[0].y, 0.0, 1.0);
let satk = P.f[0].z;
if (c.w > 0.0 && gain > 0.0) {
let a = c.w;
let rgb = c.xyz / a;
let l = 0.2126 * rgb.x + 0.7152 * rgb.y + 0.0722 * rgb.z;
if (!(l < thr || (thr >= 1.0 && l < 1.0))) {
var over = 1.0;
if (thr < 1.0) {
over = clamp((l - thr) / (1.0 - thr), 0.0, 1.0);
}
let k = 1.0 + gain * 4.0 * max(over, 0.25);
let sat = 1.0 + satk;
let o = max(vec3<f32>(l) + (rgb - vec3<f32>(l)) * sat, vec3<f32>(0.0)) * k;
c = vec4<f32>(o * a, a);
}
}
textureStore(out, p, c);
}
// Row prefix sums: out is (w + 1) × h, out[x] = sum of src[0..x]. One invocation per row.
@compute @workgroup_size(64, 1)
fn bokeh_prefix(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = vec2<i32>(textureDimensions(src));
let y = i32(gid.x);
if (y >= dims.y) {
return;
}
var acc = vec4<f32>(0.0);
textureStore(out, vec2<i32>(0, y), acc);
for (var x = 0; x < dims.x; x = x + 1) {
acc = acc + textureLoad(src, vec2<i32>(x, y), 0);
textureStore(out, vec2<i32>(x + 1, y), acc);
}
}
fn bokeh_prefix_at(y: i32, t: f32) -> vec4<f32> {
let n = i32(textureDimensions(src).x) - 1;
if (t <= 0.0) {
return vec4<f32>(0.0);
}
if (t >= f32(n)) {
return textureLoad(src, vec2<i32>(n, y), 0);
}
let i = i32(floor(t));
let f = t - f32(i);
let a = textureLoad(src, vec2<i32>(i, y), 0);
let b = textureLoad(src, vec2<i32>(i + 1, y), 0);
return a + (b - a) * f;
}
// One blur level added into the accumulator `aux`: src = prefix sums; data = the radius map
// (w·h) then spans (dy, x0, x1, weight). u[0] = (span offset, span count, level, levels),
// u[1].x = 1 for the first level (no accumulator yet); f[0] = (1 / norm, lo, hi).
@compute @workgroup_size(16, 16)
fn bokeh_gather(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
var acc = vec4<f32>(0.0);
if (P.u[1].x == 0u) {
acc = textureLoad(aux, p, 0);
}
let r = data[u32(p.y) * u32(dims.x) + u32(p.x)];
let k = P.u[0].z;
let n = P.u[0].w;
var wgt = 1.0;
if (n > 1u) {
let lo = P.f[0].y;
let hi = P.f[0].z;
let f = clamp((r - lo) / (hi - lo) * f32(n - 1u), 0.0, f32(n - 1u));
let kk = min(u32(floor(f)), n - 2u);
let t = f - f32(kk);
if (k == kk) {
wgt = 1.0 - t;
} else if (k == kk + 1u) {
wgt = t;
} else {
wgt = 0.0;
}
}
if (wgt > 0.0) {
var g = vec4<f32>(0.0);
let off = P.u[0].x;
for (var i = 0u; i < P.u[0].y; i = i + 1u) {
let s = off + i * 4u;
let sy = p.y + i32(data[s]);
if (sy < 0 || sy >= dims.y) {
continue;
}
let hi = bokeh_prefix_at(sy, f32(p.x) + data[s + 2u] + 0.5);
let lo = bokeh_prefix_at(sy, f32(p.x) + data[s + 1u] + 0.5);
g = g + (hi - lo) * data[s + 3u];
}
acc = acc + g * P.f[0].x * wgt;
}
textureStore(out, p, acc);
}

View File

@ -218,3 +218,73 @@ fn the_gpu_draws_classic_3d_runs_itself() {
let d = crate::tests::diff(&cpu, &img, 1e-3);
assert!(d.over * 200 <= d.total, "{d:?}");
}
/// Classic 3D bokeh depth of field runs on the GPU: iris shapes, highlights, fringe, Fast
/// Rectangle and a tilted plane (progressive blur) match the CPU.
#[test]
fn bokeh_depth_of_field_runs_on_the_gpu() {
let cases: [(&str, &[(&str, Value)]); 4] = [
("fast rectangle", &[]),
("hexagon + highlights", &[("irisShape", Value::Enum(4)), ("irisRotation", n(15.0)), ("highlightGain", n(40.0)), ("highlightThreshold", n(150.0))]),
("round + fringe", &[("irisShape", Value::Enum(6)), ("irisRoundness", n(60.0)), ("irisDiffractionFringe", n(80.0))]),
("wide triangle", &[("irisShape", Value::Enum(1)), ("irisAspectRatio", n(2.0))]),
];
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for (label, vals) in cases {
// The crossing planes are tilted (±35°): every plane blurs progressively.
let mut s = crossing(depth, BlendMode::Normal);
camera(&mut s, [80.0, 50.0, -220.0], Some((180.0, 40.0)));
for l in &mut s.p.comp_mut(s.cid).unwrap().layers {
if l.name == "Camera" {
for (k, v) in vals {
set(l, &format!("cameraOptions/{k}"), v.clone());
}
}
}
check(&format!("bokeh {label} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// The blur is the GPU's: the prepared planes carry it unapplied.
if depth == BitDepth::Bpc32
&& let Some(g) = gpu()
{
let r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap();
let run: Vec<_> = r.visible_layers(&ctx).into_iter().filter(|l| l.is_3d()).collect();
let prep = r.prepare_3d_run(&ctx, &run, (W, H)).unwrap();
assert!(prep.planes.iter().filter(|p| p.draw).all(|p| p.dof.is_some()), "{label}: depth of field deferred to the GPU");
let mut e = crate::context::Enc::new(g.context());
let canvas = e.image(W, H);
assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU");
}
}
}
}
/// A 2D layer whose track matte is a 3D layer: the matte is drawn through the camera on the
/// GPU walk as on the CPU.
#[test]
fn two_d_layer_with_a_three_d_track_matte() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for kind in [MatteKind::Alpha, MatteKind::Luma] {
let mut s = scene(depth);
let bg = s.solid([0.1, 0.12, 0.2], W, H);
s.push(bg);
let fill = s.footage(W, H);
let fid = s.push(fill);
// The matte: a solid turned in 3D, seen through a camera.
let m = three_d(s.solid([0.9, 0.9, 0.9], 70, 50), [80.0, 50.0, 30.0], 40.0);
let mid = s.push(m);
camera(&mut s, [40.0, 30.0, -200.0], None);
let layers = &mut s.p.comp_mut(s.cid).unwrap().layers;
let fi = layers.iter().position(|l| l.id == fid).unwrap();
layers[fi].track_matte = Some(TrackMatte { layer: mid, kind });
if let Some(ml) = layers.iter_mut().find(|l| l.id == mid) {
ml.switches.video = false;
}
check(&format!("3d matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// The same with the Advanced 3D renderer (its CPU path draws 3D mattes through the
// camera with composite_iso_with).
s.p.comp_mut(s.cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D;
check(&format!("3d matte {kind:?} {depth:?} (Advanced 3D)"), compare_at(&s, opts(), Tick::ZERO), 0.005);
}
}
}

View File

@ -247,15 +247,13 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() {
let c = s.active_comp().unwrap();
assert_eq!(c.layers[0].id.0, sid);
let shapes = s.render(cid, t, RenderOpts::default());
// Clipping groups are not shape-layer features: the clipped bar (rows 33–47) draws in
// full; everything else matches.
let rows = |img: &Image| Image {
width: w,
height: h - 15,
data: img.data.iter().enumerate().filter(|(i, _)| !(33..48).contains(&(*i as u32 / w))).map(|(_, p)| *p).collect(),
};
let d = mean_diff(&rows(&shapes), &rows(&direct));
// The clipped bar (rows 33–47) keeps its clip through Merge Paths ▸ Intersect.
let d = mean_diff(&shapes, &direct);
assert!(d < 0.01, "{name}: shapes vs page mean diff {d}");
for x in [10, 50, 100] {
let (a, b) = (shapes.get(x, 40), direct.get(x, 40));
assert!((0..4).all(|c| (a[c] - b[c]).abs() < 0.02), "{name}: bar clipped at x = {x}: {a:?} vs {b:?}");
}
let fill = shapes.get(60, 30);
assert!(fill[0] > 0.9 && fill[1] < 0.1, "red shape {fill:?}");
}
@ -334,3 +332,132 @@ fn eps_footage_imports() {
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
assert!(img.get(10, 10)[0] > 0.99 && img.get(30, 10)[2] > 0.99, "{:?} {:?}", img.get(10, 10), img.get(30, 10));
}
/// A two-page PDF: page 2 has text in a non-embedded standard font inside a page-wide clip.
fn two_page_pdf() -> Vec<u8> {
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Contents 4 0 R >>".into(), None),
(4, "<< >>".into(), Some(b"1 0 0 rg 0 0 120 80 re f".to_vec())),
(
5,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 60] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> >> /Contents 6 0 R >>".into(),
None,
),
(6, "<< >>".into(), Some(b"10 5 80 50 re W n 0 0 1 rg 0 0 100 60 re f 1 1 0 rg BT /F1 40 Tf 15 15 Td (Hi) Tj ET".to_vec())),
],
1,
)
}
#[test]
fn pdf_pages_text_and_clips_to_masks() {
let bytes = two_page_pdf();
let path = tmp("pages.pdf");
std::fs::write(&path, &bytes).unwrap();
let mut s = effectcraft_host::session();
// Page 2 as footage (File ▸ Import ▸ Page), named after its page.
let r = s.execute_checked("file.import", json!({"paths": [path], "page": 2})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let item = ItemId(r["items"][0].as_u64().unwrap());
let it = s.project.item(item).unwrap();
assert_eq!(it.name, "pages.pdf (Page 2)");
let effectcraft_project::ItemKind::Footage(f) = &it.kind else { panic!() };
assert_eq!((f.width, f.height, f.page), (100, 60, 1));
assert!(s.execute_checked("file.import", json!({"paths": [path], "page": 3})).unwrap()["errors"][0].as_str().unwrap().contains("no page 3"));
assert!(s.execute("file.import", json!({"paths": [path], "page": 0})).is_err());
let doc = effectcraft_pdf::parse_page(&bytes, 1).unwrap();
let direct = effectcraft_svg::rasterize(&doc, 100, 60, 1.0);
s.execute("comp.new", json!({"name": "P", "width": 100, "height": 60, "frameRate": 30, "duration": 1})).unwrap();
let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
let t = effectcraft_time::Tick::ZERO;
let foot = s.render(cid, t, RenderOpts::default());
assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "page 2 footage");
// Text drew (yellow glyphs over the blue page).
let ink = (0..60).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| foot.get(x, y)[0] > 0.9).count();
assert!(ink > 100, "text pixels {ink}");
// Create Shapes from Vector Layer: the page-wide clip becomes the layer's mask, the text a
// shape group named after it.
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
let sid = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap()["layers"][0].as_u64().unwrap();
let c = s.active_comp().unwrap();
let l = c.layer(effectcraft_project::LayerId(sid)).unwrap();
assert_eq!(l.masks().map(|m| m.groups().count()), Some(1));
fn names(g: &effectcraft_project::PropGroup, out: &mut Vec<String>) {
for c in g.groups() {
out.push(c.name.clone());
names(c, out);
}
}
let mut names_found = vec![];
names(l.props.sub("contents").unwrap(), &mut names_found);
let names = names_found;
assert!(names.iter().any(|n| n == "Text: Hi"), "{names:?}");
let shapes = s.render(cid, t, RenderOpts::default());
let d = mean_diff(&shapes, &direct);
assert!(d < 0.01, "shapes vs page {d}");
assert_eq!(shapes.get(5, 30)[3], 0.0, "masked outside the clip");
// Page 1 as a composition.
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "page": 1})).unwrap();
let comp = s.project.comp(ItemId(r["comps"][0].as_u64().unwrap())).unwrap();
assert_eq!((comp.width, comp.height), (120, 80));
}
#[test]
fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() {
let mut inner = WDoc::new(20, 10);
inner.layers =
vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])];
let inner_bytes = write(&inner);
let mut d = WDoc::new(64, 48);
// A perspective trapezoid, and an affine placement with an Arc warp.
let trap = [[20.0, 4.0], [44.0, 10.0], [44.0, 38.0], [20.0, 44.0]];
let warp = warp_descriptor("warpArc", 50.0, 0.0, 0.0, [0.0, 0.0, 20.0, 10.0], None);
d.layers = vec![
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
WLayer::solid("Pinned", Rect::new(20, 4, 24, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped("so-1", trap, [20.0, 10.0], None)),
];
d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())];
let mut d2 = WDoc::new(64, 48);
d2.layers = vec![
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
WLayer::solid("Arc", Rect::new(20, 10, 40, 20), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped(
"so-2",
[[20.0, 10.0], [60.0, 10.0], [60.0, 30.0], [20.0, 30.0]],
[20.0, 10.0],
Some(warp),
)),
];
d2.linked = vec![("so-2".into(), "inner.psd".into(), *b"8BPS", inner_bytes)];
let parsed = effectcraft_psd::Psd::parse(write(&d2)).unwrap();
let so = parsed.layers.iter().find_map(|l| l.smart_object.clone()).unwrap();
assert_eq!(so.warp.as_ref().map(|w| w.style.as_str()), Some("warpArc"));
let mut s = effectcraft_host::session();
let render = |s: &mut effectcraft_engine::Session, doc: &WDoc, file: &str| {
let path = tmp(file);
std::fs::write(&path, write(doc)).unwrap();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = ItemId(r["comps"][0].as_u64().unwrap());
let placed = s.project.comp(cid).unwrap().layers.iter().any(|l| {
let effectcraft_project::LayerSource::Footage { item } = l.source else { return false };
matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.layer.as_ref().is_some_and(|x| x.placed))
});
assert!(placed, "{file}: baked as placed");
s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default())
};
let img = render(&mut s, &d, "pinned.psd");
let (red, green) = (img.get(24, 24), img.get(40, 24));
assert!(red[0] > 0.9 && red[1] < 0.1, "{red:?}");
assert!(green[1] > 0.9 && green[0] < 0.1, "{green:?}");
// Above the slanted top edge (y = 4 + 6·(x − 20)/24): background.
assert!(img.get(42, 6)[0] < 0.05 && img.get(42, 6)[1] < 0.05, "{:?}", img.get(42, 6));
let img = render(&mut s, &d2, "arc.psd");
// The arc keeps the top centre and pulls the corners down and out.
let top = img.get(40, 11);
assert!(top[0] > 0.5 || top[1] > 0.5, "top centre covered {top:?}");
assert!(img.get(21, 11)[0] < 0.05 && img.get(21, 11)[1] < 0.05, "corner region empty {:?}", img.get(21, 11));
}

View File

@ -299,6 +299,10 @@ pub struct CharStyle {
/// OpenType features (stylistic sets, figures, fractions…).
#[serde(skip_serializing_if = "OpenType::is_default")]
pub opentype: OpenType,
/// Variable font axis values (tag, user units; axes not listed stay at their defaults):
/// the Character panel's Variable Font Axes.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub variations: Vec<(String, f32)>,
}
impl Default for CharStyle {
@ -383,6 +387,9 @@ pub struct TextDoc {
/// Base OpenType features (see [`CharStyle::opentype`]).
#[serde(skip_serializing_if = "OpenType::is_default")]
pub opentype: OpenType,
/// Base variable font axis values (see [`CharStyle::variations`]).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub variations: Vec<(String, f32)>,
pub indent_left: f64,
pub indent_right: f64,
pub indent_first: f64,
@ -435,6 +442,7 @@ impl Default for TextDoc {
tate_chu_yoko: false,
vertical_roman_upright: false,
opentype: OpenType::default(),
variations: vec![],
indent_left: 0.0,
indent_right: 0.0,
indent_first: 0.0,
@ -522,6 +530,7 @@ impl TextDoc {
tate_chu_yoko: self.tate_chu_yoko,
vertical_roman_upright: self.vertical_roman_upright,
opentype: self.opentype,
variations: self.variations.clone(),
}
}
@ -550,6 +559,7 @@ impl TextDoc {
self.tate_chu_yoko = s.tate_chu_yoko;
self.vertical_roman_upright = s.vertical_roman_upright;
self.opentype = s.opentype;
self.variations = s.variations.clone();
}
/// The base (first paragraph's) settings.
@ -951,6 +961,7 @@ pub const CHAR_ATTRS: &[&str] = &[
"figureStyle",
"figureWidth",
"figures",
"variations",
];
/// Paragraph attribute keys of `layer.setText`.
@ -1068,6 +1079,28 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result<bool, Stri
s.opentype.figure_style = st;
s.opentype.figure_width = w;
}
// Variable font axes: `{wght: 650, wdth: 90}` (null removes an axis), or `{}` / null to
// reset every axis to its default.
"variations" => match v {
J::Null => s.variations.clear(),
J::Object(m) => {
if m.is_empty() {
s.variations.clear();
}
for (tag, val) in m {
if tag.is_empty() || tag.len() > 4 {
return Err(format!("variations: `{tag}` is not an axis tag"));
}
s.variations.retain(|(t, _)| t != tag);
match val {
J::Null => {}
_ => s.variations.push((tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32)),
}
}
s.variations.sort_by(|a, b| a.0.cmp(&b.0));
}
_ => return Err("variations: expected {tag: number, …}".into()),
},
k if k.len() == 4 && k.starts_with("ss") && k[2..].parse::<u32>().is_ok_and(|n| (1..=20).contains(&n)) => {
let n = k[2..].parse::<u32>().unwrap_or(0);
s.opentype.set_stylistic_set(n, flag(key, v)?);
@ -1123,6 +1156,7 @@ pub fn char_style_json(s: &CharStyle) -> J {
"stylisticSets": (1..=20u32).filter(|n| s.opentype.stylistic_set(*n)).collect::<Vec<_>>(),
"swash": s.opentype.swash, "titling": s.opentype.titling, "ordinals": s.opentype.ordinals, "fractions": s.opentype.fractions,
"allSmallCaps": s.opentype.all_small_caps,
"variations": s.variations.iter().map(|(t, v)| (t.clone(), J::from(*v as f64))).collect::<serde_json::Map<String, J>>(),
"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" },
})

View File

@ -13,15 +13,20 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool {
path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes)
}
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted
/// to the footage's layer when it names one.
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option<std::result::Result<effectcraft_svg::Doc, String>> {
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page
/// `page` of a PDF, restricted to the footage's layer when it names one.
pub fn vector_doc(
path: &str,
bytes: &[u8],
layer: Option<&effectcraft_project::SourceLayer>,
page: u32,
) -> Option<std::result::Result<effectcraft_svg::Doc, String>> {
if is_svg(path, bytes) {
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
}
effectcraft_pdf::sniff(bytes)?;
Some(
effectcraft_pdf::parse(bytes)
effectcraft_pdf::parse_page(bytes, page as usize)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
None => d,
@ -70,13 +75,26 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
// A smart object's embedded file: an embedded document's merged image, or an image.
if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) {
let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?;
if effectcraft_psd::is_psd(data) {
let px = if effectcraft_psd::is_psd(data) {
let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
let px = inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?
} else {
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
let rgba = img.to_rgba32f();
effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() }
};
// A perspective quad or a warp: baked as placed.
let l = footage.layer.as_ref().expect("embedded implies a layer");
if l.placed
&& let Some(so) = psd.layers.get(l.index as usize).and_then(|pl| pl.smart_object.as_ref())
{
let bb = so.placed_bounds(px.width as f64, px.height as f64);
let k = footage.width as f64 / (bb[2] - bb[0]).max(1e-9);
if let Some((baked, _)) = so.render_placed(&px, k, Some((footage.width, footage.height))) {
return Ok(Some(straight_to_image(baked.width, baked.height, &baked.data, op)));
}
}
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
return Ok(Some(crate::convert::dynamic_to_image(&img, op)));
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
}
let px = match &footage.layer {
Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size),
@ -86,7 +104,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
}
if is_svg(path, bytes) || is_pdf_like(path, bytes) {
let doc = vector_doc(path, bytes, footage.layer.as_ref()).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?;
let doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?;
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
// The rasteriser produces premultiplied pixels already.
@ -97,8 +115,8 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
}
/// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize).
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, scale: f64) -> Option<Image> {
let doc = vector_doc(path, bytes, layer)?.ok()?;
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option<Image> {
let doc = vector_doc(path, bytes, layer, page)?.ok()?;
let (w, h) = doc.pixel_size();
let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384));
Some(effectcraft_svg::rasterize(&doc, sw, sh, scale))

View File

@ -489,9 +489,10 @@ impl Inner {
Loc { key: key(&footage.sequence[i as usize], 0), media_t: None }
}
// A layer of a layered still is keyed by its layer index and size mode (smart objects
// by their embedded file).
FootageKind::Still if footage.layer.is_some() => {
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 });
// by their embedded file; PDF pages by their number).
FootageKind::Still if footage.layer.is_some() || footage.page != 0 => {
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 })
+ ((footage.page as i64) << 42);
Loc { key: key(&footage.path, l), media_t: None }
}
FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None },
@ -622,7 +623,7 @@ impl FootageSource for MediaPool {
return None;
}
let bytes = self.inner.read(&footage.path).ok()?;
crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), scale).map(Arc::new)
crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), footage.page, scale).map(Arc::new)
}
fn aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {

View File

@ -1,6 +1,6 @@
[package]
name = "effectcraft-pdf"
description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages and EPS (a PostScript subset) parsed into the SVG render tree (paths, fills, strokes, axial / radial shadings, clipping, optional-content layers)"
description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages and EPS (a PostScript subset) parsed into the SVG render tree (paths, fills, strokes, shadings, tiling patterns, clipping, text with embedded fonts, images, blend modes, soft masks, optional-content layers)"
version.workspace = true
edition.workspace = true
license.workspace = true
@ -12,4 +12,14 @@ workspace = true
[dependencies]
effectcraft-svg = { workspace = true }
kurbo = { workspace = true }
effectcraft-text = { workspace = true }
miniz_oxide = "0.8"
# Embedded TrueType / OpenType font programs (MIT OR Apache-2.0).
skrifa = { workspace = true }
# DCT (JPEG) images (MIT OR Apache-2.0 OR Zlib; pure Rust).
zune-jpeg = "0.5"
[dev-dependencies]
# Encodes JPEG fixtures for the DCT image test.
image = { workspace = true }
effectcraft-raster = { workspace = true }

View File

@ -7,10 +7,15 @@ layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as comp
layer per file layer (File ▸ Import ▸ Composition).
Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file
structure, filters, content streams, graphics state, colour spaces, functions, shadings and
optional content; the **PostScript Language Reference, third edition** and the **Encapsulated
PostScript File Format Specification 3.0** for EPS. No third-party PDF/PostScript code was read;
test files are generated in code ([`write`](src/write.rs) and the tests).
structure, filters, content streams, graphics state, colour spaces, functions, shadings, text,
fonts, images, patterns, transparency (blend modes, soft masks) and optional content; Adobe
**Technical Note #5176** (The Compact Font Format Specification) and **#5177** (The Type 2
Charstring Format) for CFF font programs; **Adobe Type 1 Font Format** (version 1.1) for Type 1
font programs; the **PostScript Language Reference, third edition** and the **Encapsulated
PostScript File Format Specification 3.0** for EPS. TrueType font programs are read through
skrifa and JPEG (DCT) images through zune-jpeg (both permissively licensed, pure Rust). No
third-party PDF/PostScript/font code was read; test files (including the CFF and Type 1 fonts)
are generated in code ([`write`](src/write.rs) and the tests).
## What is read
@ -36,13 +41,52 @@ test files are generated in code ([`write`](src/write.rs) and the tests).
binary headers and `%%HiResBoundingBox` / `%%BoundingBox` are honoured; `%%BeginData` /
`%%BeginBinary` / preview sections are skipped.
- **Text** (§9): text objects and state (`Tc`, `Tw`, `Tz`, `TL`, `Ts`, `Tr`, `Tf`, `Td`,
`TD`, `Tm`, `T*`), `Tj`, `TJ` (with kerning), `'` and `"`; render modes fill / stroke /
fill-and-stroke / invisible and the clipping modes (glyph outlines clip until `Q`). Glyphs
become outlines (one compound shape per show operator, named "Text: …" after the string
through `ToUnicode` or the glyph names). Fonts: simple fonts with `/Widths`, base encodings
(Standard, WinAnsi, MacRoman), `/Differences` and built-in encodings; composite (Type 0) fonts
with Identity-H / Identity-V and embedded CMaps (`cidrange` / `cidchar`), `/W` and `/DW`
widths and `CIDToGIDMap`. Embedded programs: TrueType (`FontFile2`, cmap (3,1) / (3,0) /
(1,0) and `post` names), CFF (`FontFile3` `/Type1C`, `/CIDFontType0C`, `/OpenType`;
name-keyed and CID-keyed with FDArray / FDSelect, flex, hint masks, `seac`), Type 1
(`FontFile`, eexec, `/Subrs`, flex and hint replacement through othersubrs, `seac`) and Type 3
glyph procedures. Fonts that are not embedded (the standard 14 and others) are drawn with
the bundled OFL fonts by name: Inter (sans serif, Regular / Italic / SemiBold / Bold), Noto
Serif (Times and other serif names) and JetBrains Mono (Courier), with their own advances
when the PDF gives no widths.
- **Images** (§8.9): image XObjects and inline images (`BI … ID … EI`, abbreviated keys);
1, 2, 4, 8 and 16 bits per component with `/Decode`; any colour space above (Indexed through
its palette, ICCBased through its `/Alternate` space); DCT (JPEG, through zune-jpeg; Adobe
inverted CMYK) and the general filters; stencil masks (`/ImageMask`) in the fill colour;
soft masks (`/SMask` images) and colour-key and explicit `/Mask`s. Images become image nodes
of the render tree (bilinear, box-filtered when minified).
- **Transparency:** blend modes (`/BM`, all 16 PDF modes) and soft masks in the graphics state
(`/SMask` with `/S /Luminosity` and `/BC` backdrops, or `/S /Alpha`) as groups of the render
tree, open until the state changes or `Q`.
- **Patterns:** tiling patterns (coloured and uncoloured, `XStep` / `YStep`, the cell clipped
to `/BBox`, at most 4096 tiles per fill) for fills; strokes with a tiling pattern use its
colour (uncoloured) or grey.
- **Functions:** sampled, exponential, stitching and PostScript calculator (type 4) functions.
- **Pages:** any page ([`parse_page`], [`page_count`]); File ▸ Import takes `page` (from 1) and
the Import dialog asks for it; footage remembers its page (`Footage::page`).
## Limitations
- Text is not drawn (embedded fonts are not read); images, tiling patterns, mesh shadings
(types 4–7), PostScript calculator functions, soft masks and blend modes are skipped. What a
document skipped is listed in `Doc::skipped`.
- Mesh shadings (types 4–7) and function-based shadings (type 1), JPX / CCITT / JBIG2 images,
predefined CJK CMaps other than Identity, vertical metrics (`/W2`; vertical text advances by
the font size) and transfer functions (`/TR`) are not read; knockout and isolated transparency
groups composite as normal groups, and a soft mask applies to each painting operation while
it is set rather than to their composite. What a document skipped is listed in `Doc::skipped`.
- Illustrator EPS files whose drawing depends on Adobe's procedure-set resources (defined in
the file through constructs beyond this subset) and Level 3 `shfill` gradients in EPS draw
only what the subset understands; save such artwork as PDF-compatible `.ai` or PDF instead.
- Shape-layer conversion has no clipping: clipped artwork converts unclipped.
- Only the first page of a multi-page PDF is imported.
EPS text is not drawn.
- **Create Shapes from Vector Layer:** clips that enclose the whole page become layer masks
(the first clip Add, later ones Intersect). Nested clipping groups become Merge Paths: each
closed shape inside them is a group with its geometry merged into one compound path, the clip
paths, and Merge Paths ▸ Intersect before its fill and stroke, so fills match exactly and
strokes follow the clipped outline; open stroked paths inside clips stay unclipped. Blend
modes become the groups' Blend Mode; images and soft masks are not converted (shape layers
have no images; use the footage, or Import As: Composition).

View File

@ -19,7 +19,7 @@ pub(crate) struct Builder {
}
pub(crate) fn group(name: &str) -> Group {
Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![] }
Group::new(name)
}
impl Builder {
@ -70,7 +70,21 @@ impl Builder {
self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain }));
}
/// Open a group (blend mode, soft mask) closed with the graphics state.
pub fn open_group(&mut self, g: Group) {
self.stack.push((g, Kind::Plain));
}
/// Add a finished node (an image, pattern tiles).
pub fn push(&mut self, n: Node) {
self.add(n, false);
}
pub fn fill_stroke(&mut self, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option<Stroke>) {
self.fill_stroke_named("Path", path, transform, fill, stroke);
}
pub fn fill_stroke_named(&mut self, name: &str, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option<Stroke>) {
if path.elements().is_empty() || (fill.is_none() && stroke.is_none()) {
return;
}
@ -79,7 +93,7 @@ impl Builder {
Some((p, o, r)) => (Some(p), o, r),
None => (None, 1.0, FillRule::NonZero),
};
let s = Shape { name: "Path".into(), geom: Geom::Path(path), transform, fill: f, fill_opacity: fo, fill_rule: rule, stroke, opacity: 1.0 };
let s = Shape { name: name.into(), geom: Geom::Path(path), transform, fill: f, fill_opacity: fo, fill_rule: rule, stroke, opacity: 1.0 };
self.add(Node::Shape(s), false);
}

1034
crates/pdf/src/cff.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
//! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled,
//! exponential and stitching; PostScript calculator functions are not evaluated).
//! exponential, stitching and PostScript calculator functions).
use crate::object::{Dict, File, Obj, decode_stream};
@ -98,7 +98,13 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
let head = a.first().map(|h| file.resolve(h)).and_then(Obj::name).unwrap_or("");
match head {
"ICCBased" => {
let n = a.get(1).map(|s| file.resolve(s)).and_then(Obj::dict).and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize;
let d = a.get(1).map(|s| file.resolve(s)).and_then(Obj::dict);
// The profile's alternate space when it names one (profiles are not
// evaluated), else the device space with the same number of components.
if let Some(alt) = d.and_then(|d| file.get(d, "Alternate")) {
return color_space(file, alt, res);
}
let n = d.and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize;
match n {
1 => Cs::Gray,
4 => Cs::Cmyk,
@ -163,9 +169,197 @@ pub enum Func {
},
/// An array of one-output functions.
Array(Vec<Func>),
/// Type 4: a PostScript calculator program (§7.10.5).
Calc {
domain: Vec<f64>,
range: Vec<f64>,
code: Vec<PsOp>,
},
Unsupported,
}
/// A PostScript calculator token; procedures nest for `if` / `ifelse`.
#[derive(Clone, Debug, PartialEq)]
pub enum PsOp {
Num(f64),
Op(String),
Proc(Vec<PsOp>),
}
fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec<PsOp> {
let mut out = vec![];
while let Some(o) = lx.next() {
match o {
Obj::Num(n) => out.push(PsOp::Num(n)),
Obj::Bool(b) => out.push(PsOp::Num(if b { 1.0 } else { 0.0 })),
Obj::Op(op) if op == "{" => {
if depth < 32 {
out.push(PsOp::Proc(ps_parse(lx, depth + 1)));
}
}
Obj::Op(op) if op == "}" => return out,
Obj::Op(op) => out.push(PsOp::Op(op)),
_ => {}
}
}
out
}
fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
if depth > 32 {
return;
}
let mut procs: Vec<&[PsOp]> = vec![];
for op in code {
match op {
PsOp::Num(n) => st.push(*n),
PsOp::Proc(p) => procs.push(p),
PsOp::Op(o) => {
let pop = |st: &mut Vec<f64>| st.pop().unwrap_or(0.0);
let b = |v: bool| if v { 1.0 } else { 0.0 };
match o.as_str() {
"if" => {
let p = procs.pop();
if pop(st) != 0.0
&& let Some(p) = p
{
ps_run(p, st, depth + 1);
}
}
"ifelse" => {
let (f, t) = (procs.pop(), procs.pop());
let c = pop(st) != 0.0;
if let Some(p) = if c { t } else { f } {
ps_run(p, st, depth + 1);
}
}
"true" => st.push(1.0),
"false" => st.push(0.0),
"pop" => {
st.pop();
}
"dup" => {
let v = st.last().copied().unwrap_or(0.0);
st.push(v);
}
"exch" => {
let n = st.len();
if n >= 2 {
st.swap(n - 1, n - 2);
}
}
"copy" => {
let k = pop(st).max(0.0) as usize;
let n = st.len();
if k <= n {
let tail = st[n - k..].to_vec();
st.extend(tail);
}
}
"index" => {
let k = pop(st).max(0.0) as usize;
let v = st.len().checked_sub(k + 1).map(|i| st[i]).unwrap_or(0.0);
st.push(v);
}
"roll" => {
let j = pop(st) as i64;
let n = pop(st).max(0.0) as usize;
let len = st.len();
if n > 0 && n <= len {
let s = &mut st[len - n..];
let r = j.rem_euclid(n as i64) as usize;
s.rotate_right(r);
}
}
_ => {
// Unary and binary operators.
let unary: Option<fn(f64) -> f64> = match o.as_str() {
"abs" => Some(f64::abs),
"neg" => Some(|x| -x),
"ceiling" => Some(f64::ceil),
"floor" => Some(f64::floor),
"round" => Some(|x: f64| (x + 0.5).floor()),
"truncate" | "cvi" => Some(f64::trunc),
"cvr" => Some(|x| x),
"sqrt" => Some(|x: f64| x.max(0.0).sqrt()),
"sin" => Some(|x: f64| x.to_radians().sin()),
"cos" => Some(|x: f64| x.to_radians().cos()),
"ln" => Some(|x: f64| x.max(1e-300).ln()),
"log" => Some(|x: f64| x.max(1e-300).log10()),
"not" => Some(|x: f64| {
if x == 0.0 {
1.0
} else if x == 1.0 {
0.0
} else {
!(x as i64) as f64
}
}),
_ => None,
};
if let Some(f) = unary {
let x = pop(st);
st.push(f(x));
continue;
}
let y = pop(st);
let x = pop(st);
let v = match o.as_str() {
"add" => x + y,
"sub" => x - y,
"mul" => x * y,
"div" => {
if y != 0.0 {
x / y
} else {
0.0
}
}
"idiv" => {
if y as i64 != 0 {
((x as i64) / (y as i64)) as f64
} else {
0.0
}
}
"mod" => {
if y as i64 != 0 {
((x as i64) % (y as i64)) as f64
} else {
0.0
}
}
"exp" => x.powf(y),
"atan" => {
let a = x.atan2(y).to_degrees();
if a < 0.0 { a + 360.0 } else { a }
}
"eq" => b(x == y),
"ne" => b(x != y),
"gt" => b(x > y),
"ge" => b(x >= y),
"lt" => b(x < y),
"le" => b(x <= y),
"and" => ((x as i64) & (y as i64)) as f64,
"or" => ((x as i64) | (y as i64)) as f64,
"xor" => ((x as i64) ^ (y as i64)) as f64,
"bitshift" => {
let (v, s) = (x as i64, y as i64);
(if s >= 0 { v << s.min(62) } else { v >> (-s).min(62) }) as f64
}
_ => {
st.push(x);
y
}
};
st.push(v);
}
}
}
}
}
}
impl Func {
pub fn read(file: &File, o: &Obj) -> Func {
let o = file.resolve(o);
@ -215,6 +409,17 @@ impl Func {
}
Func::Sampled { domain, size, outputs, encode, decode, samples }
}
4 => {
let Obj::Stream(sd, raw) = o else { return Func::Unsupported };
let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported };
let mut lx = crate::object::Lexer::new(&data, 0);
// The program is one procedure: `{ … }`.
let code = match ps_parse(&mut lx, 0).into_iter().next() {
Some(PsOp::Proc(p)) => p,
_ => return Func::Unsupported,
};
Func::Calc { domain: dom, range: file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default(), code }
}
_ => Func::Unsupported,
}
}
@ -222,6 +427,20 @@ impl Func {
/// Evaluate at the first input (the functions used by shadings and tints take one input
/// in practice; extra inputs of DeviceN tints are averaged in).
pub fn eval(&self, input: &[f64]) -> Vec<f64> {
if let Func::Calc { domain, range, code } = self {
let mut st: Vec<f64> = input
.iter()
.enumerate()
.map(|(i, v)| match (domain.get(2 * i), domain.get(2 * i + 1)) {
(Some(a), Some(b)) => v.clamp(a.min(*b), a.max(*b)),
_ => *v,
})
.collect();
ps_run(code, &mut st, 0);
let n = range.len() / 2;
let k = st.len().saturating_sub(n);
return st[k..].iter().enumerate().map(|(i, v)| v.clamp(range[2 * i], range[2 * i + 1])).collect();
}
let t = input.first().copied().unwrap_or(0.0);
self.eval1(t)
}
@ -262,6 +481,7 @@ impl Func {
.collect()
}
Func::Array(fs) => fs.iter().map(|f| f.eval1(t).first().copied().unwrap_or(0.0)).collect(),
Func::Calc { .. } => self.eval(&[t]),
Func::Unsupported => vec![0.5],
}
}
@ -286,3 +506,19 @@ pub fn text_string(b: &[u8]) -> String {
}
b.iter().map(|&c| c as char).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calculator_functions() {
// Two inputs → three outputs: { 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } if 0 }
let code = b"{ 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } { 0.25 mul } ifelse 0 }";
let mut lx = crate::object::Lexer::new(code, 0);
let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0).into_iter().next() else { panic!() };
let f = Func::Calc { domain: vec![0.0, 1.0, 0.0, 1.0], range: vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0], code };
assert_eq!(f.eval(&[0.5, 0.4]), vec![0.2, 0.1125, 0.0]);
assert_eq!(f.eval(&[1.0, 0.8]), vec![0.8, 1.0, 0.0]);
}
}

389
crates/pdf/src/encoding.rs Normal file
View File

@ -0,0 +1,389 @@
//! Simple-font encodings (ISO 32000-1 §9.6.6 and Annex D): StandardEncoding, WinAnsiEncoding
//! and MacRomanEncoding as code → Unicode tables, and the Latin glyph names those encodings
//! use (with `uniXXXX` / `uXXXX[XX]` names for everything else).
/// Glyph names of the Latin text character set (Annex D.2), with their Unicode values.
const NAMES: &[(&str, u32)] = &[
("space", 0x20),
("exclam", 0x21),
("quotedbl", 0x22),
("numbersign", 0x23),
("dollar", 0x24),
("percent", 0x25),
("ampersand", 0x26),
("quotesingle", 0x27),
("parenleft", 0x28),
("parenright", 0x29),
("asterisk", 0x2A),
("plus", 0x2B),
("comma", 0x2C),
("hyphen", 0x2D),
("period", 0x2E),
("slash", 0x2F),
("zero", 0x30),
("one", 0x31),
("two", 0x32),
("three", 0x33),
("four", 0x34),
("five", 0x35),
("six", 0x36),
("seven", 0x37),
("eight", 0x38),
("nine", 0x39),
("colon", 0x3A),
("semicolon", 0x3B),
("less", 0x3C),
("equal", 0x3D),
("greater", 0x3E),
("question", 0x3F),
("at", 0x40),
("bracketleft", 0x5B),
("backslash", 0x5C),
("bracketright", 0x5D),
("asciicircum", 0x5E),
("underscore", 0x5F),
("grave", 0x60),
("braceleft", 0x7B),
("bar", 0x7C),
("braceright", 0x7D),
("asciitilde", 0x7E),
("exclamdown", 0xA1),
("cent", 0xA2),
("sterling", 0xA3),
("currency", 0xA4),
("yen", 0xA5),
("brokenbar", 0xA6),
("section", 0xA7),
("dieresis", 0xA8),
("copyright", 0xA9),
("ordfeminine", 0xAA),
("guillemotleft", 0xAB),
("logicalnot", 0xAC),
("registered", 0xAE),
("macron", 0xAF),
("degree", 0xB0),
("plusminus", 0xB1),
("twosuperior", 0xB2),
("threesuperior", 0xB3),
("acute", 0xB4),
("mu", 0xB5),
("paragraph", 0xB6),
("periodcentered", 0xB7),
("cedilla", 0xB8),
("onesuperior", 0xB9),
("ordmasculine", 0xBA),
("guillemotright", 0xBB),
("onequarter", 0xBC),
("onehalf", 0xBD),
("threequarters", 0xBE),
("questiondown", 0xBF),
("Agrave", 0xC0),
("Aacute", 0xC1),
("Acircumflex", 0xC2),
("Atilde", 0xC3),
("Adieresis", 0xC4),
("Aring", 0xC5),
("AE", 0xC6),
("Ccedilla", 0xC7),
("Egrave", 0xC8),
("Eacute", 0xC9),
("Ecircumflex", 0xCA),
("Edieresis", 0xCB),
("Igrave", 0xCC),
("Iacute", 0xCD),
("Icircumflex", 0xCE),
("Idieresis", 0xCF),
("Eth", 0xD0),
("Ntilde", 0xD1),
("Ograve", 0xD2),
("Oacute", 0xD3),
("Ocircumflex", 0xD4),
("Otilde", 0xD5),
("Odieresis", 0xD6),
("multiply", 0xD7),
("Oslash", 0xD8),
("Ugrave", 0xD9),
("Uacute", 0xDA),
("Ucircumflex", 0xDB),
("Udieresis", 0xDC),
("Yacute", 0xDD),
("Thorn", 0xDE),
("germandbls", 0xDF),
("agrave", 0xE0),
("aacute", 0xE1),
("acircumflex", 0xE2),
("atilde", 0xE3),
("adieresis", 0xE4),
("aring", 0xE5),
("ae", 0xE6),
("ccedilla", 0xE7),
("egrave", 0xE8),
("eacute", 0xE9),
("ecircumflex", 0xEA),
("edieresis", 0xEB),
("igrave", 0xEC),
("iacute", 0xED),
("icircumflex", 0xEE),
("idieresis", 0xEF),
("eth", 0xF0),
("ntilde", 0xF1),
("ograve", 0xF2),
("oacute", 0xF3),
("ocircumflex", 0xF4),
("otilde", 0xF5),
("odieresis", 0xF6),
("divide", 0xF7),
("oslash", 0xF8),
("ugrave", 0xF9),
("uacute", 0xFA),
("ucircumflex", 0xFB),
("udieresis", 0xFC),
("yacute", 0xFD),
("thorn", 0xFE),
("ydieresis", 0xFF),
("dotlessi", 0x131),
("Lslash", 0x141),
("lslash", 0x142),
("OE", 0x152),
("oe", 0x153),
("Scaron", 0x160),
("scaron", 0x161),
("Ydieresis", 0x178),
("Zcaron", 0x17D),
("zcaron", 0x17E),
("florin", 0x192),
("circumflex", 0x2C6),
("caron", 0x2C7),
("breve", 0x2D8),
("dotaccent", 0x2D9),
("ring", 0x2DA),
("ogonek", 0x2DB),
("tilde", 0x2DC),
("hungarumlaut", 0x2DD),
("Omega", 0x3A9),
("pi", 0x3C0),
("endash", 0x2013),
("emdash", 0x2014),
("quoteleft", 0x2018),
("quoteright", 0x2019),
("quotesinglbase", 0x201A),
("quotedblleft", 0x201C),
("quotedblright", 0x201D),
("quotedblbase", 0x201E),
("dagger", 0x2020),
("daggerdbl", 0x2021),
("bullet", 0x2022),
("ellipsis", 0x2026),
("perthousand", 0x2030),
("guilsinglleft", 0x2039),
("guilsinglright", 0x203A),
("fraction", 0x2044),
("Euro", 0x20AC),
("trademark", 0x2122),
("partialdiff", 0x2202),
("Delta", 0x2206),
("product", 0x220F),
("summation", 0x2211),
("minus", 0x2212),
("radical", 0x221A),
("infinity", 0x221E),
("integral", 0x222B),
("approxequal", 0x2248),
("notequal", 0x2260),
("lessequal", 0x2264),
("greaterequal", 0x2265),
("lozenge", 0x25CA),
("fi", 0xFB01),
("fl", 0xFB02),
("nbspace", 0xA0),
("sfthyphen", 0xAD),
];
/// Unicode value of a glyph name (`A`, `eacute`, `uni00E9`, `u1F600`, `f_i`→first part).
pub fn name_to_unicode(name: &str) -> Option<char> {
let base = name.split('.').next().unwrap_or(name);
if base.len() == 1 && base.as_bytes()[0].is_ascii_alphabetic() {
return base.chars().next();
}
if let Some((_, u)) = NAMES.iter().find(|(n, _)| *n == base) {
return char::from_u32(*u);
}
if let Some(h) = base.strip_prefix("uni")
&& h.len() >= 4
{
return u32::from_str_radix(&h[..4], 16).ok().and_then(char::from_u32);
}
if let Some(h) = base.strip_prefix('u')
&& (4..=6).contains(&h.len())
{
return u32::from_str_radix(h, 16).ok().and_then(char::from_u32);
}
// Ligature names (`f_f_i`): the first component.
if let Some((a, _)) = base.split_once('_') {
return name_to_unicode(a);
}
None
}
/// The glyph name of a Unicode value (Latin names, else `uniXXXX`).
pub fn unicode_to_name(c: char) -> String {
let u = c as u32;
if c.is_ascii_alphabetic() {
return c.to_string();
}
match NAMES.iter().find(|(_, v)| *v == u) {
Some((n, _)) => (*n).to_string(),
None => format!("uni{u:04X}"),
}
}
/// A simple font's base encoding.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Base {
Standard,
WinAnsi,
MacRoman,
}
impl Base {
pub fn from_name(n: &str) -> Option<Base> {
match n {
"StandardEncoding" => Some(Base::Standard),
"WinAnsiEncoding" => Some(Base::WinAnsi),
"MacRomanEncoding" => Some(Base::MacRoman),
_ => None,
}
}
/// The character of a code (`None` for unassigned codes).
pub fn unicode(self, code: u8) -> Option<char> {
let u = match self {
Base::Standard => standard(code)?,
Base::WinAnsi => win_ansi(code)?,
Base::MacRoman => mac_roman(code)?,
};
char::from_u32(u)
}
/// The glyph name of a code.
pub fn name(self, code: u8) -> Option<String> {
self.unicode(code).map(unicode_to_name)
}
}
fn ascii(code: u8) -> Option<u32> {
(0x20..0x7F).contains(&code).then_some(code as u32)
}
fn standard(code: u8) -> Option<u32> {
Some(match code {
0x27 => 0x2019,
0x60 => 0x2018,
0x20..=0x7E => code as u32,
0xA1 => 0xA1,
0xA2 => 0xA2,
0xA3 => 0xA3,
0xA4 => 0x2044,
0xA5 => 0xA5,
0xA6 => 0x192,
0xA7 => 0xA7,
0xA8 => 0xA4,
0xA9 => 0x27,
0xAA => 0x201C,
0xAB => 0xAB,
0xAC => 0x2039,
0xAD => 0x203A,
0xAE => 0xFB01,
0xAF => 0xFB02,
0xB1 => 0x2013,
0xB2 => 0x2020,
0xB3 => 0x2021,
0xB4 => 0xB7,
0xB6 => 0xB6,
0xB7 => 0x2022,
0xB8 => 0x201A,
0xB9 => 0x201E,
0xBA => 0x201D,
0xBB => 0xBB,
0xBC => 0x2026,
0xBD => 0x2030,
0xBF => 0xBF,
0xC1 => 0x60,
0xC2 => 0xB4,
0xC3 => 0x2C6,
0xC4 => 0x2DC,
0xC5 => 0xAF,
0xC6 => 0x2D8,
0xC7 => 0x2D9,
0xC8 => 0xA8,
0xCA => 0x2DA,
0xCB => 0xB8,
0xCD => 0x2DD,
0xCE => 0x2DB,
0xCF => 0x2C7,
0xD0 => 0x2014,
0xE1 => 0xC6,
0xE3 => 0xAA,
0xE8 => 0x141,
0xE9 => 0xD8,
0xEA => 0x152,
0xEB => 0xBA,
0xF1 => 0xE6,
0xF5 => 0x131,
0xF8 => 0x142,
0xF9 => 0xF8,
0xFA => 0x153,
0xFB => 0xDF,
_ => return None,
})
}
/// Windows code page 1252 (WinAnsiEncoding; bullets for the unused codes, as Annex D notes).
fn win_ansi(code: u8) -> Option<u32> {
const HIGH: [u32; 32] = [
0x20AC, 0x2022, 0x201A, 0x192, 0x201E, 0x2026, 0x2020, 0x2021, 0x2C6, 0x2030, 0x160, 0x2039, 0x152, 0x2022, 0x17D, 0x2022, 0x2022, 0x2018, 0x2019,
0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x2DC, 0x2122, 0x161, 0x203A, 0x153, 0x2022, 0x17E, 0x178,
];
match code {
0x80..=0x9F => Some(HIGH[(code - 0x80) as usize]),
0xA0 => Some(0x20),
0xA1..=0xFF => Some(code as u32),
_ => ascii(code),
}
}
fn mac_roman(code: u8) -> Option<u32> {
const HIGH: [u32; 128] = [
0xC4, 0xC5, 0xC7, 0xC9, 0xD1, 0xD6, 0xDC, 0xE1, 0xE0, 0xE2, 0xE4, 0xE3, 0xE5, 0xE7, 0xE9, 0xE8, 0xEA, 0xEB, 0xED, 0xEC, 0xEE, 0xEF, 0xF1, 0xF3, 0xF2,
0xF4, 0xF6, 0xF5, 0xFA, 0xF9, 0xFB, 0xFC, 0x2020, 0xB0, 0xA2, 0xA3, 0xA7, 0x2022, 0xB6, 0xDF, 0xAE, 0xA9, 0x2122, 0xB4, 0xA8, 0x2260, 0xC6, 0xD8,
0x221E, 0xB1, 0x2264, 0x2265, 0xA5, 0xB5, 0x2202, 0x2211, 0x220F, 0x3C0, 0x222B, 0xAA, 0xBA, 0x3A9, 0xE6, 0xF8, 0xBF, 0xA1, 0xAC, 0x221A, 0x192,
0x2248, 0x2206, 0xAB, 0xBB, 0x2026, 0x20, 0xC0, 0xC3, 0xD5, 0x152, 0x153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0xF7, 0x25CA, 0xFF, 0x178,
0x2044, 0xA4, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0xB7, 0x201A, 0x201E, 0x2030, 0xC2, 0xCA, 0xC1, 0xCB, 0xC8, 0xCD, 0xCE, 0xCF, 0xCC, 0xD3, 0xD4,
0xF8FF, 0xD2, 0xDA, 0xDB, 0xD9, 0x131, 0x2C6, 0x2DC, 0xAF, 0x2D8, 0x2D9, 0x2DA, 0xB8, 0x2DD, 0x2DB, 0x2C7,
];
match code {
0x80..=0xFF => Some(HIGH[(code - 0x80) as usize]),
0x27 => Some(0x27),
_ => ascii(code),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodings_and_names() {
assert_eq!(Base::WinAnsi.unicode(0x80), Some('€'));
assert_eq!(Base::WinAnsi.name(0xE9).as_deref(), Some("eacute"));
assert_eq!(Base::Standard.name(0x27).as_deref(), Some("quoteright"));
assert_eq!(Base::Standard.name(0xAE).as_deref(), Some("fi"));
assert_eq!(Base::MacRoman.unicode(0x8E), Some('é'));
assert_eq!(name_to_unicode("uni20AC"), Some('€'));
assert_eq!(name_to_unicode("u1F600"), Some('😀'));
assert_eq!(name_to_unicode("a.sc"), Some('a'));
assert_eq!(name_to_unicode("f_i"), Some('f'));
assert_eq!(unicode_to_name('Ω'), "Omega");
assert_eq!(unicode_to_name('中'), "uni4E2D");
}
}

636
crates/pdf/src/font.rs Normal file
View File

@ -0,0 +1,636 @@
//! PDF fonts (ISO 32000-1 §9.5–9.10): simple fonts (Type 1, TrueType, Type 3) and composite
//! (Type 0) fonts with their encodings, widths and embedded programs, turned into glyph
//! outlines in text space.
//!
//! Embedded programs: TrueType (`FontFile2`, through skrifa), CFF (`FontFile3` `/Type1C`,
//! `/CIDFontType0C` and OpenType, [`crate::cff`]), Type 1 (`FontFile`, [`crate::type1`]) and
//! Type 3 glyph procedures (drawn by the content interpreter). Fonts that are not embedded
//! (the standard 14 and others) are drawn with the bundled OFL fonts: Inter for sans serif,
//! Noto Serif for serif and JetBrains Mono for monospaced families.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use kurbo::{Affine, BezPath};
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::TableProvider;
use skrifa::raw::tables::cmap::PlatformId;
use skrifa::{FontRef, GlyphId, MetadataProvider};
use crate::cff::Cff;
use crate::encoding::{Base, name_to_unicode};
use crate::object::{Dict, File, Lexer, Obj, decode_stream};
use crate::type1::Type1;
pub(crate) enum Program {
TrueType(Arc<Vec<u8>>),
Cff(Box<Cff>),
Type1(Box<Type1>),
/// A bundled stand-in (not embedded).
Fallback(&'static [u8]),
/// Glyph procedures: (name → content stream, resources).
Type3 {
procs: HashMap<String, Vec<u8>>,
resources: Option<Dict>,
},
None,
}
pub(crate) struct Font {
/// Two-byte (or CMap-coded) codes and CIDs.
pub composite: bool,
pub program: Program,
/// Glyph space → text space.
pub matrix: Affine,
first_char: u32,
widths: Vec<f64>,
cid_widths: HashMap<u32, f64>,
default_width: f64,
/// Simple fonts: code → glyph name.
names: Vec<Option<String>>,
symbolic: bool,
to_unicode: HashMap<u32, String>,
cid_to_gid: Option<Vec<u16>>,
/// Composite fonts: code space ranges (bytes, lo, hi) and code → CID ranges.
codespace: Vec<(usize, u32, u32)>,
cid_ranges: Vec<(u32, u32, u32)>,
pub vertical: bool,
cache: RefCell<HashMap<u32, Option<Arc<BezPath>>>>,
/// Program glyph advances (font units → text space) for fonts without `/Widths`.
has_widths: bool,
pub base_name: String,
}
struct Pen(BezPath);
impl OutlinePen for Pen {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((x as f64, y as f64));
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((x as f64, y as f64));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((cx0 as f64, cy0 as f64), (x as f64, y as f64));
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((cx0 as f64, cy0 as f64), (cx1 as f64, cy1 as f64), (x as f64, y as f64));
}
fn close(&mut self) {
self.0.close_path();
}
}
/// A bundled stand-in for a font that is not embedded, by its PostScript name.
fn fallback(base: &str) -> &'static [u8] {
use effectcraft_text::fonts::*;
let b = base.to_ascii_lowercase();
if b.contains("courier") || b.contains("mono") || b.contains("consol") {
return JETBRAINS_MONO_REGULAR;
}
if b.contains("times")
|| b.contains("serif") && !b.contains("sans")
|| b.contains("georgia")
|| b.contains("garamond")
|| b.contains("minion")
|| b.contains("roman")
{
return NOTO_SERIF_REGULAR;
}
if b.contains("bold") || b.contains("black") || b.contains("heavy") {
return INTER_BOLD;
}
if b.contains("semibold") || b.contains("demi") {
return INTER_SEMIBOLD;
}
if b.contains("italic") || b.contains("oblique") {
return INTER_ITALIC;
}
INTER_REGULAR
}
fn sfnt_upem(data: &[u8]) -> f64 {
FontRef::new(data).ok().and_then(|f| f.head().ok().map(|h| h.units_per_em() as f64)).filter(|u| *u > 0.0).unwrap_or(1000.0)
}
/// A TrueType / OpenType glyph's outline in font units.
fn sfnt_outline(data: &[u8], gid: u16) -> Option<BezPath> {
let font = FontRef::new(data).ok()?;
let g = font.outline_glyphs().get(GlyphId::new(gid as u32))?;
let mut pen = Pen(BezPath::new());
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).ok()?;
Some(pen.0)
}
fn sfnt_advance(data: &[u8], gid: u16) -> Option<f64> {
let font = FontRef::new(data).ok()?;
font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid as u32)).map(|a| a as f64)
}
/// A glyph of a TrueType font for a simple-font code (§9.6.6.4): the (3,1) subtable through
/// the glyph's Unicode value, the (3,0) symbol subtable at `0xF000 + code`, the (1,0) Macintosh
/// subtable by code, then `post` glyph names.
fn sfnt_glyph(data: &[u8], code: u8, name: Option<&str>, symbolic: bool) -> Option<u16> {
let font = FontRef::new(data).ok()?;
let cmap = font.cmap().ok();
let sub = |pid: PlatformId, eid: u16| {
let cmap = cmap.as_ref()?;
cmap.encoding_records().iter().find(|r| r.platform_id() == pid && r.encoding_id() == eid).and_then(|r| r.subtable(cmap.offset_data()).ok())
};
let uni = name.and_then(name_to_unicode);
let ok = |g: Option<GlyphId>| g.map(|g| g.to_u32() as u16).filter(|g| *g != 0);
if !symbolic
&& let Some(u) = uni
&& let Some(g) = ok(sub(PlatformId::Windows, 1).and_then(|s| s.map_codepoint(u as u32))).or_else(|| ok(font.charmap().map(u)))
{
return Some(g);
}
if let Some(s) = sub(PlatformId::Windows, 0) {
for c in [0xF000 + code as u32, 0xF100 + code as u32, 0xF200 + code as u32, code as u32] {
if let Some(g) = ok(s.map_codepoint(c)) {
return Some(g);
}
}
}
if let Some(g) = ok(sub(PlatformId::Macintosh, 0).and_then(|s| s.map_codepoint(code as u32))) {
return Some(g);
}
if let Some(n) = name
&& let Some((g, _)) = font.glyph_names().iter().find(|(_, gn)| gn.as_str() == n)
{
return Some(g.to_u32() as u16);
}
if let Some(u) = uni {
return ok(font.charmap().map(u));
}
// Symbolic fonts without a usable cmap: the code as the glyph index.
(symbolic && code != 0).then_some(code as u16)
}
/// `/Differences [code /name /name … code /name …]`.
fn differences(file: &File, enc: &Dict, names: &mut [Option<String>]) {
let Some(diffs) = file.get(enc, "Differences").and_then(Obj::array) else { return };
let mut code = 0usize;
for d in diffs {
match file.resolve(d) {
Obj::Num(n) => code = *n as usize,
Obj::Name(n) => {
if let Some(slot) = names.get_mut(code) {
*slot = Some(n.clone());
}
code += 1;
}
_ => {}
}
}
}
/// A ToUnicode or encoding CMap: `bfchar` / `bfrange` → strings, `cidchar` / `cidrange` →
/// CIDs, `codespacerange` → code lengths.
#[derive(Default)]
struct CMap {
codespace: Vec<(usize, u32, u32)>,
unicode: HashMap<u32, String>,
cids: Vec<(u32, u32, u32)>,
}
fn be(b: &[u8]) -> u32 {
b.iter().fold(0u32, |a, &c| (a << 8) | c as u32)
}
fn utf16(b: &[u8]) -> String {
let u: Vec<u16> = b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect();
String::from_utf16_lossy(&u)
}
fn parse_cmap(data: &[u8]) -> CMap {
let mut m = CMap::default();
let mut lx = Lexer::new(data, 0);
let mut args: Vec<Obj> = vec![];
let mut mode = "";
while let Some(o) = lx.next() {
let Obj::Op(op) = o else {
args.push(o);
continue;
};
match op.as_str() {
"begincodespacerange" => mode = "cs",
"beginbfchar" => mode = "bfchar",
"beginbfrange" => mode = "bfrange",
"begincidchar" => mode = "cidchar",
"begincidrange" => mode = "cidrange",
"endcodespacerange" | "endbfchar" | "endbfrange" | "endcidchar" | "endcidrange" => {
let a = std::mem::take(&mut args);
match mode {
"cs" => {
for p in a.chunks_exact(2) {
if let (Obj::Str(lo), Obj::Str(hi)) = (&p[0], &p[1]) {
m.codespace.push((lo.len().max(1), be(lo), be(hi)));
}
}
}
"bfchar" => {
for p in a.chunks_exact(2) {
if let (Obj::Str(src), Obj::Str(dst)) = (&p[0], &p[1]) {
m.unicode.insert(be(src), utf16(dst));
}
}
}
"bfrange" => {
for p in a.chunks_exact(3) {
let (Obj::Str(lo), Obj::Str(hi)) = (&p[0], &p[1]) else { continue };
let (lo, hi) = (be(lo), be(hi));
if hi < lo || hi - lo > 65535 {
continue;
}
match &p[2] {
Obj::Str(dst) => {
let mut d = dst.clone();
for c in lo..=hi {
m.unicode.insert(c, utf16(&d));
if let Some(last) = d.last_mut() {
*last = last.wrapping_add(1);
}
}
}
Obj::Array(list) => {
for (k, s) in list.iter().enumerate() {
if let Obj::Str(s) = s {
m.unicode.insert(lo + k as u32, utf16(s));
}
}
}
_ => {}
}
}
}
"cidchar" => {
for p in a.chunks_exact(2) {
if let (Obj::Str(src), Some(cid)) = (&p[0], p[1].num()) {
let c = be(src);
m.cids.push((c, c, cid as u32));
}
}
}
"cidrange" => {
for p in a.chunks_exact(3) {
if let (Obj::Str(lo), Obj::Str(hi), Some(cid)) = (&p[0], &p[1], p[2].num()) {
m.cids.push((be(lo), be(hi), cid as u32));
}
}
}
_ => {}
}
mode = "";
}
_ => {
if mode.is_empty() {
args.clear();
}
}
}
}
m
}
impl Font {
/// Read a font dictionary. `skipped` collects what could not be read.
pub fn load(file: &File, d: &Dict, skipped: &mut Vec<String>) -> Font {
let subtype = file.get(d, "Subtype").and_then(Obj::name).unwrap_or("Type1").to_string();
let base_name = file.get(d, "BaseFont").and_then(Obj::name).unwrap_or("").to_string();
let mut f = Font {
composite: subtype == "Type0",
program: Program::None,
matrix: Affine::scale(0.001),
first_char: 0,
widths: vec![],
cid_widths: HashMap::new(),
default_width: 0.0,
names: vec![None; 256],
symbolic: false,
to_unicode: HashMap::new(),
cid_to_gid: None,
codespace: vec![],
cid_ranges: vec![],
vertical: false,
cache: RefCell::new(HashMap::new()),
has_widths: false,
base_name: base_name.clone(),
};
if let Some(Obj::Stream(sd, raw)) = file.get(d, "ToUnicode")
&& let Some(data) = decode_stream(file, sd, raw)
{
f.to_unicode = parse_cmap(&data).unicode;
}
let (desc_dict, fd) = if f.composite {
let desc = file
.get(d, "DescendantFonts")
.and_then(Obj::array)
.and_then(|a| a.first())
.map(|x| file.resolve(x))
.and_then(Obj::dict)
.cloned()
.unwrap_or_default();
let fd = file.get_dict(&desc, "FontDescriptor").cloned();
(desc, fd)
} else {
(d.clone(), file.get_dict(d, "FontDescriptor").cloned())
};
if let Some(fd) = &fd {
f.symbolic = file.get_num(fd, "Flags").is_some_and(|fl| (fl as u32) & 4 != 0);
}
// Widths.
if f.composite {
f.default_width = file.get_num(&desc_dict, "DW").unwrap_or(1000.0) / 1000.0;
if let Some(w) = file.get(&desc_dict, "W").and_then(Obj::array) {
let mut i = 0;
while i < w.len() {
let first = file.resolve(&w[i]).num().unwrap_or(0.0) as u32;
match w.get(i + 1).map(|x| file.resolve(x)) {
Some(Obj::Array(list)) => {
for (k, v) in list.iter().enumerate() {
f.cid_widths.insert(first + k as u32, file.resolve(v).num().unwrap_or(0.0) / 1000.0);
}
i += 2;
}
Some(Obj::Num(last)) => {
let wv = w.get(i + 2).and_then(|x| file.resolve(x).num()).unwrap_or(0.0) / 1000.0;
for c in first..=(*last as u32).min(first + 65535) {
f.cid_widths.insert(c, wv);
}
i += 3;
}
_ => break,
}
}
}
f.has_widths = true;
// Encoding CMap.
match file.get(d, "Encoding") {
Some(Obj::Name(n)) => {
f.vertical = n.ends_with("-V");
if !n.starts_with("Identity") {
skipped.push(format!("CMap {n}"));
}
}
Some(Obj::Stream(sd, raw)) => {
if let Some(data) = decode_stream(file, sd, raw) {
let cm = parse_cmap(&data);
f.codespace = cm.codespace;
f.cid_ranges = cm.cids;
}
}
_ => {}
}
match file.get(&desc_dict, "CIDToGIDMap") {
Some(Obj::Stream(sd, raw)) => {
f.cid_to_gid = decode_stream(file, sd, raw).map(|b| b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect());
}
_ => f.cid_to_gid = None,
}
} else {
f.first_char = file.get_num(d, "FirstChar").unwrap_or(0.0) as u32;
if let Some(w) = file.get(d, "Widths") {
f.widths = file.nums(w);
f.has_widths = !f.widths.is_empty();
}
if let Some(fd) = &fd {
f.default_width = file.get_num(fd, "MissingWidth").unwrap_or(0.0);
}
}
// The program.
let font_file = |key: &str| -> Option<(Dict, Vec<u8>)> {
let fd = fd.as_ref()?;
match file.get(fd, key) {
Some(Obj::Stream(sd, raw)) => Some((sd.clone(), decode_stream(file, sd, raw)?)),
_ => None,
}
};
if subtype == "Type3" {
f.matrix = file
.get(d, "FontMatrix")
.map(|m| file.nums(m))
.filter(|m| m.len() == 6)
.map(|m| Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]))
.unwrap_or(Affine::scale(0.001));
let mut procs = HashMap::new();
if let Some(cp) = file.get_dict(d, "CharProcs") {
for (k, v) in cp {
if let Obj::Stream(sd, raw) = file.resolve(v)
&& let Some(data) = decode_stream(file, sd, raw)
{
procs.insert(k.clone(), data);
}
}
}
f.program = Program::Type3 { procs, resources: file.get_dict(d, "Resources").cloned() };
// Type 3 widths are in glyph space.
let m = f.matrix.as_coeffs()[0];
for w in &mut f.widths {
*w *= m * 1000.0;
}
} else if let Some((_, data)) = font_file("FontFile2") {
f.program = Program::TrueType(Arc::new(data));
} else if let Some((sd, data)) = font_file("FontFile3") {
match file.get(&sd, "Subtype").and_then(Obj::name) {
Some("OpenType") => {
// An OpenType wrapper: TrueType outlines through skrifa, CFF through ours.
let cff = FontRef::new(&data).ok().and_then(|fr| fr.table_data(skrifa::Tag::new(b"CFF ")).map(|t| t.as_bytes().to_vec()));
match cff.and_then(|c| Cff::parse(&c)) {
Some(c) => f.program = Program::Cff(Box::new(c)),
None => f.program = Program::TrueType(Arc::new(data)),
}
}
_ => match Cff::parse(&data) {
Some(c) => f.program = Program::Cff(Box::new(c)),
None => skipped.push("font program (CFF)".into()),
},
}
} else if let Some((_, data)) = font_file("FontFile") {
match Type1::parse(&data) {
Some(t) => f.program = Program::Type1(Box::new(t)),
None => skipped.push("font program (Type 1)".into()),
}
} else {
f.program = Program::Fallback(fallback(&base_name));
}
if let Program::Cff(c) = &f.program {
let m = c.font_matrix;
f.matrix = Affine::new(m);
}
if let Program::Type1(t) = &f.program
&& let Some(m) = t.font_matrix
{
f.matrix = Affine::new(m);
}
// Simple-font encoding: base encoding, built-in encoding, Differences.
if !f.composite {
let enc = file.get(d, "Encoding").map(|e| file.resolve(e));
let base = match enc {
Some(Obj::Name(n)) => Base::from_name(n),
Some(Obj::Dict(e)) => file.get(e, "BaseEncoding").and_then(Obj::name).and_then(Base::from_name),
_ => None,
};
let builtin: Option<Vec<Option<String>>> = match &f.program {
Program::Type1(t) if !t.encoding.is_empty() => Some((0..256).map(|c| t.encoding.get(&(c as u8)).cloned()).collect()),
_ => None,
};
let fallback_base = if f.symbolic { None } else { Some(Base::Standard) };
for c in 0..256usize {
f.names[c] = match (base, &builtin) {
(Some(b), _) => b.name(c as u8),
(None, Some(bi)) => bi[c].clone(),
(None, None) => match &f.program {
// TrueType symbolic fonts map codes directly; others use Standard.
Program::TrueType(_) if f.symbolic => None,
Program::Fallback(_) if base_name.contains("Symbol") || base_name.contains("Dingbats") => None,
_ => fallback_base.or(Some(Base::Standard)).and_then(|b| b.name(c as u8)),
},
};
}
if let Some(Obj::Dict(e)) = enc {
differences(file, e, &mut f.names);
}
}
f
}
/// Split a string into (code, byte length) pairs.
pub fn codes(&self, s: &[u8]) -> Vec<(u32, usize)> {
if !self.composite {
return s.iter().map(|&b| (b as u32, 1)).collect();
}
let mut out = vec![];
let mut i = 0;
while i < s.len() {
let mut len = 2;
if !self.codespace.is_empty() {
len = (1..=4)
.find(|&n| {
i + n <= s.len() && {
let v = be(&s[i..i + n]);
self.codespace.iter().any(|(l, lo, hi)| *l == n && v >= *lo && v <= *hi)
}
})
.unwrap_or(1);
}
let end = (i + len).min(s.len());
out.push((be(&s[i..end]), end - i));
i = end;
}
out
}
fn cid(&self, code: u32) -> u32 {
if self.cid_ranges.is_empty() {
return code;
}
self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map(|(lo, _, c)| c + (code - lo)).unwrap_or(0)
}
/// The advance of a code in text space (before the font size).
pub fn width(&self, code: u32) -> f64 {
if self.composite {
return self.cid_widths.get(&self.cid(code)).copied().unwrap_or(self.default_width);
}
if self.has_widths {
return match code.checked_sub(self.first_char).and_then(|i| self.widths.get(i as usize)) {
Some(w) => w / 1000.0,
None => self.default_width / 1000.0,
};
}
// No /Widths (standard 14 fonts): the program's own advance.
match &self.program {
Program::Fallback(data) => {
self.sfnt_gid(data, code).and_then(|g| sfnt_advance(data, g)).map(|a| a / sfnt_upem(data)).unwrap_or(if code == 32 { 0.25 } else { 0.5 })
}
Program::TrueType(data) => self.sfnt_gid(data, code).and_then(|g| sfnt_advance(data, g)).map(|a| a / sfnt_upem(data)).unwrap_or(0.5),
Program::Type1(t) => self.name(code).and_then(|n| t.outline(n)).map(|(_, w)| self.matrix.as_coeffs()[0] * w).unwrap_or(0.5),
Program::Cff(c) => {
self.name(code).and_then(|n| c.gid_by_name(n)).and_then(|g| c.outline(g)).map(|(_, w)| self.matrix.as_coeffs()[0] * w).unwrap_or(0.5)
}
_ => 0.5,
}
}
fn name(&self, code: u32) -> Option<&str> {
self.names.get(code as usize).and_then(|n| n.as_deref())
}
fn sfnt_gid(&self, data: &[u8], code: u32) -> Option<u16> {
if self.composite {
let cid = self.cid(code);
return Some(match &self.cid_to_gid {
Some(map) => *map.get(cid as usize)?,
None => cid as u16,
});
}
// Stand-ins are looked up by Unicode (ToUnicode first).
if let Program::Fallback(_) = self.program
&& let Some(u) = self.to_unicode.get(&code).and_then(|s| s.chars().next())
{
let font = FontRef::new(data).ok()?;
return font.charmap().map(u).map(|g| g.to_u32() as u16);
}
sfnt_glyph(data, code as u8, self.name(code), self.symbolic && !matches!(self.program, Program::Fallback(_)))
}
/// The Type 3 glyph procedure of a code.
pub fn type3_proc(&self, code: u32) -> Option<(&[u8], Option<&Dict>)> {
let Program::Type3 { procs, resources } = &self.program else { return None };
let n = self.name(code)?;
procs.get(n).map(|p| (p.as_slice(), resources.as_ref()))
}
/// A code's outline in text space (font size 1), cached.
pub fn glyph(&self, code: u32) -> Option<Arc<BezPath>> {
if let Some(v) = self.cache.borrow().get(&code) {
return v.clone();
}
let path = self.glyph_uncached(code).map(Arc::new);
self.cache.borrow_mut().insert(code, path.clone());
path
}
fn glyph_uncached(&self, code: u32) -> Option<BezPath> {
match &self.program {
Program::TrueType(data) => self.sfnt_path(data, code),
Program::Fallback(data) => self.sfnt_path(data, code),
Program::Cff(c) => {
let g = if self.composite {
c.gid_by_cid(self.cid(code) as u16)?
} else {
match self.name(code).and_then(|n| c.gid_by_name(n)) {
Some(g) => g,
None => Some(c.encoding[(code & 255) as usize]).filter(|g| *g != 0)?,
}
};
c.outline(g).map(|(p, _)| self.matrix * p)
}
Program::Type1(t) => {
let name = self.name(code).filter(|n| t.has_glyph(n)).or_else(|| t.encoding.get(&(code as u8)).map(String::as_str))?;
t.outline(name).map(|(p, _)| self.matrix * p)
}
Program::Type3 { .. } | Program::None => None,
}
}
fn sfnt_path(&self, data: &[u8], code: u32) -> Option<BezPath> {
let g = self.sfnt_gid(data, code)?;
let p = sfnt_outline(data, g)?;
Some(Affine::scale(1.0 / sfnt_upem(data)) * p)
}
/// The text of a code (ToUnicode, else the glyph name), for naming text shapes.
pub fn unicode(&self, code: u32) -> String {
if let Some(s) = self.to_unicode.get(&code) {
return s.clone();
}
if self.composite {
return String::new();
}
self.name(code).and_then(name_to_unicode).map(String::from).unwrap_or_default()
}
}

310
crates/pdf/src/image.rs Normal file
View File

@ -0,0 +1,310 @@
//! Image XObjects and inline images (ISO 32000-1 §8.9): sample decoding (1–16 bits per
//! component, `/Decode` arrays), colour spaces (through [`crate::color`]: Indexed lookups and
//! ICCBased profiles by their `/Alternate` space), DCT (JPEG, through the pure-Rust `zune-jpeg`)
//! and the general filters, stencil masks (`/ImageMask`), soft masks (`/SMask`) and colour-key
//! and explicit masks (`/Mask`). The result is straight RGBA8.
use crate::color::{Cs, color_space};
use crate::object::{Dict, File, Obj, decode_stream};
pub(crate) struct Decoded {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The longest side of a decoded image (larger images are refused rather than exhausting
/// memory).
const MAX_SIDE: usize = 16384;
fn get<'a>(file: &'a File, d: &'a Dict, long: &str, short: &str) -> Option<&'a Obj> {
file.get(d, long).or_else(|| file.get(d, short))
}
fn filters(file: &File, d: &Dict) -> Vec<String> {
match get(file, d, "Filter", "F").map(|f| file.resolve(f)) {
Some(Obj::Name(n)) => vec![n.clone()],
Some(Obj::Array(a)) => a.iter().filter_map(|f| file.resolve(f).name().map(str::to_string)).collect(),
_ => vec![],
}
}
/// Inline-image abbreviations (§8.9.7) expanded to the full keys and names.
pub(crate) fn expand_inline(d: &Dict) -> Dict {
let mut out = Dict::new();
for (k, v) in d {
let key = match k.as_str() {
"BPC" => "BitsPerComponent",
"CS" => "ColorSpace",
"D" => "Decode",
"DP" => "DecodeParms",
"F" => "Filter",
"H" => "Height",
"IM" => "ImageMask",
"I" => "Interpolate",
"W" => "Width",
other => other,
};
let name = |n: &str| -> String {
match n {
"G" => "DeviceGray",
"RGB" => "DeviceRGB",
"CMYK" => "DeviceCMYK",
"I" => "Indexed",
"AHx" => "ASCIIHexDecode",
"A85" => "ASCII85Decode",
"LZW" => "LZWDecode",
"Fl" => "FlateDecode",
"RL" => "RunLengthDecode",
"CCF" => "CCITTFaxDecode",
"DCT" => "DCTDecode",
other => other,
}
.to_string()
};
let v = match v {
Obj::Name(n) => Obj::Name(name(n)),
Obj::Array(a) => Obj::Array(a.iter().map(|x| if let Obj::Name(n) = x { Obj::Name(name(n)) } else { x.clone() }).collect()),
other => other.clone(),
};
out.insert(key.to_string(), v);
}
out
}
/// The number of bytes an unfiltered inline image occupies.
pub(crate) fn inline_len(file: &File, d: &Dict, res: Option<&Dict>) -> Option<usize> {
if !filters(file, d).is_empty() {
return None;
}
let w = file.get_num(d, "Width")? as usize;
let h = file.get_num(d, "Height")? as usize;
let mask = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true)));
let bpc = if mask { 1 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize };
let n = if mask { 1 } else { file.get(d, "ColorSpace").map(|c| color_space(file, c, res).components()).unwrap_or(1) };
Some((w * n * bpc).div_ceil(8) * h)
}
/// Decode an image's samples (all filters, including DCT). Returns (bytes, components from
/// a JPEG when it decided them itself).
fn samples(file: &File, d: &Dict, raw: &[u8]) -> Result<(Vec<u8>, Option<usize>), String> {
let fs = filters(file, d);
match fs.last().map(String::as_str) {
Some("DCTDecode" | "DCT") => {
let mut d2 = d.clone();
d2.remove("F");
let rest: Vec<Obj> = fs[..fs.len() - 1].iter().map(|n| Obj::Name(n.clone())).collect();
d2.insert("Filter".into(), Obj::Array(rest));
d2.remove("DecodeParms");
d2.remove("DP");
let jpeg = decode_stream(file, &d2, raw).ok_or("image (filters)")?;
jpeg_decode(&jpeg).map(|(b, n)| (b, Some(n)))
}
Some(f @ ("JPXDecode" | "CCITTFaxDecode" | "JBIG2Decode" | "CCF")) => Err(format!("image ({f})")),
_ => decode_stream(file, d, raw).map(|b| (b, None)).ok_or_else(|| "image (filters)".to_string()),
}
}
fn jpeg_decode(data: &[u8]) -> Result<(Vec<u8>, usize), String> {
use zune_jpeg::JpegDecoder;
use zune_jpeg::zune_core::colorspace::ColorSpace;
use zune_jpeg::zune_core::options::DecoderOptions;
let mut probe = JpegDecoder::new(std::io::Cursor::new(data));
probe.decode_headers().map_err(|_| "image (JPEG)".to_string())?;
let n = probe.input_colorspace().map(|c| c.num_components()).unwrap_or(3);
let out = match n {
1 => ColorSpace::Luma,
4 => ColorSpace::CMYK,
_ => ColorSpace::RGB,
};
let opts = DecoderOptions::default().jpeg_set_out_colorspace(out).set_max_width(MAX_SIDE).set_max_height(MAX_SIDE);
let mut dec = JpegDecoder::new_with_options(std::io::Cursor::new(data), opts);
let mut px = dec.decode().map_err(|_| "image (JPEG)".to_string())?;
// Adobe CMYK JPEGs (an APP14 "Adobe" marker) store inverted values.
if n == 4 && data.windows(5).take(4096).any(|w| w == b"Adobe") {
for v in &mut px {
*v = 255 - *v;
}
}
Ok((px, n.max(1)))
}
/// Unpack `w`×`h` samples of `n` components at `bpc` bits into 0..=max integers.
fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec<u16> {
let row_bytes = (w * n * bpc).div_ceil(8);
let mut out = Vec::with_capacity(w * h * n);
for y in 0..h {
let row = data.get(y * row_bytes..((y + 1) * row_bytes).min(data.len())).unwrap_or(&[]);
for i in 0..w * n {
let v = match bpc {
8 => row.get(i).copied().unwrap_or(0) as u16,
16 => u16::from_be_bytes([row.get(2 * i).copied().unwrap_or(0), row.get(2 * i + 1).copied().unwrap_or(0)]),
1 | 2 | 4 => {
let bit = i * bpc;
let byte = row.get(bit / 8).copied().unwrap_or(0);
((byte >> (8 - bpc - bit % 8)) & ((1u8 << bpc) - 1)) as u16
}
_ => 0,
};
out.push(v);
}
}
out
}
/// Nearest-neighbour resample of a single-channel 8-bit mask to `w`×`h`.
fn resample(m: &[u8], mw: usize, mh: usize, w: usize, h: usize) -> Vec<u8> {
if mw == w && mh == h {
return m.to_vec();
}
let mut out = Vec::with_capacity(w * h);
for y in 0..h {
let sy = (y * mh / h.max(1)).min(mh.saturating_sub(1));
for x in 0..w {
let sx = (x * mw / w.max(1)).min(mw.saturating_sub(1));
out.push(m.get(sy * mw + sx).copied().unwrap_or(255));
}
}
out
}
/// A stencil or soft mask stream as 8-bit coverage.
fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec<u8>, usize, usize)> {
let Obj::Stream(d, raw) = file.resolve(o) else { return None };
let w = file.get_num(d, "Width")? as usize;
let h = file.get_num(d, "Height")? as usize;
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
return None;
}
let (data, jpeg_n) = samples(file, d, raw).ok()?;
if jpeg_n.is_some() {
return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h));
}
let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 };
let max = ((1u32 << bpc) - 1).max(1) as f64;
let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2);
let v = unpack(&data, w, h, 1, bpc);
let out = v
.into_iter()
.map(|s| {
let t = s as f64 / max;
let t = match &dec {
Some(dd) => dd[0] + t * (dd[1] - dd[0]),
None => t,
};
// Explicit (stencil) masks: sample 1 masks out with the default Decode.
let a = if soft { t } else { 1.0 - t };
(a.clamp(0.0, 1.0) * 255.0).round() as u8
})
.collect();
Some((out, w, h))
}
/// Decode an image. `fill` is the current fill colour (stencil masks paint with it).
pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill: [f64; 3]) -> Result<Decoded, String> {
let w = file.get_num(d, "Width").unwrap_or(0.0) as usize;
let h = file.get_num(d, "Height").unwrap_or(0.0) as usize;
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w * h > 64 << 20 {
return Err("image (size)".into());
}
let stencil = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true)));
let (data, jpeg_n) = samples(file, d, raw)?;
let decode_arr = file.get(d, "Decode").map(|x| file.nums(x)).unwrap_or_default();
let mut rgba = vec![0u8; w * h * 4];
if stencil {
let v = unpack(&data, w, h, 1, 1);
let paint_on = if decode_arr.first().copied().unwrap_or(0.0) > 0.5 { 1 } else { 0 };
let c = fill.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8);
for (i, s) in v.into_iter().enumerate() {
rgba[i * 4..i * 4 + 3].copy_from_slice(&c);
rgba[i * 4 + 3] = if s == paint_on { 255 } else { 0 };
}
return Ok(Decoded { width: w as u32, height: h as u32, rgba });
}
let cs = match file.get(d, "ColorSpace") {
Some(c) => color_space(file, c, res),
None if jpeg_n == Some(4) => Cs::Cmyk,
None if jpeg_n == Some(1) => Cs::Gray,
None => Cs::Rgb,
};
let n = match jpeg_n {
Some(n) => n,
None => cs.components().max(1),
};
let bpc = if jpeg_n.is_some() { 8 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize };
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
return Err(format!("image ({bpc} bits per component)"));
}
let max = ((1u32 << bpc) - 1) as f64;
let raw_samples = unpack(&data, w, h, n, bpc);
// Decode ranges per component.
let indexed = matches!(cs, Cs::Indexed { .. });
let range = |k: usize| -> (f64, f64) {
match (decode_arr.get(2 * k), decode_arr.get(2 * k + 1)) {
(Some(a), Some(b)) => (*a, *b),
_ if indexed => (0.0, max),
_ => (0.0, 1.0),
}
};
let ranges: Vec<(f64, f64)> = (0..n).map(range).collect();
// Fast paths: a lookup per 8-bit value for Gray / RGB / CMYK / Indexed.
let mut comp = vec![0.0f64; n];
// Indexed images: one colour per palette entry.
let palette: Option<Vec<[f64; 3]>> = match &cs {
Cs::Indexed { hival, .. } if n == 1 => Some((0..=*hival).map(|i| cs.to_rgb(&[i as f64])).collect()),
_ => None,
};
for i in 0..w * h {
if let Some(pal) = &palette {
let s = raw_samples[i] as f64 / max;
let (a, b) = ranges[0];
let idx = (a + s * (b - a)).round().clamp(0.0, (pal.len() - 1) as f64) as usize;
let c = pal[idx];
rgba[i * 4..i * 4 + 4].copy_from_slice(&[(c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8, 255]);
continue;
}
for k in 0..n {
let s = raw_samples[i * n + k] as f64 / max;
let (a, b) = ranges[k];
comp[k] = a + s * (b - a);
}
let c = cs.to_rgb(&comp);
let o = i * 4;
rgba[o] = (c[0] * 255.0).round() as u8;
rgba[o + 1] = (c[1] * 255.0).round() as u8;
rgba[o + 2] = (c[2] * 255.0).round() as u8;
rgba[o + 3] = 255;
}
// Colour-key masking: samples inside every [min max] pair are transparent.
match file.get(d, "Mask").map(|m| file.resolve(m)) {
Some(Obj::Array(a)) => {
let keys: Vec<f64> = a.iter().filter_map(|x| file.resolve(x).num()).collect();
if keys.len() >= 2 * n {
for i in 0..w * h {
if (0..n).all(|k| {
let v = raw_samples[i * n + k] as f64;
v >= keys[2 * k] && v <= keys[2 * k + 1]
}) {
rgba[i * 4 + 3] = 0;
}
}
}
}
Some(m @ Obj::Stream(..)) => {
if let Some((m, mw, mh)) = mask_image(file, m, false) {
for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() {
rgba[i * 4 + 3] = a;
}
}
}
_ => {}
}
if let Some(sm) = file.get(d, "SMask")
&& let Some((m, mw, mh)) = mask_image(file, sm, true)
{
for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() {
rgba[i * 4 + 3] = ((rgba[i * 4 + 3] as u32 * a as u32) / 255) as u8;
}
}
Ok(Decoded { width: w as u32, height: h as u32, rgba })
}

View File

@ -8,10 +8,15 @@
//! (Illustrator layers), with content outside them gathered into groups of its own.
mod build;
mod cff;
mod color;
mod encoding;
mod font;
mod image;
mod object;
mod page;
mod ps;
mod type1;
pub mod write;
pub use effectcraft_svg::Doc;
@ -124,7 +129,10 @@ fn finish(nodes: Vec<Node>, flags: Vec<bool>, skipped: Vec<String>, width: f64,
g.name = format!("Layer {}", i + 1);
}
}
Doc { width, height, root: Group { name: "page".into(), transform: to_doc, opacity: 1.0, children: layers, clip: vec![] }, skipped }
let mut root = Group::new("page");
root.transform = to_doc;
root.children = layers;
Doc { width, height, root, skipped }
}
fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
@ -170,6 +178,7 @@ pub fn layer_names(doc: &Doc) -> Vec<String> {
.map(|n| match n {
Node::Group(g) => g.name.clone(),
Node::Shape(s) => s.name.clone(),
Node::Image(i) => i.name.clone(),
})
.collect()
}

View File

@ -1,13 +1,20 @@
//! Pages and content streams (ISO 32000-1 §7.7.3, §8): the page tree, the graphics state,
//! path construction and painting, clipping, colour, shading patterns and `sh`, form XObjects
//! and optional-content marked sequences. Text, images and tiling patterns are skipped (listed
//! in [`effectcraft_svg::Doc::skipped`]).
//! and optional-content marked sequences, text (§9: text state, text objects, glyph outlines
//! from [`crate::font`]), images ([`crate::image`]), tiling patterns, blend modes and soft
//! masks. What is not drawn is listed in [`effectcraft_svg::Doc::skipped`].
use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Gradient, GradientKind, Join, Paint, Spread, Stroke};
use kurbo::Point;
use std::collections::HashMap;
use std::rc::Rc;
use effectcraft_svg::{
Affine, BezPath, BlendMode, Cap, FillRule, Gradient, GradientKind, Group, Image as SvgImage, Join, Node, Paint, SoftMask, Spread, Stroke,
};
use kurbo::{Point, Shape as _};
use crate::build::Builder;
use crate::color::{Cs, Func, color_space, text_string};
use crate::font::Font;
use crate::object::{Dict, File, Lexer, Obj, decode_stream};
/// A page: its dictionary with inherited attributes resolved.
@ -70,10 +77,24 @@ pub fn page_content(file: &File, page: &Page) -> Vec<u8> {
out
}
/// A tiling pattern (§8.7.3.1): its cell content, drawn in pattern space at every step.
pub(crate) struct Tiling {
/// Pattern space → page (default user space of the stream that set it).
space: Affine,
bbox: [f64; 4],
xstep: f64,
ystep: f64,
content: Vec<u8>,
res: Dict,
/// PaintType 2: the cell is painted with this colour.
uncolored: Option<[f64; 3]>,
}
#[derive(Clone)]
enum Fill {
Color([f64; 3]),
Paint(Paint),
Tiling(Rc<Tiling>),
None,
}
@ -91,6 +112,20 @@ struct GState {
dash: Option<(Vec<f64>, f64)>,
fill_alpha: f64,
stroke_alpha: f64,
// Text state (§9.3).
font: Option<Rc<Font>>,
font_size: f64,
char_spacing: f64,
word_spacing: f64,
h_scale: f64,
leading: f64,
rise: f64,
render_mode: u8,
/// Builder depths of the open blend-mode and soft-mask groups.
blend_depth: Option<usize>,
mask_depth: Option<usize>,
/// Colour operators are ignored (uncoloured tiling patterns, `d1` glyphs).
lock_color: bool,
}
impl Default for GState {
@ -108,6 +143,17 @@ impl Default for GState {
dash: None,
fill_alpha: 1.0,
stroke_alpha: 1.0,
font: None,
font_size: 0.0,
char_spacing: 0.0,
word_spacing: 0.0,
h_scale: 1.0,
leading: 0.0,
rise: 0.0,
render_mode: 0,
blend_depth: None,
mask_depth: None,
lock_color: false,
}
}
}
@ -125,6 +171,12 @@ pub(crate) struct Interp<'a> {
/// The page box in default user space (for `sh`).
page_box: [f64; 4],
budget: usize,
// Text object state.
tm: Affine,
tlm: Affine,
/// Glyph outlines collected for a clipping text render mode (page space).
text_clip: Option<BezPath>,
fonts: HashMap<String, Rc<Font>>,
}
fn affine(v: &[f64]) -> Affine {
@ -134,6 +186,15 @@ fn affine(v: &[f64]) -> Affine {
Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]])
}
/// The most tiles one tiling-pattern fill draws.
const MAX_TILES: usize = 4096;
/// A `TJ` element.
enum TextItem {
Str(Vec<u8>),
Adjust(f64),
}
impl<'a> Interp<'a> {
pub fn new(file: &'a File, page_box: [f64; 4]) -> Self {
Interp {
@ -148,6 +209,21 @@ impl<'a> Interp<'a> {
marked: vec![],
page_box,
budget: 20_000_000,
tm: Affine::IDENTITY,
tlm: Affine::IDENTITY,
text_clip: None,
fonts: HashMap::new(),
}
}
/// Close builder groups down to `depth` and forget graphics-state groups above it.
fn close_to(&mut self, depth: usize) {
self.b.close_to(depth);
if self.gs.blend_depth.is_some_and(|d| d >= depth) {
self.gs.blend_depth = None;
}
if self.gs.mask_depth.is_some_and(|d| d >= depth) {
self.gs.mask_depth = None;
}
}
@ -156,7 +232,6 @@ impl<'a> Interp<'a> {
pub fn run(&mut self, content: &[u8], res: &Dict, pattern_base: Affine, depth: usize) {
let mut lx = Lexer::new(content, 0);
let mut args: Vec<Obj> = Vec::with_capacity(8);
let mut in_text = false;
while let Some(o) = lx.next() {
let Obj::Op(op) = o else {
if args.len() < 64 {
@ -169,21 +244,15 @@ impl<'a> Interp<'a> {
return;
}
self.budget -= 1;
if in_text {
if op == "ET" {
in_text = false;
}
args.clear();
continue;
}
let n = |i: usize| args.get(i).and_then(Obj::num).unwrap_or(0.0);
let nums = || args.iter().filter_map(Obj::num).collect::<Vec<f64>>();
let locked = self.gs.lock_color;
match op.as_str() {
"q" => self.saved.push((self.gs.clone(), self.b.depth())),
"Q" => {
if let Some((g, d)) = self.saved.pop() {
self.gs = g;
self.b.close_to(d);
self.close_to(d);
}
}
"cm" => self.gs.ctm *= affine(&nums()),
@ -199,7 +268,8 @@ impl<'a> Interp<'a> {
if let Some(name) = args.first().and_then(Obj::name)
&& let Some(g) = self.file.get_dict(res, "ExtGState").and_then(|e| self.file.get_dict(e, name))
{
self.ext_gstate(g);
let g = g.clone();
self.ext_gstate(&g, res, depth);
}
}
// Path construction.
@ -251,27 +321,28 @@ impl<'a> Interp<'a> {
self.start = self.cur;
}
// Painting.
"S" => self.paint(false, None, true),
"S" => self.paint(false, None, true, depth),
"s" => {
self.path.close_path();
self.paint(false, None, true)
self.paint(false, None, true, depth)
}
"f" | "F" => self.paint(true, Some(FillRule::NonZero), false),
"f*" => self.paint(true, Some(FillRule::EvenOdd), false),
"B" => self.paint(true, Some(FillRule::NonZero), true),
"B*" => self.paint(true, Some(FillRule::EvenOdd), true),
"f" | "F" => self.paint(true, Some(FillRule::NonZero), false, depth),
"f*" => self.paint(true, Some(FillRule::EvenOdd), false, depth),
"B" => self.paint(true, Some(FillRule::NonZero), true, depth),
"B*" => self.paint(true, Some(FillRule::EvenOdd), true, depth),
"b" => {
self.path.close_path();
self.paint(true, Some(FillRule::NonZero), true)
self.paint(true, Some(FillRule::NonZero), true, depth)
}
"b*" => {
self.path.close_path();
self.paint(true, Some(FillRule::EvenOdd), true)
self.paint(true, Some(FillRule::EvenOdd), true, depth)
}
"n" => self.paint(false, None, false),
"n" => self.paint(false, None, false, depth),
"W" => self.pending_clip = Some(FillRule::NonZero),
"W*" => self.pending_clip = Some(FillRule::EvenOdd),
// Colour.
// Colour (ignored inside uncoloured patterns and `d1` glyphs).
"g" | "G" | "rg" | "RG" | "k" | "K" | "cs" | "CS" | "sc" | "scn" | "SC" | "SCN" if locked => {}
"g" => self.gs.fill_cs = Cs::Gray,
"G" => self.gs.stroke_cs = Cs::Gray,
"rg" => self.gs.fill_cs = Cs::Rgb,
@ -293,7 +364,7 @@ impl<'a> Interp<'a> {
let fill = op.starts_with('s');
let cs = if fill { self.gs.fill_cs.clone() } else { self.gs.stroke_cs.clone() };
let v = if let (Cs::Pattern, Some(name)) = (&cs, args.last().and_then(Obj::name)) {
self.pattern(res, name, pattern_base)
self.pattern(res, name, pattern_base, &nums())
} else {
Fill::Color(cs.to_rgb(&nums()))
};
@ -313,25 +384,65 @@ impl<'a> Interp<'a> {
self.xobject(res, name, depth);
}
}
// Text objects (§9.4).
"BT" => {
in_text = true;
self.b.skip("text");
self.tm = Affine::IDENTITY;
self.tlm = Affine::IDENTITY;
self.text_clip = None;
}
"BI" => {
// Inline image: skip to EI.
let d = lx.data;
let mut p = lx.pos;
while p + 2 < d.len() && !(d[p] == b'I' && d[p - 1] == b'D' && crate::object::is_white(d[p + 1])) {
p += 1;
"ET" => {
if let Some(clip) = self.text_clip.take() {
self.b.clip(clip, FillRule::NonZero);
}
while p + 2 < d.len()
&& !(crate::object::is_white(d[p - 1]) && d[p] == b'E' && d[p + 1] == b'I' && (p + 2 == d.len() || crate::object::is_white(d[p + 2])))
{
p += 1;
}
lx.pos = (p + 2).min(d.len());
self.b.skip("image");
}
"Tc" => self.gs.char_spacing = n(0),
"Tw" => self.gs.word_spacing = n(0),
"Tz" => self.gs.h_scale = n(0) / 100.0,
"TL" => self.gs.leading = n(0),
"Ts" => self.gs.rise = n(0),
"Tr" => self.gs.render_mode = (n(0) as i64).clamp(0, 7) as u8,
"Tf" => {
self.gs.font = args.first().and_then(Obj::name).map(str::to_string).and_then(|name| self.font(res, &name));
self.gs.font_size = n(1);
}
"Td" => self.text_move(n(0), n(1)),
"TD" => {
self.gs.leading = -n(1);
self.text_move(n(0), n(1));
}
"Tm" => {
self.tm = affine(&nums());
self.tlm = self.tm;
}
"T*" => self.text_move(0.0, -self.gs.leading),
"Tj" | "'" | "\"" => {
if op == "\"" {
self.gs.word_spacing = n(0);
self.gs.char_spacing = n(1);
}
if op != "Tj" {
self.text_move(0.0, -self.gs.leading);
}
if let Some(Obj::Str(s)) = args.iter().rev().find(|a| matches!(a, Obj::Str(_))) {
let s = s.clone();
self.show(&[TextItem::Str(s)], res, pattern_base, depth);
}
}
"TJ" => {
if let Some(Obj::Array(a)) = args.first() {
let items: Vec<TextItem> = a
.iter()
.filter_map(|x| match x {
Obj::Str(s) => Some(TextItem::Str(s.clone())),
Obj::Num(v) => Some(TextItem::Adjust(*v)),
_ => None,
})
.collect();
self.show(&items, res, pattern_base, depth);
}
}
"d1" => self.gs.lock_color = true,
"BI" => self.inline_image(&mut lx, res),
"BMC" => self.marked.push(None),
"BDC" => {
let layer = match (args.first().and_then(Obj::name), args.get(1)) {
@ -353,21 +464,69 @@ impl<'a> Interp<'a> {
}
"EMC" => {
if let Some(Some(d)) = self.marked.pop() {
self.b.close_to(d);
self.close_to(d);
}
}
_ => {}
}
// Colour operands set by the device-space shorthands.
match op.as_str() {
"g" | "rg" | "k" => self.gs.fill = Fill::Color(self.gs.fill_cs.to_rgb(&nums())),
"G" | "RG" | "K" => self.gs.stroke = Fill::Color(self.gs.stroke_cs.to_rgb(&nums())),
_ => {}
if !locked {
match op.as_str() {
"g" | "rg" | "k" => self.gs.fill = Fill::Color(self.gs.fill_cs.to_rgb(&nums())),
"G" | "RG" | "K" => self.gs.stroke = Fill::Color(self.gs.stroke_cs.to_rgb(&nums())),
_ => {}
}
}
args.clear();
}
}
/// An inline image: `BI key value … ID data EI` (the lexer is just past `BI`).
fn inline_image(&mut self, lx: &mut Lexer, res: &Dict) {
let mut d = Dict::new();
let mut key: Option<String> = None;
while let Some(o) = lx.next() {
match o {
Obj::Op(ref x) if x == "ID" => break,
Obj::Name(k) if key.is_none() => key = Some(k),
v => {
if let Some(k) = key.take() {
d.insert(k, v);
}
}
}
}
let d = crate::image::expand_inline(&d);
let data = lx.data;
let start = (lx.pos + 1).min(data.len());
let is_ei = |p: usize| {
p >= 1
&& p + 1 < data.len()
&& crate::object::is_white(data[p - 1])
&& data[p] == b'E'
&& data[p + 1] == b'I'
&& (p + 2 == data.len() || crate::object::is_white(data[p + 2]))
};
let end = match crate::image::inline_len(self.file, &d, Some(res)) {
Some(len) => (start + len).min(data.len()),
None => {
let mut p = start;
while p + 1 < data.len() && !is_ei(p) {
p += 1;
}
// Data ends before the whitespace preceding `EI`.
p.saturating_sub(1).max(start).min(data.len())
}
};
let raw = data[start..end].to_vec();
let mut p = end;
while p + 1 < data.len() && !(data[p] == b'E' && data[p + 1] == b'I') {
p += 1;
}
lx.pos = (p + 2).min(data.len());
self.image(&d, &raw, res);
}
fn ocg_name(&self, ocg: &Dict) -> Option<String> {
// An optional-content membership dictionary names its groups in /OCGs.
let d = match self.file.get(ocg, "OCGs") {
@ -389,7 +548,7 @@ impl<'a> Interp<'a> {
}
}
fn ext_gstate(&mut self, g: &Dict) {
fn ext_gstate(&mut self, g: &Dict, res: &Dict, depth: usize) {
let f = self.file;
if let Some(v) = f.get_num(g, "LW") {
self.gs.lw = v;
@ -414,27 +573,120 @@ impl<'a> Interp<'a> {
let ph = d.get(1).and_then(|p| f.resolve(p).num()).unwrap_or(0.0);
self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then_some((arr, ph));
}
if f.get(g, "SMask").is_some_and(|s| !matches!(s, Obj::Name(n) if n == "None")) {
self.b.skip("soft mask");
if let Some(Obj::Array(fa)) = f.get(g, "Font")
&& let (Some(Obj::Dict(fd)), Some(size)) = (fa.first().map(|x| f.resolve(x)), fa.get(1).and_then(|x| f.resolve(x).num()))
{
let mut sk = vec![];
self.gs.font = Some(Rc::new(Font::load(f, fd, &mut sk)));
self.gs.font_size = size;
for s in sk {
self.b.skip(&s);
}
}
// Blend mode: a group composited with it, open until the mode changes or `Q`.
let bm = match f.get(g, "BM") {
Some(Obj::Name(n)) => Some(n.clone()),
Some(Obj::Array(a)) => a.first().and_then(|x| f.resolve(x).name()).map(str::to_string),
_ => None,
};
if let Some(bm) = bm {
let mode = BlendMode::from_pdf_name(&bm);
if let Some(d) = self.gs.blend_depth {
self.close_to(d);
}
if mode != BlendMode::Normal {
self.gs.blend_depth = Some(self.b.depth());
let mut grp = Group::new(&bm);
grp.blend = mode;
self.b.open_group(grp);
}
}
// Soft mask: the mask group drawn now, applied to what is painted while it is set.
match f.get(g, "SMask") {
None => {}
Some(Obj::Name(n)) if n == "None" => {
if let Some(d) = self.gs.mask_depth {
self.close_to(d);
}
}
Some(sm) => {
let Some(sm) = sm.dict().cloned() else { return };
let luminosity = f.get(&sm, "S").and_then(Obj::name) != Some("Alpha");
let Some(Obj::Stream(gd, graw)) = f.get(&sm, "G").cloned() else { return };
// Backdrop: in the group's colour space (black by default).
let bc = f.get(&sm, "BC").map(|b| f.nums(b)).unwrap_or_default();
let gcs = f.get_dict(&gd, "Group").and_then(|gr| f.get(gr, "CS")).map(|c| color_space(f, c, Some(res))).unwrap_or(Cs::Rgb);
let backdrop = if bc.is_empty() { [0.0; 3] } else { gcs.to_rgb(&bc) };
let saved_b = std::mem::replace(&mut self.b, Builder::new());
let ctm = self.gs.ctm;
let saved_gs = std::mem::replace(&mut self.gs, GState { ctm, ..GState::default() });
let saved_stack = std::mem::take(&mut self.saved);
self.form(&gd, &graw, res, depth);
let mask_b = std::mem::replace(&mut self.b, saved_b);
self.gs = saved_gs;
self.saved = saved_stack;
let (nodes, _, sk) = mask_b.finish();
for s in sk {
self.b.skip(&s);
}
if let Some(d) = self.gs.mask_depth {
self.close_to(d);
}
let mut content = Group::new("Soft Mask");
content.children = nodes;
let mut grp = Group::new("Soft Mask Group");
grp.mask = Some(Box::new(SoftMask { content, luminosity, backdrop }));
self.gs.mask_depth = Some(self.b.depth());
self.b.open_group(grp);
}
}
}
fn paint_of(f: &Fill) -> Option<Paint> {
fn paint_of(&mut self, f: &Fill) -> Option<Paint> {
match f {
Fill::Color(c) => Some(Paint::Color(*c)),
Fill::Paint(p) => Some(p.clone()),
Fill::Tiling(t) => {
self.b.skip("tiling pattern stroke");
Some(Paint::Color(t.uncolored.unwrap_or([0.5; 3])))
}
Fill::None => None,
}
}
fn paint(&mut self, fill: bool, rule: Option<FillRule>, stroke: bool) {
fn paint(&mut self, fill: bool, rule: Option<FillRule>, stroke: bool, depth: usize) {
let path = std::mem::take(&mut self.path);
self.cur = None;
self.start = None;
self.paint_path(path.clone(), fill, rule.unwrap_or(FillRule::NonZero), stroke, "Path", depth);
if let Some(r) = self.pending_clip.take() {
self.b.clip(self.gs.ctm * path, r);
}
}
/// Fill and / or stroke `path` (user space) with the current graphics state.
fn paint_path(&mut self, path: BezPath, fill: bool, rule: FillRule, stroke: bool, name: &str, depth: usize) {
if path.elements().is_empty() {
return;
}
let ctm = self.gs.ctm;
let fill = if fill { Self::paint_of(&self.gs.fill).map(|p| (p, self.gs.fill_alpha, rule.unwrap_or(FillRule::NonZero))) } else { None };
let mut fill_paint = None;
if fill {
match self.gs.fill.clone() {
Fill::Tiling(t) => {
let page = ctm * path.clone();
let d = self.b.depth();
let area = page.bounding_box();
self.b.clip(page, rule);
self.tile(&t, area, depth);
self.close_to(d);
}
f => fill_paint = self.paint_of(&f).map(|p| (p, self.gs.fill_alpha, rule)),
}
}
let stroke = if stroke {
Self::paint_of(&self.gs.stroke).map(|p| {
let sf = self.gs.stroke.clone();
self.paint_of(&sf).map(|p| {
// Width 0: the thinnest line the device can draw (one pixel).
let s = { (ctm.determinant().abs()).sqrt() };
let width = if self.gs.lw <= 0.0 { if s > 0.0 { 1.0 / s } else { 1.0 } } else { self.gs.lw };
@ -451,12 +703,67 @@ impl<'a> Interp<'a> {
} else {
None
};
if fill.is_some() || stroke.is_some() {
self.b.fill_stroke(path.clone(), ctm, fill, stroke);
if fill_paint.is_some() || stroke.is_some() {
self.b.fill_stroke_named(name, path, ctm, fill_paint, stroke);
}
if let Some(r) = self.pending_clip.take() {
self.b.clip(ctm * path, r);
}
/// Draw a tiling pattern's cells over `area` (page space; the caller clips).
fn tile(&mut self, t: &Tiling, area: kurbo::Rect, depth: usize) {
if depth > 8 {
return;
}
// The cell, drawn once in pattern space.
let saved_b = std::mem::replace(&mut self.b, Builder::new());
let saved_gs = std::mem::take(&mut self.gs);
let saved_stack = std::mem::take(&mut self.saved);
let saved_path = std::mem::take(&mut self.path);
if let Some(c) = t.uncolored {
self.gs.fill = Fill::Color(c);
self.gs.stroke = Fill::Color(c);
self.gs.lock_color = true;
}
self.run(&t.content, &t.res, Affine::IDENTITY, depth + 1);
let cell_b = std::mem::replace(&mut self.b, saved_b);
self.gs = saved_gs;
self.saved = saved_stack;
self.path = saved_path;
let (cell, _, sk) = cell_b.finish();
for s in sk {
self.b.skip(&s);
}
if cell.is_empty() {
return;
}
let (xs, ys) = (t.xstep.abs().max(1e-6), t.ystep.abs().max(1e-6));
let inv = t.space.inverse();
let corners = [(area.x0, area.y0), (area.x1, area.y0), (area.x1, area.y1), (area.x0, area.y1)].map(|(x, y)| inv * Point::new(x, y));
let (mut lo, mut hi) = (Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN));
for c in corners {
lo = Point::new(lo.x.min(c.x), lo.y.min(c.y));
hi = Point::new(hi.x.max(c.x), hi.y.max(c.y));
}
let [bx0, by0, bx1, by1] = t.bbox;
let i0 = ((lo.x - bx1) / xs).floor();
let i1 = ((hi.x - bx0) / xs).ceil();
let j0 = ((lo.y - by1) / ys).floor();
let j1 = ((hi.y - by0) / ys).ceil();
if !(i0.is_finite() && i1.is_finite() && j0.is_finite() && j1.is_finite()) || (i1 - i0 + 1.0) * (j1 - j0 + 1.0) > MAX_TILES as f64 {
self.b.skip("tiling pattern (too many tiles)");
return;
}
let cell_clip = kurbo::Rect::new(bx0, by0, bx1, by1).to_path(0.1);
let mut tiles = Group::new("Pattern");
for j in j0 as i64..=j1 as i64 {
for i in i0 as i64..=i1 as i64 {
let mut g = Group::new("Tile");
g.transform = t.space * Affine::translate((i as f64 * xs, j as f64 * ys));
g.clip.push((cell_clip.clone(), FillRule::NonZero));
g.children = cell.clone();
tiles.children.push(Node::Group(g));
}
}
self.b.push(Node::Group(tiles));
}
/// A shading dictionary → gradient paint in shading space.
@ -490,19 +797,20 @@ impl<'a> Interp<'a> {
let stops = ts
.into_iter()
.map(|t| {
let c = cs.to_rgb(&func.eval1(dom[0] + t * (dom[1] - dom[0])));
let c = cs.to_rgb(&func.eval(&[dom[0] + t * (dom[1] - dom[0])]));
(t, [c[0], c[1], c[2], 1.0])
})
.collect();
Some(Gradient { kind, stops, bbox_units: false, transform: Affine::IDENTITY, spread: Spread::Pad })
}
fn pattern(&mut self, res: &Dict, name: &str, base: Affine) -> Fill {
fn pattern(&mut self, res: &Dict, name: &str, base: Affine, comps: &[f64]) -> Fill {
let f = self.file;
let Some(p) = f.get_dict(res, "Pattern").and_then(|pp| f.get_dict(pp, name)) else { return Fill::None };
let Some(po) = f.get_dict(res, "Pattern").and_then(|pp| pp.get(name)).map(|x| f.resolve(x)) else { return Fill::None };
let Some(p) = po.dict() else { return Fill::None };
let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY);
match f.get_num(p, "PatternType").unwrap_or(0.0) as i32 {
2 => {
let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY);
let Some(sh) = f.get(p, "Shading").and_then(Obj::dict) else { return Fill::None };
let sh = sh.clone();
match self.shading_gradient(&sh) {
@ -514,10 +822,27 @@ impl<'a> Interp<'a> {
None => Fill::None,
}
}
_ => {
self.b.skip("tiling pattern");
Fill::Color([0.5; 3])
1 => {
let Obj::Stream(d, raw) = po else { return Fill::None };
let Some(content) = decode_stream(f, d, raw) else { return Fill::None };
let bb = f.get(d, "BBox").map(|b| f.nums(b)).filter(|b| b.len() == 4).unwrap_or_else(|| vec![0.0, 0.0, 1.0, 1.0]);
let uncolored = (f.get_num(d, "PaintType").unwrap_or(1.0) as i32 == 2).then(|| match comps.len() {
1 => Cs::Gray.to_rgb(comps),
4 => Cs::Cmyk.to_rgb(comps),
3 => Cs::Rgb.to_rgb(comps),
_ => [0.0; 3],
});
Fill::Tiling(Rc::new(Tiling {
space: base * m,
bbox: [bb[0].min(bb[2]), bb[1].min(bb[3]), bb[0].max(bb[2]), bb[1].max(bb[3])],
xstep: f.get_num(d, "XStep").unwrap_or(bb[2] - bb[0]),
ystep: f.get_num(d, "YStep").unwrap_or(bb[3] - bb[1]),
content,
res: f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone()),
uncolored,
}))
}
_ => Fill::None,
}
}
@ -541,33 +866,177 @@ impl<'a> Interp<'a> {
self.b.fill_stroke(path, self.gs.ctm, Some((Paint::Gradient(g), self.gs.fill_alpha, FillRule::NonZero)), None);
}
/// Run a form XObject (matrix, bounding-box clip, its own resources) in a saved state.
fn form(&mut self, d: &Dict, raw: &[u8], res: &Dict, depth: usize) {
if depth >= 12 {
return;
}
let f = self.file;
let Some(content) = decode_stream(f, d, raw) else { return };
let m = f.get(d, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY);
let form_res = f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone());
self.saved.push((self.gs.clone(), self.b.depth()));
self.gs.ctm *= m;
let bb = f.get(d, "BBox").map(|b| f.nums(b)).unwrap_or_default();
if bb.len() == 4 {
let r = kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]);
self.b.clip(self.gs.ctm * r.to_path(0.1), FillRule::NonZero);
}
let base = self.gs.ctm;
let path = std::mem::take(&mut self.path);
let (tm, tlm) = (self.tm, self.tlm);
self.run(&content, &form_res, base, depth + 1);
self.path = path;
self.tm = tm;
self.tlm = tlm;
if let Some((g, dd)) = self.saved.pop() {
self.gs = g;
self.close_to(dd);
}
}
fn xobject(&mut self, res: &Dict, name: &str, depth: usize) {
let f = self.file;
let Some(x) = f.get_dict(res, "XObject").and_then(|xs| xs.get(name)).map(|x| f.resolve(x)) else { return };
let Obj::Stream(d, raw) = x else { return };
match f.get(d, "Subtype").and_then(Obj::name) {
Some("Form") if depth < 12 => {
let Some(content) = decode_stream(f, d, raw) else { return };
let m = f.get(d, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY);
let form_res = f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone());
self.saved.push((self.gs.clone(), self.b.depth()));
self.gs.ctm *= m;
let bb = f.get(d, "BBox").map(|b| f.nums(b)).unwrap_or_default();
if bb.len() == 4 {
let r = kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]);
self.b.clip(self.gs.ctm * kurbo::Shape::to_path(&r, 0.1), FillRule::NonZero);
}
let base = self.gs.ctm;
let path = std::mem::take(&mut self.path);
self.run(&content, &form_res, base, depth + 1);
self.path = path;
if let Some((g, dd)) = self.saved.pop() {
self.gs = g;
self.b.close_to(dd);
}
}
Some("Image") => self.b.skip("image"),
Some("Form") => self.form(d, raw, res, depth),
Some("Image") => self.image(d, raw, res),
_ => {}
}
}
/// Paint an image: the unit square of user space, pixel (0, 0) at its top-left corner.
fn image(&mut self, d: &Dict, raw: &[u8], res: &Dict) {
let fill = match &self.gs.fill {
Fill::Color(c) => *c,
_ => [0.0; 3],
};
match crate::image::decode(self.file, d, raw, Some(res), fill) {
Ok(img) => {
let (w, h) = (img.width as f64, img.height as f64);
let transform = self.gs.ctm * Affine::new([1.0 / w, 0.0, 0.0, -1.0 / h, 0.0, 1.0]);
self.b.push(Node::Image(SvgImage {
name: "Image".into(),
transform,
width: img.width,
height: img.height,
rgba: std::sync::Arc::new(img.rgba),
opacity: self.gs.fill_alpha,
}));
}
Err(what) => self.b.skip(&what),
}
}
/// The font named `name` in the resources (cached by object).
fn font(&mut self, res: &Dict, name: &str) -> Option<Rc<Font>> {
let f = self.file;
let entry = f.get_dict(res, "Font")?.get(name)?;
let key = match entry {
Obj::Ref(n, g) => Some(format!("{n} {g}")),
_ => None,
};
if let Some(k) = &key
&& let Some(font) = self.fonts.get(k)
{
return Some(font.clone());
}
let d = f.resolve(entry).dict()?;
let mut sk = vec![];
let font = Rc::new(Font::load(f, d, &mut sk));
for s in sk {
self.b.skip(&s);
}
if let Some(k) = key {
self.fonts.insert(k, font.clone());
}
Some(font)
}
fn text_move(&mut self, tx: f64, ty: f64) {
self.tlm *= Affine::translate((tx, ty));
self.tm = self.tlm;
}
/// Show text (`Tj`, `TJ`, `'`, `"`): glyph outlines placed by the text matrix, painted by
/// the text render mode as one compound shape.
fn show(&mut self, items: &[TextItem], res: &Dict, pattern_base: Affine, depth: usize) {
let Some(font) = self.gs.font.clone() else {
self.b.skip("text (no font)");
return;
};
let (fs, th, rise) = (self.gs.font_size, self.gs.h_scale, self.gs.rise);
let mut path = BezPath::new();
let mut text = String::new();
let mut missing = false;
for it in items {
match it {
TextItem::Adjust(v) => {
let d = -v / 1000.0 * fs;
self.tm *= if font.vertical { Affine::translate((0.0, d)) } else { Affine::translate((d * th, 0.0)) };
}
TextItem::Str(s) => {
for (code, nbytes) in font.codes(s) {
let w0 = font.width(code);
let trm = self.tm * Affine::new([fs * th, 0.0, 0.0, fs, 0.0, rise]);
let trm = if font.vertical { trm * Affine::translate((-w0 / 2.0, -0.88)) } else { trm };
if let Some((proc_, t3res)) = font.type3_proc(code) {
let proc_ = proc_.to_vec();
let r = t3res.cloned().unwrap_or_else(|| res.clone());
self.type3_glyph(&proc_, &r, trm * font.matrix, pattern_base, depth);
} else if let Some(g) = font.glyph(code) {
path.extend(trm * (*g).clone());
} else if code != 32 {
missing = true;
}
text.push_str(&font.unicode(code));
let tw = if nbytes == 1 && code == 32 { self.gs.word_spacing } else { 0.0 };
if font.vertical {
self.tm *= Affine::translate((0.0, -(fs + self.gs.char_spacing + tw)));
} else {
self.tm *= Affine::translate(((w0 * fs + self.gs.char_spacing + tw) * th, 0.0));
}
}
}
}
}
if missing && !matches!(font.program, crate::font::Program::Type3 { .. }) {
self.b.skip(&format!("glyphs missing from font {}", font.base_name));
}
if path.elements().is_empty() {
return;
}
let mode = self.gs.render_mode;
let fill = matches!(mode, 0 | 2 | 4 | 6);
let stroke = matches!(mode, 1 | 2 | 5 | 6);
let label: String = text.chars().filter(|c| !c.is_control()).take(40).collect();
let name = if label.trim().is_empty() { "Text".to_string() } else { format!("Text: {}", label.trim()) };
if mode >= 4 {
let clip = self.text_clip.get_or_insert_with(BezPath::new);
clip.extend(self.gs.ctm * path.clone());
}
if fill || stroke {
self.paint_path(path, fill, FillRule::NonZero, stroke, &name, depth);
}
}
/// Run a Type 3 glyph procedure with `m` (glyph space → user space).
fn type3_glyph(&mut self, content: &[u8], res: &Dict, m: Affine, pattern_base: Affine, depth: usize) {
if depth >= 8 {
return;
}
self.saved.push((self.gs.clone(), self.b.depth()));
self.gs.ctm *= m;
let path = std::mem::take(&mut self.path);
let (tm, tlm) = (self.tm, self.tlm);
self.run(content, res, pattern_base, depth + 1);
self.path = path;
self.tm = tm;
self.tlm = tlm;
if let Some((g, d)) = self.saved.pop() {
self.gs = g;
self.close_to(d);
}
}
}

View File

@ -49,7 +49,7 @@ fn pdf_paths_clip_gradient_and_layers() {
let doc = parse(&bytes).unwrap();
assert_eq!((doc.width, doc.height), (200.0, 100.0));
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}");
assert!(doc.skipped.contains(&"text".to_string()));
assert!(doc.skipped.contains(&"text (no font)".to_string()), "{:?}", doc.skipped);
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
let at = |x: i64, y: i64| img.get(x, y);
@ -148,3 +148,362 @@ fn filters_decode() {
let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6);
assert_eq!(object::inflate(&z).unwrap(), b"abc");
}
// ------------------------------------------------------------------ M13.6: text, images,
// patterns, soft masks, blend modes, pages
type Objs = Vec<(u32, String, Option<Vec<u8>>)>;
/// A one-page 200×100 PDF: `resources` is the page's resource dictionary body, `extra` more
/// objects (numbered from 10).
fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec<u8> {
let mut objs: Objs = vec![
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << {resources} >> /Contents 4 0 R >>"), None),
(4, "<< >>".into(), Some(content.as_bytes().to_vec())),
];
objs.extend(extra);
pdf(&objs, 1)
}
fn render(doc: &Doc) -> effectcraft_raster::Image {
let (w, h) = doc.pixel_size();
effectcraft_svg::rasterize(doc, w, h, 1.0)
}
fn coverage(img: &effectcraft_raster::Image, x0: i64, y0: i64, x1: i64, y1: i64) -> f32 {
let mut s = 0.0;
for y in y0..y1 {
for x in x0..x1 {
s += img.get(x, y)[3];
}
}
s
}
fn shape_names(doc: &Doc) -> Vec<String> {
fn walk(g: &effectcraft_svg::Group, out: &mut Vec<String>) {
for c in &g.children {
match c {
Node::Group(s) => walk(s, out),
Node::Shape(s) => out.push(s.name.clone()),
Node::Image(i) => out.push(i.name.clone()),
}
}
}
let mut out = vec![];
walk(&doc.root, &mut out);
out
}
/// Type 2 charstring operands.
fn t2(v: &[i32]) -> Vec<u8> {
let mut o = vec![];
for &x in v {
if (-107..=107).contains(&x) {
o.push((x + 139) as u8);
} else {
o.push(28);
o.extend_from_slice(&(x as i16).to_be_bytes());
}
}
o
}
/// A CFF font with a 500-unit square glyph `square` (x 50..550, y 0..500, width 600).
fn square_cff() -> Vec<u8> {
let mut sq = t2(&[600, 50, 0]);
sq.push(21);
sq.extend(t2(&[500, 500, -500]));
sq.push(6);
sq.push(14);
crate::cff::write_test_cff(&[(".notdef", vec![14]), ("square", sq)])
}
const SQUARE_FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Square /FirstChar 65 /LastChar 65 /Widths [600] /Encoding << /Differences [65 /square] >> /FontDescriptor 11 0 R >>";
fn square_font_objs() -> Objs {
vec![
(10, SQUARE_FONT.into(), None),
(11, "<< /Type /FontDescriptor /FontName /Square /Flags 32 /FontFile3 12 0 R >>".into(), None),
(12, "<< /Subtype /Type1C >>".into(), Some(square_cff())),
]
}
#[test]
fn text_with_an_embedded_cff_font_spacing_and_tj() {
// Squares 10 pt per 100 units: A at 0..60 (square 5..55), Tc 10 and a TJ kern of −400
// (+40) put the second at 110 (square 115..165).
let bytes = page_pdf("BT /F1 100 Tf 0 0 Td 10 Tc [(A) -400 (A)] TJ ET", "/Font << /F1 10 0 R >>", square_font_objs());
let doc = parse(&bytes).unwrap();
assert!(!doc.skipped.iter().any(|s| s.contains("text")), "{:?}", doc.skipped);
assert!(shape_names(&doc).iter().any(|n| n.starts_with("Text")), "{:?}", shape_names(&doc));
let img = render(&doc);
for (x, on) in [(30, true), (60, false), (105, false), (140, true), (170, false)] {
assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}: {:?}", img.get(x, 75));
}
// Squares are 50 pt tall: rows 50..100.
assert_eq!(img.get(30, 45)[3], 0.0);
}
#[test]
fn text_render_mode_clip() {
let bytes = page_pdf("BT /F1 100 Tf 7 Tr 0 0 Td (A) Tj ET 1 0 0 rg 0 0 200 100 re f", "/Font << /F1 10 0 R >>", square_font_objs());
let img = render(&parse(&bytes).unwrap());
assert!(img.get(30, 75)[0] > 0.99 && img.get(30, 75)[3] > 0.99);
assert_eq!(img.get(100, 75)[3], 0.0, "clipped to the glyph");
assert_eq!(img.get(30, 25)[3], 0.0);
}
#[test]
fn text_with_an_embedded_type1_font() {
// hsbw 50 600; 0 0 rmoveto; 500 hlineto 500 vlineto -500 hlineto closepath endchar
let cs = vec![50 + 139, 248, 236, 13, 139, 139, 21, 248, 136, 6, 248, 136, 7, 252, 136, 6, 9, 14];
let font = crate::type1::write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(66, "box")]);
let extra: Objs = vec![
(10, "<< /Type /Font /Subtype /Type1 /BaseFont /Box /FirstChar 66 /LastChar 66 /Widths [600] /FontDescriptor 11 0 R >>".into(), None),
(11, "<< /Type /FontDescriptor /FontName /Box /Flags 4 /FontFile 12 0 R >>".into(), None),
(12, "<< /Length1 0 /Length2 0 /Length3 0 >>".into(), Some(font)),
];
// The font's built-in encoding maps B (66) to `box`.
let img = render(&parse(&page_pdf("BT /F1 100 Tf 0 0 Td (BB) Tj ET", "/Font << /F1 10 0 R >>", extra)).unwrap());
for (x, on) in [(30, true), (60, false), (90, true), (120, false)] {
assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}");
}
}
#[test]
fn text_with_a_type3_font() {
let extra: Objs = vec![
(
10,
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /sq 11 0 R >> /Encoding << /Type /Encoding /Differences [65 /sq] >> /FirstChar 65 /LastChar 65 /Widths [100] >>".into(),
None,
),
(11, "<< >>".into(), Some(b"100 0 d0 0 0 100 100 re f".to_vec())),
];
let img = render(&parse(&page_pdf("BT 0 0 1 rg /F1 20 Tf 10 10 Td (AA) Tj ET", "/Font << /F1 10 0 R >>", extra)).unwrap());
// 20 pt squares from x = 10, advancing 20: user 10..30 and 30..50, y 10..30 (rows 70..90).
assert!(img.get(20, 80)[2] > 0.99 && img.get(20, 80)[3] > 0.99, "{:?}", img.get(20, 80));
assert!(img.get(40, 80)[3] > 0.99);
assert_eq!(img.get(55, 80)[3], 0.0);
assert_eq!(img.get(20, 60)[3], 0.0);
}
#[test]
fn text_with_standard_fonts_embedded_truetype_and_cid_fonts() {
use skrifa::MetadataProvider;
let inter = effectcraft_text::fonts::INTER_REGULAR.to_vec();
// Not embedded: Helvetica drawn with the bundled sans serif.
let std14 = page_pdf("BT /F1 60 Tf 10 30 Td (Hi) Tj ET", "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>", vec![]);
let doc = parse(&std14).unwrap();
assert!(shape_names(&doc).contains(&"Text: Hi".to_string()), "{:?}", shape_names(&doc));
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let a = render(&doc);
let ink = coverage(&a, 0, 0, 200, 100);
assert!(ink > 300.0, "{ink}");
// Ink only on the text line: baseline at row 70, cap height ≈ 44 px above it.
assert!(coverage(&a, 0, 72, 200, 100) < 1.0 && coverage(&a, 0, 0, 200, 20) < 1.0);
// The same face embedded (a TrueType font program, WinAnsiEncoding): the same outlines.
let tt = page_pdf(
"BT /F1 60 Tf 10 30 Td (Hi) Tj ET",
"/Font << /F1 10 0 R >>",
vec![
(10, "<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+Inter /Encoding /WinAnsiEncoding /FontDescriptor 11 0 R >>".into(), None),
(11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None),
(12, "<< >>".into(), Some(inter.clone())),
],
);
let b = render(&parse(&tt).unwrap());
assert!((coverage(&b, 0, 0, 200, 100) - ink).abs() < 1.0, "{} vs {ink}", coverage(&b, 0, 0, 200, 100));
// A composite font: Identity-H codes are glyph ids of a CIDFontType2.
let font = skrifa::FontRef::new(&inter).unwrap();
let gid = |c: char| font.charmap().map(c).unwrap().to_u32() as u16;
let (h, i) = (gid('H'), gid('i'));
let loc = skrifa::instance::LocationRef::default();
let upem = font.metrics(skrifa::instance::Size::unscaled(), loc).units_per_em as f32;
let w = |g: u16| font.glyph_metrics(skrifa::instance::Size::unscaled(), loc).advance_width(skrifa::GlyphId::new(g as u32)).unwrap() / upem * 1000.0;
let cid = page_pdf(
&format!("BT /F1 60 Tf 10 30 Td <{h:04X}{i:04X}> Tj ET"),
"/Font << /F1 10 0 R >>",
vec![
(10, "<< /Type /Font /Subtype /Type0 /BaseFont /Inter /Encoding /Identity-H /DescendantFonts [13 0 R] >>".into(), None),
(
13,
format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Inter /CIDToGIDMap /Identity /W [{h} [{}] {i} [{}]] /FontDescriptor 11 0 R >>",
w(h),
w(i)
),
None,
),
(11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None),
(12, "<< >>".into(), Some(inter)),
],
);
let c = render(&parse(&cid).unwrap());
assert!((coverage(&c, 0, 0, 200, 100) - ink).abs() < 1.0, "{} vs {ink}", coverage(&c, 0, 0, 200, 100));
}
#[test]
fn images_flate_smask_indexed_stencil_inline_and_dct() {
let objs = |im: &str, data: Vec<u8>, extra: Objs| -> Vec<u8> {
let mut e: Objs = vec![(10, im.to_string(), Some(data))];
e.extend(extra);
page_pdf("q 100 0 0 50 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", e)
};
// RGB with a soft mask: red opaque, green transparent.
let doc = parse(&objs(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R >>",
vec![255, 0, 0, 0, 255, 0],
vec![(11, "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 >>".into(), Some(vec![255, 0]))],
))
.unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let img = render(&doc);
assert!(img.get(25, 75)[0] > 0.95 && img.get(25, 75)[3] > 0.95, "{:?}", img.get(25, 75));
assert!(img.get(75, 75)[3] < 0.05, "{:?}", img.get(75, 75));
assert_eq!(img.get(25, 25)[3], 0.0, "the image is the unit square of user space");
// Indexed (palette in a string), 8 bits.
let img = render(
&parse(&objs(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1 <0000FFFFFF00>] /BitsPerComponent 8 >>",
vec![1, 0],
vec![],
))
.unwrap(),
);
let (l, r) = (img.get(25, 75), img.get(75, 75));
assert!(l[0] > 0.95 && l[1] > 0.95 && l[2] < 0.05 && r[2] > 0.95 && r[0] < 0.05, "{l:?} {r:?}");
// ICCBased with an /Alternate space, 1-bit samples.
let img = render(
&parse(&objs(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/ICCBased 11 0 R] /BitsPerComponent 1 >>",
vec![0b0100_0000],
vec![(11, "<< /N 1 /Alternate /DeviceGray >>".into(), Some(vec![0; 16]))],
))
.unwrap(),
);
assert!(img.get(25, 75)[0] < 0.05 && img.get(75, 75)[0] > 0.95);
// Stencil mask painted in the fill colour (sample 0 paints).
let stencil = page_pdf(
"0 1 0 rg q 100 0 0 50 0 0 cm /Im1 Do Q",
"/XObject << /Im1 10 0 R >>",
vec![(10, "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ImageMask true >>".into(), Some(vec![0b0100_0000]))],
);
let img = render(&parse(&stencil).unwrap());
assert!(img.get(25, 75)[1] > 0.95 && img.get(25, 75)[3] > 0.95);
assert!(img.get(75, 75)[3] < 0.05);
// Inline image (abbreviated keys, ASCIIHex) in the top half.
let inline = page_pdf("q 100 0 0 50 0 50 cm BI /W 2 /H 1 /CS /RGB /BPC 8 /F /AHx ID 00FF00FF0000> EI Q 0 0 1 rg 150 0 50 50 re f", "", vec![]);
let doc = parse(&inline).unwrap();
let img = render(&doc);
assert!(img.get(25, 25)[1] > 0.95 && img.get(75, 25)[0] > 0.95, "{:?} {:?}", img.get(25, 25), img.get(75, 25));
assert!(img.get(175, 75)[2] > 0.95, "content after EI still runs");
// DCT (JPEG): left half red, right half blue.
let mut jpg = vec![];
let mut rgb = ::image::RgbImage::new(16, 16);
for (x, _, p) in rgb.enumerate_pixels_mut() {
*p = if x < 8 { ::image::Rgb([255, 0, 0]) } else { ::image::Rgb([0, 0, 255]) };
}
::image::DynamicImage::ImageRgb8(rgb).write_to(&mut std::io::Cursor::new(&mut jpg), ::image::ImageFormat::Jpeg).unwrap();
let doc =
parse(&objs("<< /Type /XObject /Subtype /Image /Width 16 /Height 16 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode >>", jpg, vec![]))
.unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let img = render(&doc);
let (l, r) = (img.get(20, 75), img.get(80, 75));
assert!(l[0] > 0.85 && l[2] < 0.15 && r[2] > 0.85 && r[0] < 0.15, "{l:?} {r:?}");
}
#[test]
fn soft_masks_and_blend_modes() {
// Luminosity: a form that is white on the left half, transparent (→ black backdrop) elsewhere.
let lum = page_pdf(
"q /GS1 gs 1 0 0 rg 0 0 200 100 re f Q 0 0 1 rg 0 0 20 20 re f",
"/ExtGState << /GS1 << /SMask << /S /Luminosity /G 10 0 R >> >> >>",
vec![(
10,
"<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Group << /S /Transparency /CS /DeviceGray >> >>".into(),
Some(b"1 g 0 0 100 100 re f".to_vec()),
)],
);
let doc = parse(&lum).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let img = render(&doc);
assert!(img.get(50, 50)[0] > 0.99 && img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50));
assert!(img.get(150, 50)[3] < 0.01, "{:?}", img.get(150, 50));
assert!(img.get(10, 90)[2] > 0.99, "the mask ends with Q");
// Alpha: the mask form's coverage.
let alpha = page_pdf(
"/GS1 gs 1 0 0 rg 0 0 200 100 re f",
"/ExtGState << /GS1 << /SMask << /S /Alpha /G 10 0 R >> >> >>",
vec![(10, "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] >>".into(), Some(b"0 0 0 rg 100 0 100 100 re f".to_vec()))],
);
let img = render(&parse(&alpha).unwrap());
assert!(img.get(50, 50)[3] < 0.01 && img.get(150, 50)[0] > 0.99);
// Multiply over a light blue backdrop; Screen in the right half.
let blend = page_pdf(
"0.5 0.5 1 rg 0 0 200 100 re f q /M gs 1 1 0 rg 0 0 100 100 re f Q q /S gs 0.5 0 0 rg 100 0 100 100 re f Q",
"/ExtGState << /M << /BM /Multiply >> /S << /BM [/Screen] >> >>",
vec![],
);
let img = render(&parse(&blend).unwrap());
let m = img.get(50, 50);
assert!((m[0] - 0.5).abs() < 0.01 && (m[1] - 0.5).abs() < 0.01 && m[2] < 0.01, "multiply {m:?}");
let s = img.get(150, 50);
assert!((s[0] - 0.75).abs() < 0.01 && (s[1] - 0.5).abs() < 0.01 && (s[2] - 1.0).abs() < 0.01, "screen {s:?}");
}
#[test]
fn tiling_patterns_coloured_and_uncoloured() {
let cell = b"1 0 0 rg 0 0 10 10 re f 0 0 1 rg 10 10 10 10 re f".to_vec();
let bytes = page_pdf(
"/Pattern cs /P1 scn 0 0 100 100 re f /Cs1 cs 0 1 0 /P2 scn 100 0 100 100 re f",
"/Pattern << /P1 10 0 R /P2 11 0 R >> /ColorSpace << /Cs1 [/Pattern /DeviceRGB] >>",
vec![
(10, "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), Some(cell)),
(
11,
"<< /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 >>".into(),
Some(b"1 0 0 rg 0 0 10 10 re f".to_vec()),
),
],
);
let doc = parse(&bytes).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let img = render(&doc);
// PDF (x, y) → pixel (x, 100 − y).
let at = |x: i64, y: i64| img.get(x, 100 - y);
assert!(at(5, 5)[0] > 0.99 && at(25, 5)[0] > 0.99 && at(85, 45)[0] > 0.99, "{:?}", at(5, 5));
assert!(at(15, 15)[2] > 0.99 && at(55, 35)[2] > 0.99);
assert_eq!(at(15, 5)[3], 0.0);
// Uncoloured: the cell in the colour given with the pattern (green), its own colour ignored.
assert!(at(105, 5)[1] > 0.99 && at(105, 5)[0] < 0.01, "{:?}", at(105, 5));
assert_eq!(at(115, 15)[3], 0.0);
}
#[test]
fn multiple_pages_and_calculator_shadings() {
let objs: Objs = vec![
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None),
(4, "<< >>".into(), Some(b"1 0 0 rg 0 0 200 100 re f".to_vec())),
(5, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /Shading << /Sh 7 0 R >> >> /Contents 6 0 R >>".into(), None),
(6, "<< >>".into(), Some(b"/Sh sh".to_vec())),
(7, "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 8 0 R >>".into(), None),
(8, "<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] >>".into(), Some(b"{ 0 0 }".to_vec())),
];
let bytes = pdf(&objs, 1);
assert_eq!(page_count(&bytes), 2);
let p1 = parse_page(&bytes, 0).unwrap();
assert_eq!((p1.width, p1.height), (200.0, 100.0));
let p2 = parse_page(&bytes, 1).unwrap();
assert_eq!((p2.width, p2.height), (100.0, 50.0));
assert!(p2.skipped.is_empty(), "{:?}", p2.skipped);
let img = render(&p2);
assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25));
assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2)));
}

535
crates/pdf/src/type1.rs Normal file
View File

@ -0,0 +1,535 @@
//! Type 1 font programs (`FontFile`), from Adobe's published *Adobe Type 1 Font Format*:
//! the cleartext part (`/FontMatrix`, the built-in `/Encoding`), `eexec` decryption, `/Subrs`
//! and `/CharStrings`, and a Type 1 charstring interpreter (including flex and hint
//! replacement through `callothersubr`, and `seac` accented characters).
use std::collections::HashMap;
use kurbo::BezPath;
const EEXEC_R: u16 = 55665;
const CHARSTRING_R: u16 = 4330;
fn decrypt(data: &[u8], mut r: u16, skip: usize) -> Vec<u8> {
const C1: u16 = 52845;
const C2: u16 = 22719;
let mut out = Vec::with_capacity(data.len());
for &c in data {
out.push(c ^ (r >> 8) as u8);
r = (c as u16).wrapping_add(r).wrapping_mul(C1).wrapping_add(C2);
}
out.drain(..skip.min(out.len()));
out
}
/// Encrypt (for test fonts): the inverse of [`decrypt`] with `skip` leading zero bytes.
#[cfg(test)]
pub(crate) fn encrypt(plain: &[u8], mut r: u16, skip: usize) -> Vec<u8> {
const C1: u16 = 52845;
const C2: u16 = 22719;
let mut out = vec![];
for &p in std::iter::repeat_n(&0u8, skip).chain(plain.iter()) {
let c = p ^ (r >> 8) as u8;
r = (c as u16).wrapping_add(r).wrapping_mul(C1).wrapping_add(C2);
out.push(c);
}
out
}
/// A parsed Type 1 font.
#[derive(Clone, Debug, Default)]
pub struct Type1 {
pub font_matrix: Option<[f64; 6]>,
/// The built-in encoding (code → glyph name), when the font has one.
pub encoding: HashMap<u8, String>,
subrs: Vec<Vec<u8>>,
charstrings: HashMap<String, Vec<u8>>,
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if from >= hay.len() {
return None;
}
hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
}
/// Strip PFB segment headers (`0x80 type len32le`).
fn unpfb(data: &[u8]) -> Vec<u8> {
if data.first() != Some(&0x80) {
return data.to_vec();
}
let mut out = vec![];
let mut p = 0;
while p + 6 <= data.len() && data[p] == 0x80 && data[p + 1] != 3 {
let n = u32::from_le_bytes([data[p + 2], data[p + 3], data[p + 4], data[p + 5]]) as usize;
let end = (p + 6 + n).min(data.len());
out.extend_from_slice(&data[p + 6..end]);
p = end;
}
out
}
fn tokens(s: &[u8]) -> impl Iterator<Item = &[u8]> {
s.split(|c| c.is_ascii_whitespace() || matches!(c, b'[' | b']' | b'{' | b'}')).filter(|t| !t.is_empty())
}
impl Type1 {
pub fn parse(data: &[u8]) -> Option<Type1> {
let data = unpfb(data);
let mut f = Type1::default();
let ee = find(&data, b"eexec", 0)?;
let clear = &data[..ee];
// /FontMatrix [a b c d e f]
if let Some(p) = find(clear, b"/FontMatrix", 0) {
let end = find(clear, b"]", p).unwrap_or(clear.len());
let v: Vec<f64> = tokens(&clear[p + 11..end]).filter_map(|t| std::str::from_utf8(t).ok()?.parse().ok()).collect();
if v.len() == 6 {
f.font_matrix = Some([v[0], v[1], v[2], v[3], v[4], v[5]]);
}
}
// /Encoding: `dup code /name put` entries (StandardEncoding otherwise).
if let Some(p) = find(clear, b"/Encoding", 0) {
let mut toks = tokens(&clear[p + 9..]).peekable();
let mut last: Vec<&[u8]> = vec![];
for t in toks.by_ref() {
if t == b"readonly" || t == b"def" {
break;
}
if t == b"put" && last.len() >= 3 && last[last.len() - 3] == b"dup" {
let code = std::str::from_utf8(last[last.len() - 2]).ok().and_then(|c| c.parse::<u8>().ok());
let name = last[last.len() - 1].strip_prefix(b"/");
if let (Some(c), Some(n)) = (code, name) {
f.encoding.insert(c, String::from_utf8_lossy(n).into_owned());
}
}
last.push(t);
if last.len() > 4 {
last.remove(0);
}
}
}
// The encrypted part: binary or hexadecimal.
let mut body = &data[ee + 5..];
while body.first().is_some_and(|c| c.is_ascii_whitespace()) {
body = &body[1..];
}
let hex = body.iter().take(4).all(u8::is_ascii_hexdigit);
let raw: Vec<u8> = if hex {
let digits: Vec<u8> = body.iter().copied().filter(u8::is_ascii_hexdigit).collect();
digits.chunks_exact(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect()
} else {
body.to_vec()
};
let priv_ = decrypt(&raw, EEXEC_R, 4);
let len_iv =
find(&priv_, b"/lenIV", 0).and_then(|p| tokens(&priv_[p + 6..]).next()).and_then(|t| std::str::from_utf8(t).ok()?.parse::<i64>().ok()).unwrap_or(4);
let cs = |bytes: &[u8]| if len_iv < 0 { bytes.to_vec() } else { decrypt(bytes, CHARSTRING_R, len_iv as usize) };
// Binary items: `<n> RD <n bytes>` (RD may be spelt `-|`).
let read_binary = |p: usize| -> Option<(Vec<u8>, usize)> {
// p points at the length token.
let mut q = p;
while q < priv_.len() && priv_[q].is_ascii_whitespace() {
q += 1;
}
let s = q;
while q < priv_.len() && priv_[q].is_ascii_digit() {
q += 1;
}
let n: usize = std::str::from_utf8(&priv_[s..q]).ok()?.parse().ok()?;
while q < priv_.len() && priv_[q].is_ascii_whitespace() {
q += 1;
}
while q < priv_.len() && !priv_[q].is_ascii_whitespace() {
q += 1;
}
q += 1;
let bytes = priv_.get(q..q + n)?;
Some((cs(bytes), q + n))
};
if let Some(p) = find(&priv_, b"/Subrs", 0) {
let mut q = p + 6;
while let Some(d) = find(&priv_, b"dup", q) {
let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len());
if d > lim {
break;
}
let mut r = d + 3;
while r < priv_.len() && priv_[r].is_ascii_whitespace() {
r += 1;
}
let s = r;
while r < priv_.len() && priv_[r].is_ascii_digit() {
r += 1;
}
let Some(i) = std::str::from_utf8(&priv_[s..r]).ok().and_then(|x| x.parse::<usize>().ok()) else { break };
let Some((bytes, end)) = read_binary(r) else { break };
if i < 65536 {
if f.subrs.len() <= i {
f.subrs.resize(i + 1, vec![]);
}
f.subrs[i] = bytes;
}
q = end;
}
}
let cs_at = find(&priv_, b"/CharStrings", 0)?;
let mut q = cs_at + 12;
// Skip `n dict dup begin`.
q = find(&priv_, b"begin", q).map(|b| b + 5).unwrap_or(q);
while let Some(slash) = priv_[q.min(priv_.len())..].iter().position(|&c| c == b'/').map(|x| x + q) {
let mut e = slash + 1;
while e < priv_.len() && !priv_[e].is_ascii_whitespace() && !matches!(priv_[e], b'/' | b'[' | b'{') {
e += 1;
}
let name = String::from_utf8_lossy(&priv_[slash + 1..e]).into_owned();
let Some((bytes, end)) = read_binary(e) else { break };
f.charstrings.insert(name, bytes);
q = end;
// Stop at the end of the CharStrings dictionary.
let mut r = q;
while r < priv_.len() && !priv_[r].is_ascii_whitespace() {
r += 1;
}
let rest = &priv_[r.min(priv_.len())..];
let next_tok = tokens(rest).next().unwrap_or(b"");
if next_tok == b"end" {
break;
}
}
(!f.charstrings.is_empty()).then_some(f)
}
pub fn has_glyph(&self, name: &str) -> bool {
self.charstrings.contains_key(name)
}
/// A glyph's outline in glyph space and its advance width.
pub fn outline(&self, name: &str) -> Option<(BezPath, f64)> {
self.outline_depth(name, 0)
}
fn outline_depth(&self, name: &str, depth: usize) -> Option<(BezPath, f64)> {
let cs = self.charstrings.get(name)?;
let mut st = T1 {
font: self,
path: BezPath::new(),
x: 0.0,
y: 0.0,
stack: vec![],
ps: vec![],
width: 0.0,
sbx: 0.0,
open: false,
flex: None,
done: false,
depth: 0,
seac: None,
};
st.run(cs);
if st.open {
st.path.close_path();
}
let mut path = st.path;
if let Some((asb, adx, ady, b, a)) = st.seac
&& depth < 2
{
let name_of = |c: u8| crate::encoding::Base::Standard.name(c);
if let Some((bp, _)) = name_of(b).and_then(|n| self.outline_depth(&n, depth + 1)) {
path.extend(bp);
}
if let Some((ap, _)) = name_of(a).and_then(|n| self.outline_depth(&n, depth + 1)) {
// The accent's origin: its own sidebearing is replaced by `asb`.
path.extend(kurbo::Affine::translate((adx - asb + st.sbx, ady)) * ap);
}
}
Some((path, st.width))
}
}
fn hexv(c: u8) -> u8 {
match c {
b'0'..=b'9' => c - b'0',
b'a'..=b'f' => c - b'a' + 10,
b'A'..=b'F' => c - b'A' + 10,
_ => 0,
}
}
struct T1<'a> {
font: &'a Type1,
path: BezPath,
x: f64,
y: f64,
stack: Vec<f64>,
/// The PostScript operand stack shared with `callothersubr` / `pop`.
ps: Vec<f64>,
width: f64,
sbx: f64,
open: bool,
/// Flex points being collected (othersubr 1 … 0).
flex: Option<Vec<(f64, f64)>>,
done: bool,
depth: usize,
seac: Option<(f64, f64, f64, u8, u8)>,
}
impl T1<'_> {
fn rmove(&mut self, dx: f64, dy: f64) {
self.x += dx;
self.y += dy;
if let Some(f) = &mut self.flex {
f.push((self.x, self.y));
return;
}
if self.open {
self.path.close_path();
}
self.path.move_to((self.x, self.y));
self.open = true;
}
fn rline(&mut self, dx: f64, dy: f64) {
if !self.open {
self.path.move_to((self.x, self.y));
self.open = true;
}
self.x += dx;
self.y += dy;
self.path.line_to((self.x, self.y));
}
fn rcurve(&mut self, d: [f64; 6]) {
if !self.open {
self.path.move_to((self.x, self.y));
self.open = true;
}
let (x1, y1) = (self.x + d[0], self.y + d[1]);
let (x2, y2) = (x1 + d[2], y1 + d[3]);
self.x = x2 + d[4];
self.y = y2 + d[5];
self.path.curve_to((x1, y1), (x2, y2), (self.x, self.y));
}
fn run(&mut self, cs: &[u8]) {
if self.depth > 10 {
return;
}
let mut i = 0;
while i < cs.len() && !self.done {
let b0 = cs[i];
i += 1;
let s = |st: &Self, k: usize| st.stack.get(k).copied().unwrap_or(0.0);
match b0 {
32..=246 => self.stack.push(b0 as f64 - 139.0),
247..=250 => {
self.stack.push((b0 as f64 - 247.0) * 256.0 + *cs.get(i).unwrap_or(&0) as f64 + 108.0);
i += 1;
}
251..=254 => {
self.stack.push(-(b0 as f64 - 251.0) * 256.0 - *cs.get(i).unwrap_or(&0) as f64 - 108.0);
i += 1;
}
255 => {
let b = |k: usize| *cs.get(i + k).unwrap_or(&0);
self.stack.push(i32::from_be_bytes([b(0), b(1), b(2), b(3)]) as f64);
i += 4;
}
13 => {
// hsbw: sbx wx
self.sbx = s(self, 0);
self.x = self.sbx;
self.y = 0.0;
self.width = s(self, 1);
self.stack.clear();
}
21 => {
let (a, b) = (s(self, 0), s(self, 1));
self.rmove(a, b);
self.stack.clear();
}
22 => {
let a = s(self, 0);
self.rmove(a, 0.0);
self.stack.clear();
}
4 => {
let a = s(self, 0);
self.rmove(0.0, a);
self.stack.clear();
}
5 => {
let (a, b) = (s(self, 0), s(self, 1));
self.rline(a, b);
self.stack.clear();
}
6 => {
let a = s(self, 0);
self.rline(a, 0.0);
self.stack.clear();
}
7 => {
let a = s(self, 0);
self.rline(0.0, a);
self.stack.clear();
}
8 => {
let d = [s(self, 0), s(self, 1), s(self, 2), s(self, 3), s(self, 4), s(self, 5)];
self.rcurve(d);
self.stack.clear();
}
30 => {
// vhcurveto: dy1 dx2 dy2 dx3
let d = [0.0, s(self, 0), s(self, 1), s(self, 2), s(self, 3), 0.0];
self.rcurve(d);
self.stack.clear();
}
31 => {
// hvcurveto: dx1 dx2 dy2 dy3
let d = [s(self, 0), 0.0, s(self, 1), s(self, 2), 0.0, s(self, 3)];
self.rcurve(d);
self.stack.clear();
}
9 => {
if self.open {
self.path.close_path();
self.open = false;
}
self.stack.clear();
}
10 => {
let Some(n) = self.stack.pop() else { continue };
if let Some(sub) = self.font.subrs.get(n.max(0.0) as usize) {
self.depth += 1;
let sub = sub.clone();
self.run(&sub);
self.depth -= 1;
}
}
11 => return,
14 => {
self.done = true;
self.stack.clear();
}
1 | 3 => self.stack.clear(),
12 => {
let b1 = *cs.get(i).unwrap_or(&0);
i += 1;
self.escape(b1);
}
_ => self.stack.clear(),
}
}
}
fn escape(&mut self, b1: u8) {
match b1 {
6 => {
// seac: asb adx ady bchar achar
let v = |k: usize| self.stack.get(k).copied().unwrap_or(0.0);
self.seac = Some((v(0), v(1), v(2), v(3) as u8, v(4) as u8));
self.done = true;
self.stack.clear();
}
7 => {
// sbw: sbx sby wx wy
let v = |k: usize| self.stack.get(k).copied().unwrap_or(0.0);
self.sbx = v(0);
self.x = v(0);
self.y = v(1);
self.width = v(2);
self.stack.clear();
}
12 => {
let b = self.stack.pop().unwrap_or(1.0);
let a = self.stack.pop().unwrap_or(0.0);
self.stack.push(if b != 0.0 { a / b } else { 0.0 });
}
16 => {
// callothersubr: args… n othersubr#
let other = self.stack.pop().unwrap_or(0.0) as i32;
let n = self.stack.pop().unwrap_or(0.0).max(0.0) as usize;
let k = self.stack.len().saturating_sub(n);
let args: Vec<f64> = self.stack.drain(k..).collect();
match other {
1 => self.flex = Some(vec![]),
2 => {}
0 => {
// End of flex: seven points (a reference point, then two curves).
let pts = self.flex.take().unwrap_or_default();
if pts.len() >= 7 {
if !self.open {
self.path.move_to((self.x, self.y));
self.open = true;
}
self.path.curve_to(pts[1], pts[2], pts[3]);
self.path.curve_to(pts[4], pts[5], pts[6]);
self.x = pts[6].0;
self.y = pts[6].1;
}
// `pop pop setcurrentpoint` follows.
self.ps = vec![self.y, self.x];
}
_ => {
// Hint replacement and others: hand the arguments back to `pop`.
self.ps = args.into_iter().rev().collect();
}
}
}
17 => {
let v = self.ps.pop().unwrap_or(0.0);
self.stack.push(v);
}
33 => {
// setcurrentpoint
let v = |k: usize| self.stack.get(k).copied().unwrap_or(0.0);
self.x = v(0);
self.y = v(1);
self.stack.clear();
}
_ => self.stack.clear(),
}
}
}
/// A minimal Type 1 font for tests: `glyphs` are (name, plain charstring bytes).
#[cfg(test)]
pub(crate) fn write_test_type1(glyphs: &[(&str, Vec<u8>)], encoding: &[(u8, &str)]) -> Vec<u8> {
let mut clear = b"%!PS-AdobeFont-1.0: Test 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\n/Encoding 256 array\n".to_vec();
for (c, n) in encoding {
clear.extend_from_slice(format!("dup {c} /{n} put\n").as_bytes());
}
clear.extend_from_slice(b"readonly def\ncurrentfile eexec\n");
let mut priv_ =
b"dup /Private 8 dict dup begin /RD{string currentfile exch readstring pop}executeonly def\n/lenIV 4 def\n/Subrs 0 array\nND\n2 index /CharStrings "
.to_vec();
priv_.extend_from_slice(format!("{} dict dup begin\n", glyphs.len()).as_bytes());
for (n, cs) in glyphs {
let enc = encrypt(cs, CHARSTRING_R, 4);
priv_.extend_from_slice(format!("/{n} {} RD ", enc.len()).as_bytes());
priv_.extend_from_slice(&enc);
priv_.extend_from_slice(b" ND\n");
}
priv_.extend_from_slice(b"end\nend\nmark currentfile closefile\n");
let mut out = clear;
out.extend(encrypt(&priv_, EEXEC_R, 4));
out.extend_from_slice(b"\n0000000000000000\ncleartomark\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
#[test]
fn type1_decrypts_and_draws() {
// hsbw 50 600; rmoveto 0 0; 500 hlineto 500 vlineto -500 hlineto closepath endchar
let cs = vec![50 + 139, 248, 236, 13, 139, 139, 21, 248, 136, 6, 248, 136, 7, 252, 136, 6, 9, 14];
let font = write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(65, "box")]);
let t = Type1::parse(&font).unwrap();
assert_eq!(t.font_matrix, Some([0.001, 0.0, 0.0, 0.001, 0.0, 0.0]));
assert_eq!(t.encoding.get(&65).map(String::as_str), Some("box"));
let (p, w) = t.outline("box").unwrap();
assert_eq!(w, 600.0);
assert!((p.area().abs() - 250_000.0).abs() < 1e-6);
assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0));
}
}

View File

@ -1,6 +1,7 @@
//! A minimal PDF writer for generating test files (EffectCraft does not export PDF).
/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed), a
/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed unless the
/// dictionary names its own `/Filter`, in which case the data is written as given), a
/// cross-reference table and a trailer naming object `root` as the catalog.
pub fn pdf(objects: &[(u32, String, Option<Vec<u8>>)], root: u32) -> Vec<u8> {
let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec();
@ -8,6 +9,12 @@ pub fn pdf(objects: &[(u32, String, Option<Vec<u8>>)], root: u32) -> Vec<u8> {
for (n, dict, stream) in objects {
offsets.push((*n, out.len()));
match stream {
Some(data) if dict.contains("/Filter") => {
let d = format!("{} /Length {} >>", dict.trim_end().trim_end_matches(">>"), data.len());
out.extend_from_slice(format!("{n} 0 obj\n{d}\nstream\n").as_bytes());
out.extend_from_slice(data);
out.extend_from_slice(b"\nendstream\nendobj\n");
}
Some(data) => {
let z = miniz_oxide::deflate::compress_to_vec_zlib(data, 6);
let d = format!("{} /Filter /FlateDecode /Length {} >>", dict.trim_end().trim_end_matches(">>"), z.len());

View File

@ -770,6 +770,9 @@ pub struct Footage {
/// project so expressions read it everywhere (and on the web).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<String>,
/// PDF / Illustrator footage: the page shown (0-based; File ▸ Import ▸ Page).
#[serde(default, skip_serializing_if = "is_default")]
pub page: u32,
}
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
@ -801,6 +804,7 @@ impl Default for Footage {
linear_light: false,
data: None,
layer: None,
page: 0,
}
}
}
@ -845,6 +849,10 @@ pub struct SourceLayer {
/// is the footage instead of the layer's pixels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedded: Option<String>,
/// A smart object with a perspective quad or a warp: the footage is its embedded file baked
/// as placed (warped and pinned to its corners) over the placed bounds in document space.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub placed: bool,
}
fn one() -> u32 {

View File

@ -16,6 +16,12 @@ Bitmap, Grayscale, Indexed, RGB, CMYK (naive conversion) and Lab colour modes.
Text engine data (`EngineData`) is read best-effort for the font, size, fill colour and
justification of the first style run. Smart objects import as footage of their embedded file
(a Photoshop document's merged image, or an image) placed by the smart object's corner quad
(position, scale, rotation; perspective and warps are not applied). Externally linked files
(position, scale, rotation). A non-affine quad (`nonAffineTransform`: perspective) or a placed
layer warp (`warp`: the named styles with bend and horizontal / vertical distortion, and custom
envelope warps whose quilt mesh of bicubic Bezier patches is read from `meshPoints` (`ObAr`)
and `quiltSliceX` / `quiltSliceY`) is baked as placed over the placed bounds, at up to 4x the
document resolution ([`placed`](src/placed.rs)). Custom meshes are evaluated exactly; the
named styles' curves are not published, so they are clean-room geometric definitions matching
each style by behaviour. Externally linked files
fall back to the layer's rendered pixels. No Adobe code, sample files or assets were used; test
fixtures are generated in-test by the writer in this crate ([`write`](src/write.rs)).

View File

@ -30,6 +30,9 @@ pub enum DValue {
Class(String, String),
Raw(Vec<u8>),
Reference(Vec<RefItem>),
/// An object array (`ObAr`): `count` objects of one class stored column-wise, each item a
/// list of `count` values (unit floats).
ObjectArray(u32, Descriptor),
}
/// An item of a reference (`obj `).
@ -161,6 +164,10 @@ fn read_value(r: &mut Reader, ty: &[u8; 4], depth: usize) -> Result<DValue> {
}
DValue::List(v)
}
b"ObAr" => {
let n = r.u32()?;
DValue::ObjectArray(n, read_descriptor(r)?)
}
b"doub" => DValue::Double(r.f64()?),
b"UntF" => {
let unit = String::from_utf8_lossy(&r.tag()?).into_owned();
@ -280,6 +287,11 @@ fn write_value(out: &mut Vec<u8>, v: &DValue) {
write_value(out, x);
}
}
DValue::ObjectArray(n, d) => {
out.extend_from_slice(b"ObAr");
put_u32(out, *n);
write_descriptor(out, d);
}
DValue::Double(x) => {
out.extend_from_slice(b"doub");
out.extend_from_slice(&x.to_be_bytes());

View File

@ -9,6 +9,7 @@
pub mod descriptor;
pub mod engine_data;
pub mod placed;
mod read;
pub mod write;
@ -214,6 +215,8 @@ pub struct SmartObject {
pub quad: [[f64; 2]; 4],
/// The content's size in pixels (`Sz `), when recorded.
pub size: Option<[f64; 2]>,
/// The placed layer's warp (`warp`), when it has one ([`placed`]).
pub warp: Option<placed::Warp>,
}
impl SmartObject {
@ -665,8 +668,11 @@ impl Psd {
}
};
let t = nums("Trnf").filter(|t| t.len() == 8).unwrap_or_else(|| vec![0.0; 8]);
// The non-affine transform when present (perspective), else the transform.
let t = nums("nonAffineTransform").filter(|t| t.len() == 8).unwrap_or(t);
let size = d.obj("Sz ").and_then(|s| Some([s.num("Wdth")?, s.num("Hght")?]));
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size });
let warp = d.obj("warp").and_then(placed::Warp::read);
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size, warp });
}
b"PlLd" if l.smart_object.is_none() => {
let _id = r.tag()?;
@ -681,7 +687,7 @@ impl Psd {
for v in &mut t {
*v = r.f64()?;
}
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None });
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None, warp: None });
}
b"vmsk" | b"vsms" => {
let _ver = r.u32()?;

410
crates/psd/src/placed.rs Normal file
View File

@ -0,0 +1,410 @@
//! Placed-layer (smart object) geometry: the non-affine corner quad (a projective map of the
//! content rectangle) and the placed-layer warp (`warp` in `SoLd`: the named warp styles with
//! bend and horizontal / vertical distortion, and custom envelope warps whose quilt mesh is a
//! grid of bicubic Bézier patches), and a renderer that bakes both into document-space pixels.
//!
//! The custom mesh is evaluated exactly (bicubic Bézier patches over `meshPoints`, split by
//! `quiltSliceX` / `quiltSliceY`). Photoshop does not publish the curves of its named styles;
//! the styles here are clean-room geometric definitions of what each name describes (Arc,
//! Arch, Bulge, Shell, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist), matched by
//! behaviour, not numerically.
use crate::descriptor::{DValue, Descriptor};
use crate::{Pixels, SmartObject};
/// A placed layer's warp.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Warp {
/// `warpNone`, `warpArc`, `warpArcLower`, `warpArcUpper`, `warpArch`, `warpBulge`,
/// `warpShellLower`, `warpShellUpper`, `warpFlag`, `warpWave`, `warpFish`, `warpRise`,
/// `warpFisheye`, `warpInflate`, `warpSqueeze`, `warpTwist`, `warpCustom`.
pub style: String,
/// Bend, −100..100 (%).
pub bend: f64,
/// Horizontal and vertical distortion, −100..100 (%).
pub horizontal: f64,
pub vertical: f64,
/// The style is applied rotated a quarter turn (`warpRotate` = vertical).
pub rotate_vertical: bool,
/// The warped area in content pixels: left, top, right, bottom.
pub bounds: [f64; 4],
/// Custom envelope: patch columns / rows and the (3·cols+1)×(3·rows+1) control points
/// (content pixels, row-major).
pub cols: usize,
pub rows: usize,
pub mesh: Vec<[f64; 2]>,
}
fn floats(v: Option<&DValue>) -> Vec<f64> {
match v {
Some(DValue::UnitFloats(_, v)) => v.clone(),
Some(DValue::List(l)) => l
.iter()
.filter_map(|x| match x {
DValue::Double(v) | DValue::UnitFloat(_, v) => Some(*v),
DValue::Integer(i) => Some(*i as f64),
_ => None,
})
.collect(),
_ => vec![],
}
}
impl Warp {
/// Read a `warp` descriptor; `None` for no warp.
pub fn read(d: &Descriptor) -> Option<Warp> {
let style = d.enum_value("warpStyle").unwrap_or("warpNone").to_string();
let b = d.obj("bounds");
let bounds = b
.map(|b| [b.num("Left").unwrap_or(0.0), b.num("Top ").unwrap_or(0.0), b.num("Rght").unwrap_or(0.0), b.num("Btom").unwrap_or(0.0)])
.unwrap_or([0.0; 4]);
let mut w = Warp {
bend: d.num("warpValue").unwrap_or(0.0),
horizontal: d.num("warpPerspective").unwrap_or(0.0),
vertical: d.num("warpPerspectiveOther").unwrap_or(0.0),
rotate_vertical: d.enum_value("warpRotate") == Some("Vrtc"),
bounds,
cols: 1,
rows: 1,
mesh: vec![],
style,
};
if let Some(env) = d.obj("customEnvelopeWarp") {
let cols = floats(env.get("quiltSliceX")).len().saturating_sub(1).max(1);
let rows = floats(env.get("quiltSliceY")).len().saturating_sub(1).max(1);
if let Some(DValue::ObjectArray(_, pts)) = env.get("meshPoints") {
let xs = floats(pts.get("Hrzn"));
let ys = floats(pts.get("Vrtc"));
let n = (3 * cols + 1) * (3 * rows + 1);
if xs.len() == n && ys.len() == n {
w.cols = cols;
w.rows = rows;
w.mesh = xs.into_iter().zip(ys).map(|(x, y)| [x, y]).collect();
}
}
}
let identity = w.style == "warpNone" || (w.style != "warpCustom" && w.bend == 0.0 && w.horizontal == 0.0 && w.vertical == 0.0);
let custom_ok = w.style != "warpCustom" || !w.mesh.is_empty();
(!identity && custom_ok).then_some(w)
}
/// The warp of a content point (content pixels → content pixels).
pub fn apply(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
let [l, t, r, b] = if self.bounds[2] > self.bounds[0] && self.bounds[3] > self.bounds[1] { self.bounds } else { [0.0, 0.0, w, h] };
let (bw, bh) = (r - l, b - t);
let (u, v) = ((p[0] - l) / bw, (p[1] - t) / bh);
if self.style == "warpCustom" {
return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
}
// Styles work in a centred frame (pixels), optionally rotated a quarter turn.
let (x, y, fw, fh) = if self.rotate_vertical { (bh * (v - 0.5), -bw * (u - 0.5), bh, bw) } else { (bw * (u - 0.5), bh * (v - 0.5), bw, bh) };
let [mut x2, mut y2] = style_map(&self.style, self.bend / 100.0, x, y, fw, fh);
// Distortions: a perspective-like taper across the width (horizontal) or height.
let (hd, vd) = if self.rotate_vertical { (self.vertical / 100.0, self.horizontal / 100.0) } else { (self.horizontal / 100.0, self.vertical / 100.0) };
let s = (x2 / (fw / 2.0)).clamp(-1.5, 1.5);
let tt = (y2 / (fh / 2.0)).clamp(-1.5, 1.5);
y2 *= 1.0 + hd * s;
x2 *= 1.0 + vd * tt;
let (ox, oy) = if self.rotate_vertical { (-y2, x2) } else { (x2, y2) };
[l + bw / 2.0 + ox, t + bh / 2.0 + oy]
}
/// A point of the custom quilt mesh at (u, v) ∈ [0, 1]².
fn mesh_point(&self, u: f64, v: f64) -> [f64; 2] {
let (cols, rows) = (self.cols, self.rows);
let pu = (u * cols as f64).min(cols as f64 - 1e-9);
let pv = (v * rows as f64).min(rows as f64 - 1e-9);
let (ci, cj) = (pu.floor() as usize, pv.floor() as usize);
let (fu, fv) = (pu - ci as f64, pv - cj as f64);
let stride = 3 * cols + 1;
let bern = |t: f64| {
let s = 1.0 - t;
[s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t]
};
let (bu, bv) = (bern(fu), bern(fv));
let mut out = [0.0; 2];
for j in 0..4 {
for i in 0..4 {
let q = self.mesh[(3 * cj + j) * stride + 3 * ci + i];
let k = bu[i] * bv[j];
out[0] += q[0] * k;
out[1] += q[1] * k;
}
}
out
}
}
/// A named style in a centred frame `fw`×`fh` (y down), bend `b` ∈ [−1, 1].
fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] {
use std::f64::consts::PI;
let (hw, hh) = (fw / 2.0, fh / 2.0);
let s = x / hw; // −1..1 across
let t = y / hh; // −1..1 down
// Concentric arcs: the midline bends through an angle of b·π, rows keep their distance.
let arc = |x: f64, y: f64| -> [f64; 2] {
let theta = b * PI;
if theta.abs() < 1e-9 {
return [x, y];
}
let r = fw / theta;
let phi = x / hw * theta / 2.0;
let rr = r - y;
// Arc centre below the shape for upward bends; keep the midline's centre fixed.
[rr * phi.sin(), r - rr * phi.cos()]
};
let bump = 1.0 - s * s;
match style {
"warpArc" => arc(x, y),
"warpArcLower" => {
let a = arc(x, y);
let k = (t + 1.0) / 2.0;
[x + (a[0] - x) * k, y + (a[1] - y) * k]
}
"warpArcUpper" => {
let a = arc(x, y);
let k = (1.0 - t) / 2.0;
[x + (a[0] - x) * k, y + (a[1] - y) * k]
}
"warpArch" => [x, y - b * hh * bump],
"warpBulge" => [x, y + t * b * hh * bump],
"warpShellLower" => [x * (1.0 - b * 0.25 * (1.0 - t) / 2.0), y + (t + 1.0) / 2.0 * b * hh * bump],
"warpShellUpper" => [x * (1.0 - b * 0.25 * (t + 1.0) / 2.0), y - (1.0 - t) / 2.0 * b * hh * bump],
"warpFlag" => [x, y - b * hh * 0.5 * (PI * s).sin()],
"warpWave" => [x + b * hw * 0.1 * (PI * t).sin(), y - b * hh * 0.5 * (PI * s).sin() * (1.0 - 0.5 * t)],
"warpFish" => [x, y + t * b * hh * (PI * (s + 1.0) / 2.0).sin() * (1.0 - 0.5 * (s + 1.0) / 2.0)],
"warpRise" => [x, y - b * hh * (PI * s / 2.0).sin()],
"warpFisheye" | "warpInflate" => {
let k = if style == "warpFisheye" { (1.0 - (s * s + t * t) / 2.0).max(0.0) } else { 1.0 };
[x * (1.0 + b * 0.5 * k * (1.0 - t * t)), y * (1.0 + b * 0.5 * k * bump)]
}
"warpSqueeze" => [x * (1.0 - b * 0.5 * (1.0 - t * t)), y * (1.0 + b * 0.5 * bump)],
"warpTwist" => {
let r = ((s * s + t * t) / 2.0).sqrt().min(1.0);
let a = b * PI / 2.0 * (1.0 - r);
let (sn, cs) = a.sin_cos();
[x * cs - y * sn, x * sn + y * cs]
}
_ => [x, y],
}
}
/// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q`
/// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1).
fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] {
let [p0, p1, p2, p3] = q;
let sx = p0[0] - p1[0] + p2[0] - p3[0];
let sy = p0[1] - p1[1] + p2[1] - p3[1];
if sx.abs() < 1e-12 && sy.abs() < 1e-12 {
return [[p1[0] - p0[0], p2[0] - p1[0], p0[0]], [p1[1] - p0[1], p2[1] - p1[1], p0[1]], [0.0, 0.0, 1.0]];
}
let (dx1, dx2) = (p1[0] - p2[0], p3[0] - p2[0]);
let (dy1, dy2) = (p1[1] - p2[1], p3[1] - p2[1]);
let den = dx1 * dy2 - dx2 * dy1;
let g = (sx * dy2 - dx2 * sy) / den;
let h = (dx1 * sy - sx * dy1) / den;
[[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]]
}
impl SmartObject {
/// Whether the corner quad is a parallelogram (an affine placement).
pub fn is_affine(&self) -> bool {
let q = self.quad;
let ex = q[0][0] + q[2][0] - q[1][0] - q[3][0];
let ey = q[0][1] + q[2][1] - q[1][1] - q[3][1];
ex.abs() < 1e-3 && ey.abs() < 1e-3
}
/// Whether placing needs baking (a perspective quad or a warp).
pub fn needs_bake(&self) -> bool {
!self.is_affine() || self.warp.is_some()
}
/// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad.
pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
let p = match &self.warp {
Some(wp) => wp.apply(p, w, h),
None => p,
};
let m = square_to_quad(self.quad);
let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9));
let z = m[2][0] * u + m[2][1] * v + m[2][2];
let z = if z.abs() < 1e-12 { 1e-12 } else { z };
[(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z]
}
/// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content.
pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] {
let n = 32;
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for j in 0..=n {
for i in 0..=n {
let p = self.map([i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h);
b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])];
}
}
[b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()]
}
/// The baked size for a `w`×`h` content at `k` pixels per document pixel.
pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) {
let bb = self.placed_bounds(w, h);
(((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32)
}
/// Bake the content (straight RGBA) as placed: warped and mapped by the quad, rendered at
/// `k` pixels per document pixel (into `size` pixels when given; see [`Self::placed_size`]).
/// Returns the pixels and their document-space origin.
pub fn render_placed(&self, content: &Pixels, k: f64, size: Option<(u32, u32)>) -> Option<(Pixels, [f64; 2])> {
let (cw, ch) = (content.width as f64, content.height as f64);
if content.width == 0 || content.height == 0 {
return None;
}
let bb = self.placed_bounds(cw, ch);
let (ow, oh) = match size {
Some((w, h)) => (w.max(1), h.max(1)),
None => self.placed_size(cw, ch, k),
};
let (ow, oh) = (ow as usize, oh as usize);
// A forward grid, filled triangle by triangle with inverse barycentric sampling.
let n = 48usize;
let mut dst = Vec::with_capacity((n + 1) * (n + 1));
let mut src = Vec::with_capacity((n + 1) * (n + 1));
for j in 0..=n {
for i in 0..=n {
let s = [i as f64 / n as f64 * cw, j as f64 / n as f64 * ch];
let d = self.map(s, cw, ch);
dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]);
src.push(s);
}
}
let mut out = vec![[0.0f32; 4]; ow * oh];
let sample = |x: f64, y: f64| -> [f32; 4] {
let (fx, fy) = ((x - 0.5).clamp(0.0, cw - 1.0), (y - 0.5).clamp(0.0, ch - 1.0));
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
let (x1, y1) = ((x0 + 1).min(content.width as usize - 1), (y0 + 1).min(content.height as usize - 1));
let (a, b) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32);
let at = |x: usize, y: usize| {
let p = content.data[y * content.width as usize + x];
[p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]
};
let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1));
let mut o = [0.0; 4];
for c in 0..4 {
o[c] = (p00[c] * (1.0 - a) + p10[c] * a) * (1.0 - b) + (p01[c] * (1.0 - a) + p11[c] * a) * b;
}
o
};
let idx = |i: usize, j: usize| j * (n + 1) + i;
for j in 0..n {
for i in 0..n {
for tri in [[idx(i, j), idx(i + 1, j), idx(i + 1, j + 1)], [idx(i, j), idx(i + 1, j + 1), idx(i, j + 1)]] {
let [a, b, c] = tri.map(|t| dst[t]);
let den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
if den.abs() < 1e-12 {
continue;
}
let x0 = a[0].min(b[0]).min(c[0]).floor().max(0.0) as usize;
let x1 = (a[0].max(b[0]).max(c[0]).ceil() as usize).min(ow);
let y0 = a[1].min(b[1]).min(c[1]).floor().max(0.0) as usize;
let y1 = (a[1].max(b[1]).max(c[1]).ceil() as usize).min(oh);
for y in y0..y1 {
for x in x0..x1 {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let l1 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den;
let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den;
let l3 = 1.0 - l1 - l2;
if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 {
continue;
}
let [sa, sb, sc] = tri.map(|t| src[t]);
let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3;
let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3;
out[y * ow + x] = sample(sx, sy);
}
}
}
}
}
// Back to straight alpha.
for p in &mut out {
if p[3] > 0.0 {
for c in 0..3 {
p[c] /= p[3];
}
}
}
Some((Pixels { width: ow as u32, height: oh as u32, data: out }, [bb[0], bb[1]]))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn so(quad: [[f64; 2]; 4], warp: Option<Warp>) -> SmartObject {
SmartObject { uuid: "x".into(), quad, size: Some([20.0, 10.0]), warp }
}
#[test]
fn perspective_quad_maps_corners_and_is_projective() {
let q = [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]];
let s = so(q, None);
assert!(!s.is_affine() && s.needs_bake());
for (c, want) in [([0.0, 0.0], q[0]), ([20.0, 0.0], q[1]), ([20.0, 10.0], q[2]), ([0.0, 10.0], q[3])] {
let p = s.map(c, 20.0, 10.0);
assert!((p[0] - want[0]).abs() < 1e-9 && (p[1] - want[1]).abs() < 1e-9, "{c:?} → {p:?}");
}
// Projective, not bilinear: the content centre is where the diagonals cross.
let c = s.map([10.0, 5.0], 20.0, 10.0);
assert!((c[0] - 30.0).abs() > 0.5, "{c:?}");
let affine = so([[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], None);
assert!(affine.is_affine() && !affine.needs_bake());
}
#[test]
fn custom_mesh_and_styles_warp() {
// An identity 4×4 mesh shifted 3 px right in its interior row: interior points move.
let mut mesh = vec![];
for j in 0..4 {
for i in 0..4 {
mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 3.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]);
}
}
let w = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() };
let p = w.apply([10.0, 5.0], 20.0, 10.0);
assert!((p[0] - 12.25).abs() < 1e-9 && (p[1] - 5.0).abs() < 1e-9, "{p:?}");
assert_eq!(w.apply([0.0, 0.0], 20.0, 10.0), [0.0, 0.0]);
// Arc: the top centre rises, the bottom centre too (concentric arcs); Bulge: top up,
// bottom down; Flag keeps the centre.
let style = |s: &str| Warp { style: s.into(), bend: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() };
let top = style("warpArc").apply([100.0, 0.0], 200.0, 100.0);
let corner = style("warpArc").apply([0.0, 0.0], 200.0, 100.0);
assert!(top[1] < corner[1], "arc {top:?} {corner:?}");
let b = style("warpBulge");
assert!(b.apply([100.0, 0.0], 200.0, 100.0)[1] < 0.0 && b.apply([100.0, 100.0], 200.0, 100.0)[1] > 100.0);
assert!((style("warpFlag").apply([100.0, 50.0], 200.0, 100.0)[1] - 50.0).abs() < 1e-9);
// Vertical rotation swaps the roles of the axes.
let mut v = style("warpBulge");
v.rotate_vertical = true;
let l = v.apply([0.0, 50.0], 200.0, 100.0);
assert!(l[0] < 0.0 && (l[1] - 50.0).abs() < 1e-9, "{l:?}");
// Horizontal distortion: the right side grows taller.
let d = Warp { style: "warpArc".into(), horizontal: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() };
assert!(d.apply([200.0, 0.0], 200.0, 100.0)[1] < d.apply([0.0, 0.0], 200.0, 100.0)[1]);
}
#[test]
fn render_placed_bakes_a_perspective_quad() {
// 2×1 content: red | green, pinned to a trapezoid.
let content = Pixels { width: 2, height: 1, data: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]] };
let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [40.0, 5.0], [40.0, 15.0], [0.0, 20.0]], size: None, warp: None };
let (px, origin) = s.render_placed(&content, 1.0, None).unwrap();
assert_eq!(origin, [0.0, 0.0]);
assert_eq!((px.width, px.height), (40, 20));
let at = |x: usize, y: usize| px.data[y * 40 + x];
assert!(at(5, 10)[0] > 0.9 && at(5, 10)[3] > 0.99, "{:?}", at(5, 10));
assert!(at(35, 10)[1] > 0.9, "{:?}", at(35, 10));
assert_eq!(at(38, 1)[3], 0.0, "outside the trapezoid");
}
}

View File

@ -414,14 +414,53 @@ fn linked_block(files: &[(String, String, [u8; 4], Vec<u8>)]) -> Vec<u8> {
/// `SoLd`: a placed layer showing linked file `uuid` (content `size` pixels) with its corners at
/// `quad` (top-left, top-right, bottom-right, bottom-left).
pub fn smart_object_block(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2]) -> ([u8; 4], Vec<u8>) {
smart_object_block_warped(uuid, quad, size, None)
}
/// A `warp` descriptor: a named style (bend and distortions in %) over `bounds` (left, top,
/// right, bottom), or with `mesh` (16 points, content pixels) a custom envelope.
pub fn warp_descriptor(style: &str, bend: f64, h: f64, v: f64, bounds: [f64; 4], mesh: Option<&[[f64; 2]]>) -> Descriptor {
let mut d = Descriptor::new("warp")
.with("warpStyle", DValue::Enum("warpStyle".into(), style.into()))
.with("warpValue", DValue::Double(bend))
.with("warpPerspective", DValue::Double(h))
.with("warpPerspectiveOther", DValue::Double(v))
.with("warpRotate", DValue::Enum("Ornt".into(), "Hrzn".into()))
.with(
"bounds",
DValue::Descriptor(
Descriptor::new("Rctn")
.with("Top ", DValue::UnitFloat("#Pxl".into(), bounds[1]))
.with("Left", DValue::UnitFloat("#Pxl".into(), bounds[0]))
.with("Btom", DValue::UnitFloat("#Pxl".into(), bounds[3]))
.with("Rght", DValue::UnitFloat("#Pxl".into(), bounds[2])),
),
)
.with("uOrder", DValue::Integer(4))
.with("vOrder", DValue::Integer(4));
if let Some(m) = mesh {
let pts = Descriptor::new("rationalPoint")
.with("Hrzn", DValue::UnitFloats("#Pxl".into(), m.iter().map(|p| p[0]).collect()))
.with("Vrtc", DValue::UnitFloats("#Pxl".into(), m.iter().map(|p| p[1]).collect()));
d = d
.with("customEnvelopeWarp", DValue::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", DValue::ObjectArray(m.len() as u32, pts))));
}
d
}
/// `SoLd` with an optional warp; `quad` may be non-affine (perspective).
pub fn smart_object_block_warped(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2], warp: Option<Descriptor>) -> ([u8; 4], Vec<u8>) {
let list = |v: &[f64]| DValue::List(v.iter().map(|x| DValue::Double(*x)).collect());
let t: Vec<f64> = quad.iter().flat_map(|p| [p[0], p[1]]).collect();
let d = Descriptor::new("null")
let mut d = Descriptor::new("null")
.with("Idnt", DValue::Text(uuid.to_string()))
.with("placed", DValue::Text(uuid.to_string()))
.with("Trnf", list(&t))
.with("nonAffineTransform", list(&t))
.with("Sz ", DValue::Descriptor(Descriptor::new("Pnt ").with("Wdth", DValue::Double(size[0])).with("Hght", DValue::Double(size[1]))));
if let Some(w) = warp {
d = d.with("warp", DValue::Descriptor(w));
}
let mut out = b"soLD".to_vec();
u32b(&mut out, 4);
u32b(&mut out, 16);

View File

@ -395,6 +395,8 @@ pub struct Renderer<'a> {
/// Switches inherited from the precomp layers this comp is rendered through (see
/// [`RenderOpts::nested_switches`]): the worst quality and whether motion blur is allowed.
pub(crate) inherited: Option<(Quality, bool)>,
/// Classic 3D depth of field is left to an accelerator ([`three_d::Plane3d::dof`]).
pub(crate) defer_dof: bool,
/// This renderer draws into the top-level frame's canvas (the top comp, or a collapsed
/// precomp drawn straight into it): the region of interest's offset applies.
top: bool,
@ -442,6 +444,7 @@ impl<'a> Renderer<'a> {
opacity_mul: 1.0,
collapse3d: None,
inherited: None,
defer_dof: false,
top: true,
}
}
@ -477,7 +480,7 @@ impl<'a> Renderer<'a> {
/// A renderer for nested work (precomps, layers read by effects): one level deeper.
fn nested(&self) -> Renderer<'a> {
Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, top: false, ..*self }
Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, defer_dof: false, top: false, ..*self }
}
/// The nested comp of a precomp layer whose transformations collapse into this comp: the

View File

@ -190,21 +190,28 @@ pub fn boost_highlights(img: &mut Image, h: &Highlight) {
});
}
/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels
/// beyond the image are transparent.
pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image {
/// Weighted row-span kernels and their normalisation.
pub type Kernels = (Vec<(f32, Vec<(i32, f64, f64)>)>, f32);
/// The bokeh kernel of radius `r` as weighted row spans (see [`Iris::spans`]) and the sum of
/// weights; `None` when the blur does nothing (radius below 0.3 px). Fast Rectangle is the box
/// of the iris' width and height (one span per row covering whole pixels); the diffraction
/// fringe adds the rim as the full iris minus a shrunken one. The GPU gathers with exactly these
/// spans.
pub fn kernel_spans(iris: &Iris, r: f64) -> Option<Kernels> {
if r < 0.3 {
return img.clone();
return None;
}
if iris.sides == 0 {
// Fast Rectangle: a separable box of the iris' width and height.
let a = iris.aspect.clamp(0.01, 100.0).sqrt();
let rx = (r * a).round().max(0.0) as usize;
let ry = (r / a).round().max(0.0) as usize;
return effectcraft_raster::box_blur(img, rx, ry, 1, false);
let rx = (r * a).round().max(0.0) as i32;
let ry = (r / a).round().max(0.0) as i32;
if rx == 0 && ry == 0 {
return None;
}
let spans = (-ry..=ry).map(|dy| (dy, -rx as f64 - 0.5, rx as f64 + 0.5)).collect();
return Some((vec![(1.0, spans)], ((2 * rx + 1) * (2 * ry + 1)) as f32));
}
// The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). The
// diffraction fringe adds the rim: the full iris minus a shrunken one.
let outer = iris.spans(r);
let area = |sp: &[(i32, f64, f64)]| sp.iter().map(|s| (s.2 - s.1) as f32).sum::<f32>();
let mut kernels = vec![(1.0f32, outer)];
@ -216,25 +223,72 @@ pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image {
kernels.push((-f, inner));
}
let norm: f32 = kernels.iter().map(|(wt, sp)| wt * area(sp)).sum();
if norm <= 0.0 {
(norm > 0.0).then_some((kernels, norm))
}
/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels
/// beyond the image are transparent.
pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image {
if r < 0.3 {
return img.clone();
}
span_gather(img, &kernels, norm)
if iris.sides == 0 {
// Fast Rectangle: a separable box of the iris' width and height (the same as its spans).
let a = iris.aspect.clamp(0.01, 100.0).sqrt();
let rx = (r * a).round().max(0.0) as usize;
let ry = (r / a).round().max(0.0) as usize;
return effectcraft_raster::box_blur(img, rx, ry, 1, false);
}
// The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area).
match kernel_spans(iris, r) {
Some((kernels, norm)) => span_gather(img, &kernels, norm),
None => img.clone(),
}
}
/// The blur levels of a depth-varying blur ([`progressive_blur`]): `None` when nothing blurs,
/// one level for a near-constant radius, else up to eight radii spanning the range.
pub fn blur_levels(radius: &[f32]) -> Option<Vec<f32>> {
let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r)));
if !lo.is_finite() || hi < 0.3 {
return None;
}
if hi - lo < 0.75 {
return Some(vec![(lo + hi) * 0.5]);
}
let n = (((hi - lo) / 2.0).ceil() as usize + 1).clamp(2, 8);
Some((0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect())
}
/// How much level `k` of `levels` contributes at radius `r` (the two levels bracketing `r`
/// are blended linearly).
pub fn level_weight(levels: &[f32], k: usize, r: f32) -> f32 {
let n = levels.len();
if n <= 1 {
return 1.0;
}
let (lo, hi) = (levels[0], levels[n - 1]);
let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32);
let kk = (f.floor() as usize).min(n - 2);
let t = f - kk as f32;
if k == kk {
1.0 - t
} else if k == kk + 1 {
t
} else {
0.0
}
}
/// Depth-varying blur: `radius` gives each pixel's blur radius. The image is blurred at up to
/// eight radii spanning the range and each pixel interpolates between the two levels around
/// its own radius.
pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image {
let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r)));
if !lo.is_finite() || hi < 0.3 {
return img.clone();
let Some(levels) = blur_levels(radius) else { return img.clone() };
if levels.len() == 1 {
return bokeh_blur(img, iris, levels[0] as f64);
}
if hi - lo < 0.75 {
return bokeh_blur(img, iris, ((lo + hi) * 0.5) as f64);
}
let n = (((hi - lo) / 2.0).ceil() as usize + 1).clamp(2, 8);
let levels: Vec<f32> = (0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect();
let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len());
let blurred: Vec<Image> = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect();
let mut out = Image::new(img.width, img.height);
out.data.par_iter_mut().enumerate().for_each(|(i, o)| {

View File

@ -62,6 +62,21 @@ pub(crate) struct Item<'a> {
winv: Mat4,
bounds: [f64; 4],
bbox: [i64; 4],
/// Depth of field left to an accelerator (`buf` is then unblurred and unpadded).
dof: Option<PlaneDof>,
}
/// Depth of field of a plane for an accelerator to apply ([`Plane3d::dof`]): pad the buffer by
/// `pad` transparent pixels, boost its highlights, then blur it at each of `levels` with
/// [`super::bokeh::kernel_spans`] and blend the levels by each pixel's `radius` (padded buffer
/// pixels, row-major) with [`super::bokeh::level_weight`] — what the CPU's depth of field does.
#[derive(Clone, Debug)]
pub struct PlaneDof {
pub pad: u32,
pub iris: super::bokeh::Iris,
pub highlight: super::bokeh::Highlight,
pub radius: Arc<Vec<f32>>,
pub levels: Vec<f32>,
}
impl Item<'_> {
@ -218,10 +233,20 @@ fn prepare_with<'a>(
let world = outer * ctx.world_matrix(layer) * local;
// Depth of field: a bokeh blur by each buffer pixel's circle of confusion (constant for a
// layer facing the camera, progressive for a tilted one).
let mut plane_dof = None;
let mut bounds = buf_bounds(&buf);
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
buf = dof_blur(&cam, &dof, world, buf);
if r.defer_dof {
// An accelerator blurs: hand it the radius map of the padded buffer.
if let Some((pd, padded_bounds)) = dof_plan(&cam, &dof, world, &buf) {
plane_dof = Some(pd);
bounds = padded_bounds;
}
} else {
buf = dof_blur(&cam, &dof, world, buf);
bounds = buf_bounds(&buf);
}
}
let bounds = buf_bounds(&buf);
let n = if in_run { mb_samples(r, ctx, layer) } else { 1 };
let geos: Vec<Geo> = if n <= 1 {
geo(&cam, world, bounds, comp, s, out, r.out_offset()).into_iter().collect()
@ -260,16 +285,20 @@ fn prepare_with<'a>(
winv: world.inverse().unwrap_or(Mat4::IDENTITY),
bounds,
bbox,
dof: plane_dof,
})
}
/// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from
/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there.
fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec<f32> {
let s = buf.scale.max(1e-9);
dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale)
}
fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec<f32> {
let s = scale.max(1e-9);
let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9);
let (w, h) = (buf.img.width as usize, buf.img.height as usize);
let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - buf.offset[0]) / s, (y - buf.offset[1]) / s, 0.0)));
let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0)));
// Camera depth is affine across a plane.
let z0 = at(0.5, 0.5);
let (zx, zy) = (at(1.5, 0.5) - z0, at(0.5, 1.5) - z0);
@ -287,6 +316,30 @@ fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf
out
}
/// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`.
fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 {
let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt());
(rmax * a).ceil() as u32 + 2
}
/// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded
/// buffer's layer-space bounds. `None` when nothing blurs.
fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> {
let radii = dof_radii(cam, dof, world, buf);
let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64;
if rmax <= 0.3 {
return None;
}
let pad = dof_pad(dof, rmax);
let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize);
let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64];
let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale);
let levels = super::bokeh::blur_levels(&radius)?;
let s = buf.scale.max(1e-9);
let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s];
Some((PlaneDof { pad, iris: dof.iris, highlight: dof.highlight, radius: Arc::new(radius), levels }, bounds))
}
/// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth.
fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc<Buf>) -> Arc<Buf> {
let radii = dof_radii(cam, dof, world, &buf);
@ -294,8 +347,7 @@ fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc<B
if rmax <= 0.3 {
return buf;
}
let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt());
let pad = (rmax * a).ceil() as u32 + 2;
let pad = dof_pad(dof, rmax);
let mut padded = buf.img.padded(pad);
super::bokeh::boost_highlights(&mut padded, &dof.highlight);
let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale };
@ -666,8 +718,11 @@ pub struct Run3d {
/// One plane of a [`Run3d`].
#[derive(Clone, Debug)]
pub struct Plane3d {
/// The layer buffer (blending space; depth of field already applied).
/// The layer buffer (blending space; depth of field already applied unless `dof` is set).
pub buf: Arc<Buf>,
/// Depth of field for the accelerator to apply to `buf` (`bounds` are then the padded
/// buffer's).
pub dof: Option<PlaneDof>,
pub bicubic: bool,
/// One per motion-blur sub-sample (equal weights).
pub geos: Vec<Geo>,
@ -697,7 +752,9 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3
if super::adv::active(r, ctx) {
return None;
}
let g = gather(r, ctx, run, out);
// Depth of field is left to the accelerator (its blur runs where the planes are drawn).
let rd = Renderer { defer_dof: true, ..*r };
let g = gather(&rd, ctx, run, out);
let planes: Vec<Plane3d> = g
.items
.into_iter()
@ -706,6 +763,7 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3
layer_id: it.layer.id.0 as u32,
mode: it.layer.blend_mode,
buf: it.buf,
dof: it.dof,
bicubic: it.bicubic,
geos: it.geos,
mat: it.mat,

View File

@ -17,7 +17,7 @@ mod tests;
mod tests_orient;
pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam};
pub use compose::{Geo as PlaneGeo, Plane3d, Run3d};
pub use compose::{Geo as PlaneGeo, Plane3d, PlaneDof, Run3d};
pub use light::{LightState, Material, lights_at};
use effectcraft_geom::Mat3;

View File

@ -13,7 +13,7 @@ use std::collections::HashMap;
pub use color::parse_color;
pub use kurbo::{Affine, BezPath};
pub use pathdata::parse_path_data;
pub use raster::{rasterize, rasterize_with};
pub use raster::{blend_pixel, rasterize, rasterize_with};
#[derive(Debug, Clone, PartialEq)]
pub enum Error {
@ -173,6 +173,88 @@ pub struct Group {
/// Clipping paths in the group's space (after `transform`); the children show only where
/// every one of them covers (PDF / EPS clipping; SVG `clipPath` is not read yet).
pub clip: Vec<(BezPath, FillRule)>,
/// How the group composites onto what is below it (PDF blend modes; SVG `mix-blend-mode`
/// is not read yet).
pub blend: BlendMode,
/// A soft mask (PDF `SMask` in the graphics state): the group shows where the mask's
/// luminosity (or alpha) is high.
pub mask: Option<Box<SoftMask>>,
}
impl Group {
/// An empty, opaque, unclipped group.
pub fn new(name: &str) -> Group {
Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![], blend: BlendMode::Normal, mask: None }
}
}
/// Separable and non-separable blend modes (ISO 32000-1 §11.3.5; the same formulas as the
/// W3C Compositing and Blending Level 1 specification).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BlendMode {
#[default]
Normal,
Multiply,
Screen,
Overlay,
Darken,
Lighten,
ColorDodge,
ColorBurn,
HardLight,
SoftLight,
Difference,
Exclusion,
Hue,
Saturation,
Color,
Luminosity,
}
impl BlendMode {
/// A PDF blend mode name (`/Multiply` …); unknown names are Normal.
pub fn from_pdf_name(n: &str) -> BlendMode {
use BlendMode::*;
match n {
"Multiply" => Multiply,
"Screen" => Screen,
"Overlay" => Overlay,
"Darken" => Darken,
"Lighten" => Lighten,
"ColorDodge" => ColorDodge,
"ColorBurn" => ColorBurn,
"HardLight" => HardLight,
"SoftLight" => SoftLight,
"Difference" => Difference,
"Exclusion" => Exclusion,
"Hue" => Hue,
"Saturation" => Saturation,
"Color" => Color,
"Luminosity" => Luminosity,
_ => Normal,
}
}
}
/// A soft mask: `content` is drawn in the masked group's space; its luminosity (composited
/// over `backdrop`) or its alpha is the mask.
#[derive(Clone, Debug, PartialEq)]
pub struct SoftMask {
pub content: Group,
pub luminosity: bool,
pub backdrop: [f64; 3],
}
/// A raster image: `width`×`height` straight RGBA8 pixels; `transform` maps pixel space (y
/// down, pixel `(0, 0)` at the top-left corner) to the parent's space.
#[derive(Clone, Debug, PartialEq)]
pub struct Image {
pub name: String,
pub transform: Affine,
pub width: u32,
pub height: u32,
pub rgba: std::sync::Arc<Vec<u8>>,
pub opacity: f64,
}
#[derive(Clone, Debug, PartialEq)]
@ -180,6 +262,7 @@ pub struct Group {
pub enum Node {
Group(Group),
Shape(Shape),
Image(Image),
}
/// A parsed SVG document. `root.transform` maps the viewBox to `width`×`height` pixels.
@ -207,6 +290,7 @@ impl Doc {
match c {
Node::Group(sub) => walk(sub, m, o, out),
Node::Shape(s) => out.push((m * s.transform, o * s.opacity, s)),
Node::Image(_) => {}
}
}
}
@ -669,7 +753,7 @@ impl<'a, 'i> Parser<'a, 'i> {
for c in n.children() {
self.node(c, &st, &mut children, depth + 1);
}
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] }));
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None }));
}
"svg" => {
// Nested viewport.
@ -688,7 +772,15 @@ impl<'a, 'i> Parser<'a, 'i> {
self.node(c, &st, &mut children, depth + 1);
}
(self.vw, self.vh) = saved;
out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children, clip: vec![] }));
out.push(Node::Group(Group {
name: Self::element_name(n),
transform: Affine::translate((x, y)) * vb,
opacity,
children,
clip: vec![],
blend: BlendMode::Normal,
mask: None,
}));
}
"use" => {
let Some(target) = self.href(n) else { return };
@ -709,11 +801,19 @@ impl<'a, 'i> Parser<'a, 'i> {
for c in target.children() {
self.node(c, &sst, &mut inner, depth + 1);
}
children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner, clip: vec![] }));
children.push(Node::Group(Group {
name: Self::element_name(target),
transform: vb,
opacity: o,
children: inner,
clip: vec![],
blend: BlendMode::Normal,
mask: None,
}));
} else {
self.node(target, &st, &mut children, depth + 1);
}
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] }));
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None }));
}
"path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => {
if !st.visible {
@ -753,7 +853,15 @@ impl<'a, 'i> Parser<'a, 'i> {
self.node(c, &st, &mut children, depth + 1);
}
if !children.is_empty() {
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] }));
out.push(Node::Group(Group {
name: Self::element_name(n),
transform,
opacity,
children,
clip: vec![],
blend: BlendMode::Normal,
mask: None,
}));
}
}
}
@ -945,7 +1053,12 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
p.node(c, &rst, &mut children, 1);
}
let transform = viewbox_transform(root, dw, dh);
Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children, clip: vec![] }, skipped: std::mem::take(&mut p.skipped) })
Ok(Doc {
width: dw,
height: dh,
root: Group { name: "svg".into(), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None },
skipped: std::mem::take(&mut p.skipped),
})
}
#[cfg(test)]

View File

@ -6,7 +6,7 @@ use effectcraft_raster::{Image, Mask};
use kurbo::{Affine, Point, Shape as _};
use rayon::prelude::*;
use crate::{Doc, FillRule, Gradient, GradientKind, Group, Node, Paint, Shape, Spread};
use crate::{BlendMode, Doc, FillRule, Gradient, GradientKind, Group, Node, Paint, Shape, Spread};
fn mat3(a: Affine) -> Mat3 {
let c = a.as_coeffs();
@ -31,38 +31,166 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
if o <= 0.0 {
return;
}
if !g.clip.is_empty() {
// Draw the children on their own, keep what every clip path covers, composite.
let mut tmp = Image::new(dst.width, dst.height);
for c in &g.children {
draw_node(c, m, 1.0, &mut tmp);
}
for (path, rule) in &g.clip {
let rule = match rule {
FillRule::NonZero => effectcraft_path::FillRule::NonZero,
FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd,
};
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), dst.width, dst.height, rule);
tmp.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(p, c)| {
for v in p.iter_mut() {
*v *= c;
}
});
}
over(dst, &tmp, o as f32);
return;
}
if g.opacity < 1.0 && g.children.len() > 1 {
let mut tmp = Image::new(dst.width, dst.height);
for c in &g.children {
draw_node(c, m, 1.0, &mut tmp);
}
over(dst, &tmp, o as f32);
} else {
let isolated = !g.clip.is_empty() || g.mask.is_some() || g.blend != BlendMode::Normal || (g.opacity < 1.0 && g.children.len() > 1);
if !isolated {
for c in &g.children {
draw_node(c, m, o, dst);
}
return;
}
// Draw the children on their own, keep what every clip path covers and the soft mask
// lets through, then composite with the group's opacity and blend mode.
let mut tmp = Image::new(dst.width, dst.height);
for c in &g.children {
draw_node(c, m, 1.0, &mut tmp);
}
for (path, rule) in &g.clip {
let rule = match rule {
FillRule::NonZero => effectcraft_path::FillRule::NonZero,
FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd,
};
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), dst.width, dst.height, rule);
tmp.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(p, c)| {
for v in p.iter_mut() {
*v *= c;
}
});
}
if let Some(sm) = &g.mask {
let mut mk = Image::new(dst.width, dst.height);
draw_group(&sm.content, m, 1.0, &mut mk);
let bc = sm.backdrop.map(|v| v as f32);
let lum = sm.luminosity;
tmp.data.par_iter_mut().zip(mk.data.par_iter()).for_each(|(p, q)| {
let k = if lum {
// The mask group composited over its backdrop colour, then its luminosity.
let c = [0, 1, 2].map(|i| q[i] + bc[i] * (1.0 - q[3]));
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
} else {
q[3]
};
let k = k.clamp(0.0, 1.0);
for v in p.iter_mut() {
*v *= k;
}
});
}
if g.blend == BlendMode::Normal {
over(dst, &tmp, o as f32);
} else {
let mode = g.blend;
let k = o as f32;
dst.data.par_iter_mut().zip(tmp.data.par_iter()).for_each(|(d, s)| {
let s = [s[0] * k, s[1] * k, s[2] * k, s[3] * k];
if s[3] > 0.0 {
*d = blend_pixel(mode, *d, s);
}
});
}
}
/// `src` composited onto `dst` with a blend mode (both premultiplied RGBA):
/// `co = cs·(1 − αb) + cb·(1 − αs) + αs·αb·B(cb, cs)` (ISO 32000-1 §11.3.6).
pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4]) -> [f32; 4] {
let (ab, as_) = (dst[3], src[3]);
let un = |c: [f32; 4]| if c[3] > 0.0 { [c[0] / c[3], c[1] / c[3], c[2] / c[3]] } else { [0.0; 3] };
let (cb, cs) = (un(dst), un(src));
let b = blend_color(mode, cb, cs);
let mut out = [0.0; 4];
for i in 0..3 {
out[i] = src[i] * (1.0 - ab) + dst[i] * (1.0 - as_) + as_ * ab * b[i];
}
out[3] = as_ + ab * (1.0 - as_);
out
}
fn blend_color(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
use BlendMode::*;
let sep = |f: fn(f32, f32) -> f32| [f(cb[0], cs[0]), f(cb[1], cs[1]), f(cb[2], cs[2])];
fn hard(b: f32, s: f32) -> f32 {
if s <= 0.5 {
b * 2.0 * s
} else {
let t = 2.0 * s - 1.0;
b + t - b * t
}
}
match mode {
Normal => cs,
Multiply => sep(|b, s| b * s),
Screen => sep(|b, s| b + s - b * s),
Overlay => sep(|b, s| hard(s, b)),
Darken => sep(f32::min),
Lighten => sep(f32::max),
ColorDodge => sep(|b, s| {
if b <= 0.0 {
0.0
} else if s >= 1.0 {
1.0
} else {
(b / (1.0 - s)).min(1.0)
}
}),
ColorBurn => sep(|b, s| {
if b >= 1.0 {
1.0
} else if s <= 0.0 {
0.0
} else {
1.0 - ((1.0 - b) / s).min(1.0)
}
}),
HardLight => sep(hard),
SoftLight => sep(|b, s| {
if s <= 0.5 {
b - (1.0 - 2.0 * s) * b * (1.0 - b)
} else {
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
b + (2.0 * s - 1.0) * (d - b)
}
}),
Difference => sep(|b, s| (b - s).abs()),
Exclusion => sep(|b, s| b + s - 2.0 * b * s),
Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)),
Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)),
Color => set_lum(cs, lum(cb)),
Luminosity => set_lum(cb, lum(cs)),
}
}
fn lum(c: [f32; 3]) -> f32 {
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
}
fn clip_color(c: [f32; 3]) -> [f32; 3] {
let l = lum(c);
let n = c[0].min(c[1]).min(c[2]);
let x = c[0].max(c[1]).max(c[2]);
let mut c = c;
if n < 0.0 && l - n > 0.0 {
c = c.map(|v| l + (v - l) * l / (l - n));
}
if x > 1.0 && x - l > 0.0 {
c = c.map(|v| l + (v - l) * (1.0 - l) / (x - l));
}
c
}
fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] {
let d = l - lum(c);
clip_color(c.map(|v| v + d))
}
fn sat(c: [f32; 3]) -> f32 {
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
}
fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
let (mx, mn) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
if mx - mn <= 0.0 {
return [0.0; 3];
}
c.map(|v| (v - mn) * s / (mx - mn))
}
fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) {
@ -77,9 +205,73 @@ fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) {
draw_shape(s, m, opacity * s.opacity, dst);
}
}
Node::Image(im) => draw_image(im, m, opacity * im.opacity, dst),
}
}
/// Draw a raster image: bilinear samples inside the image's (anti-aliased) quadrilateral.
fn draw_image(im: &crate::Image, m: Affine, opacity: f64, dst: &mut Image) {
let (iw, ih) = (im.width as usize, im.height as usize);
if iw == 0 || ih == 0 || im.rgba.len() < iw * ih * 4 || opacity <= 0.0 {
return;
}
let full = m * im.transform;
let inv = full.inverse();
if !inv.as_coeffs().iter().all(|v| v.is_finite()) {
return;
}
let quad = kurbo::Rect::new(0.0, 0.0, iw as f64, ih as f64).to_path(0.01);
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&quad), &mat3(full), dst.width, dst.height, effectcraft_path::FillRule::NonZero);
let w = dst.width as usize;
if w == 0 {
return;
}
let px = |x: usize, y: usize| -> [f32; 4] {
let i = (y * iw + x) * 4;
let a = im.rgba[i + 3] as f32 / 255.0;
[im.rgba[i] as f32 / 255.0 * a, im.rgba[i + 1] as f32 / 255.0 * a, im.rgba[i + 2] as f32 / 255.0 * a, a]
};
// Pixel footprint in image pixels: box-filter heavy minification instead of aliasing.
let c = inv.as_coeffs();
let fx = (c[0] * c[0] + c[1] * c[1]).sqrt();
let fy = (c[2] * c[2] + c[3] * c[3]).sqrt();
let taps = (fx.max(fy).ceil() as usize).clamp(1, 8);
let k = opacity as f32;
dst.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
for (x, d) in row.iter_mut().enumerate() {
let cv = cov.data[y * w + x];
if cv <= 0.0 {
continue;
}
let mut acc = [0.0f32; 4];
for ty in 0..taps {
for tx in 0..taps {
let sx = x as f64 + (tx as f64 + 0.5) / taps as f64;
let sy = y as f64 + (ty as f64 + 0.5) / taps as f64;
let p = inv * Point::new(sx, sy);
let (u, v) = ((p.x - 0.5).clamp(0.0, iw as f64 - 1.0), (p.y - 0.5).clamp(0.0, ih as f64 - 1.0));
let (x0, y0) = (u.floor() as usize, v.floor() as usize);
let (x1, y1) = ((x0 + 1).min(iw - 1), (y0 + 1).min(ih - 1));
let (a, b) = ((u - x0 as f64) as f32, (v - y0 as f64) as f32);
let (p00, p10, p01, p11) = (px(x0, y0), px(x1, y0), px(x0, y1), px(x1, y1));
for i in 0..4 {
acc[i] += (p00[i] * (1.0 - a) + p10[i] * a) * (1.0 - b) + (p01[i] * (1.0 - a) + p11[i] * a) * b;
}
}
}
let n = (taps * taps) as f32;
let s = acc.map(|v| v / n * cv * k);
if s[3] <= 0.0 {
continue;
}
for i in 0..3 {
d[i] = s[i] + d[i] * (1.0 - s[3]);
}
d[3] = s[3] + d[3] * (1.0 - s[3]);
}
});
}
/// `dst = src·k over dst` (premultiplied).
fn over(dst: &mut Image, src: &Image, k: f32) {
dst.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(d, s)| {

View File

@ -85,6 +85,9 @@ pub struct TextStyle {
pub leading: Option<f32>,
/// OpenType features (stylistic sets, figures, fractions, all small caps…).
pub opentype: OpenType,
/// Variable font axis values (tag, user units): shaping (advances through HVAR / gvar
/// phantom points) and outlines use this design-space position.
pub variations: Vec<(String, f32)>,
}
impl Default for TextStyle {
@ -109,6 +112,7 @@ impl Default for TextStyle {
script: Script::Normal,
leading: None,
opentype: OpenType::default(),
variations: Vec::new(),
}
}
}
@ -194,6 +198,10 @@ impl Hash for TextStyle {
self.leading.map(f32::to_bits).hash(h);
(self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h);
self.opentype.hash(h);
for (t, v) in &self.variations {
t.hash(h);
hf(h, *v);
}
}
}
@ -225,6 +233,9 @@ pub struct Glyph {
pub v_scale: f32,
/// Index of the style run the glyph belongs to.
pub run: usize,
/// The run's variable font axis values, interned ([`crate::variable::coords`]; 0 = the
/// default instance): outlines are drawn there.
pub variations: u32,
}
/// One laid-out line.
@ -425,7 +436,13 @@ fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> {
fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize, form: Form) -> Vec<ShapedGlyph> {
let f = fonts::face(face);
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
let shaper = data.shaper(&font).build();
let instance = (!style.variations.is_empty()).then(|| {
harfrust::ShaperInstance::from_variations(
&font,
style.variations.iter().map(|(t, v)| harfrust::Variation { tag: crate::variable::tag_of(t), value: *v }),
)
});
let shaper = data.shaper(&font).instance(instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for &(b, _, sc) in chars {
buf.add(if sc == '\t' { ' ' } else { sc }, b as u32);
@ -819,6 +836,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
h_scale: st.h_scale,
v_scale: st.v_scale,
run: g.run,
variations: crate::variable::intern(&st.variations),
});
pen += g.adv;
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {

View File

@ -70,7 +70,8 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
/// The outline of a laid-out glyph in pixels, origin on its baseline (y down), with its
/// horizontal / vertical scale and faux italic.
pub fn glyph_outline(g: &Glyph) -> BezPath {
let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() };
let units = if g.variations == 0 { outline_units(g.face, g.id) } else { variable::outline_units_at(g.face, g.id, &variable::coords(g.variations)) };
let Some(units) = units else { return BezPath::new() };
glyph_affine(g) * (*units).clone()
}
@ -107,6 +108,8 @@ pub struct CharGlyph {
pub face: FaceId,
pub gid: u32,
pub outline_xf: Affine,
/// The character's variable font axis values (its style's Variable Font Axes).
pub variations: Vec<(String, f32)>,
}
#[derive(Clone, Debug, Default)]
@ -162,6 +165,7 @@ pub fn text_style(s: &CharStyle) -> TextStyle {
},
leading: s.leading.map(|l| l as f32),
opentype: s.opentype,
variations: s.variations.clone(),
}
}
@ -336,6 +340,7 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
face: g.face,
gid: g.id,
outline_xf,
variations: variable::coords(g.variations).to_vec(),
});
}
}

View File

@ -12,6 +12,32 @@ use skrifa::{GlyphId, MetadataProvider, Tag};
use crate::fonts::{self, FaceId};
use crate::{CharGlyph, Pen};
/// Intern a set of axis values (0 for none), so `Copy` glyphs can carry them.
pub fn intern(coords: &[(String, f32)]) -> u32 {
if coords.is_empty() {
return 0;
}
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
if let Some(i) = t.iter().position(|c| c.as_slice() == coords) {
return i as u32 + 1;
}
t.push(Arc::new(coords.to_vec()));
t.len() as u32
}
/// The axis values of an interned id (empty for 0).
pub fn coords(id: u32) -> Arc<Vec<(String, f32)>> {
if id == 0 {
return Arc::new(Vec::new());
}
table().lock().unwrap_or_else(|e| e.into_inner()).get(id as usize - 1).cloned().unwrap_or_default()
}
fn table() -> &'static Mutex<Vec<Arc<Vec<(String, f32)>>>> {
static T: OnceLock<Mutex<Vec<Arc<Vec<(String, f32)>>>>> = OnceLock::new();
T.get_or_init(Default::default)
}
/// One variation axis of a face, in user units.
#[derive(Clone, Debug, PartialEq)]
pub struct FontAxis {
@ -38,7 +64,7 @@ pub fn font_axes(face: FaceId) -> Vec<FontAxis> {
}
/// A tag string as a [`Tag`] (padded with spaces, at most four characters).
fn tag_of(s: &str) -> Tag {
pub(crate) fn tag_of(s: &str) -> Tag {
let mut b = [b' '; 4];
for (i, c) in s.bytes().take(4).enumerate() {
b[i] = c;
@ -78,14 +104,21 @@ pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Opt
}
/// A laid-out character's outline (same placement as [`CharGlyph::path`]) with its face's axes
/// moved by `deltas` (tag, user-unit offset from the axis default).
/// moved by `deltas` (tag, user-unit offset from the character's own axis value — its style's
/// Variable Font Axes setting, else the axis default).
pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<BezPath> {
let axes = font_axes(g.face);
let coords: Vec<(String, f32)> =
deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (a.default + d).clamp(a.min, a.max)))).collect();
let base = |t: &str, d: f32| g.variations.iter().find(|(x, _)| x.trim_end() == t.trim_end()).map(|(_, v)| *v).unwrap_or(d);
let mut coords: Vec<(String, f32)> =
deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (base(&a.tag, a.default) + d).clamp(a.min, a.max)))).collect();
if coords.is_empty() {
return None;
}
for (t, v) in &g.variations {
if !coords.iter().any(|(c, _)| c == t) {
coords.push((t.clone(), *v));
}
}
let units = outline_units_at(g.face, g.gid, &coords)?;
Some(g.outline_xf * (*units).clone())
}
@ -122,4 +155,37 @@ mod tests {
let s = crate::outline_units(face, gid).unwrap();
assert!((d.area() - s.area()).abs() < 1e-3);
}
#[test]
fn style_variations_change_advances_and_outlines() {
assert_eq!(intern(&[]), 0);
let id = intern(&[("wght".into(), 700.0)]);
assert_eq!(intern(&[("wght".into(), 700.0)]), id);
assert_eq!(coords(id).as_slice(), &[("wght".to_string(), 700.0)]);
let Some((face, axes)) = find_variable_face() else {
eprintln!("no variable font installed: skipped");
return;
};
// An axis that changes widths (wdth or wght, else the first that moves).
let a = axes.iter().find(|a| a.tag == "wdth" && a.max > a.min).or_else(|| axes.iter().find(|a| a.tag == "wght" && a.max > a.min)).unwrap_or(&axes[0]);
let info = crate::fonts::face(face).info.clone();
let st = |v: f32| crate::TextStyle {
family: info.family.clone(),
style: info.style.clone(),
size: 100.0,
variations: vec![(a.tag.clone(), v)],
..Default::default()
};
let (lo, hi) = (crate::layout::measure("Hamburgefonts", &st(a.min)), crate::layout::measure("Hamburgefonts", &st(a.max)));
if crate::resolve(&info.family, &info.style).face != face {
eprintln!("variable face not reachable by family/style: skipped");
return;
}
assert!((lo - hi).abs() > 1.0, "{} {}..{}: advances {lo} vs {hi}", a.tag, a.min, a.max);
let ink = |v: f32| {
let l = crate::layout::layout("H", &st(v), &crate::ParagraphStyle::default());
l.glyphs.iter().map(|g| crate::glyph_outline(g).area().abs()).sum::<f64>()
};
assert!((ink(a.min) - ink(a.max)).abs() > 1.0);
}
}

View File

@ -118,6 +118,16 @@ fn is_layered(p: &Value) -> bool {
}
}
/// The import includes a PDF or Illustrator file (which may have several pages).
fn is_pdf(p: &Value) -> bool {
let pdf = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("pdf" | "ai")));
match p.get("paths").or(p.get("path")) {
Some(Value::Array(a)) => a.iter().any(pdf),
Some(v) => pdf(v),
None => false,
}
}
pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let s = &app.session;
let comp = s.active_comp();
@ -136,6 +146,14 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
1,
)],
),
// PDF / Illustrator files: Import Kind and the page.
"file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => (
"Import PDF / Illustrator File".into(),
vec![
Field::choice("importAs", "Import Kind", &[("Footage", json!("footage")), ("Composition", json!("composition"))], 0),
Field::num("page", "Page", 1.0),
],
),
// Puppet tool ▸ Record Options.
"puppet.recordOptions" if !has(p, &["speed", "smoothing", "useDraftDeformation", "showMesh"]) => {
let o = &s.state.puppet;

View File

@ -256,6 +256,29 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.text(pos2(cr.max.x + 6.0, y + 7.0), Align2::LEFT_CENTER, label, Tokens::ui(11.0), t.text_dim);
}
}
// Variable Font Axes: one value per axis of a variable font (`character.axis.<tag>`).
let face = effectcraft_engine::text::resolve(&doc.font, &doc.style).face;
let axes = effectcraft_engine::text::variable::font_axes(face);
if !axes.is_empty() {
y += 26.0;
p.text(pos2(x0, y + 7.0), Align2::LEFT_CENTER, "Variable Font Axes", Tokens::semibold(11.0), t.text_dim);
for (i, a) in axes.iter().enumerate() {
let (col, row) = (i % 2, i / 2);
let fx = x0 + col as f32 * (w / 2.0);
let fy = y + 20.0 + row as f32 * 24.0;
let tag = a.tag.trim_end().to_string();
let v = doc.variations.iter().find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default);
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim);
let (r, nv, _) =
widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::new(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
app.auto.add(&format!("character.axis.{tag}"), r, &a.name);
if let Some(nv) = nv
&& enabled
{
actions.push(json!({"variations": {tag.clone(): nv}, "merge": format!("char-axis-{tag}")}));
}
}
}
match &target {
None => {
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);

View File

@ -26,7 +26,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L2 | `text` | Fonts, shaping, layout, per-glyph geometry, text animators and selectors |
| L2 | `effects` | The effect registry (241 effects) and their CPU implementations |
| L2 | `svg` | SVG import (W3C SVG 1.1/2 static subset: shapes, paths, transforms, `use`, CSS, gradients) and rasterisation at any scale |
| L2 | `pdf` | PDF, PDF-compatible Illustrator (`.ai`) and EPS (PostScript subset) vector footage into the `svg` render tree: paths, fills, strokes, shadings, clipping, optional-content layers |
| L2 | `pdf` | PDF, PDF-compatible Illustrator (`.ai`) and EPS (PostScript subset) vector footage into the `svg` render tree: paths, fills, strokes, shadings, tiling patterns, clipping, text (embedded TrueType / CFF / Type 1 / Type 3 fonts; the bundled `text` fonts for the standard 14), images, blend modes, soft masks, optional-content layers |
| L2 | `model` | 3D models for Advanced 3D: glTF 2.0 (`.gltf`/`.glb`) and OBJ/MTL import (meshes, PBR metallic-roughness materials and textures, node hierarchy, skins, animations), parametric primitives, extruded/bevelled outline meshes and polygon triangulation |
| L2 | `track` | Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation, Lucas–Kanade sub-pixel refinement), confidence, homography/affine/similarity solves |
| L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown |
@ -43,7 +43,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L5 | `automation` | The MCP server, headless or bridged to the running app |
| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) |
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path, track`,
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `pdf → svg, text`, `effects → project, text, path, track`,
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine, script`,
`script → engine`.

View File

@ -35,8 +35,8 @@ Tracking 96%, Effects 85%, Web 65%.
What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands;
approximated effects (Key Cleaner) and Liquify's viewer brush; text in imported
PDF/AI files and Illustrator procset EPS; and more codecs. Face tracking and a true Subspace Warp
approximated effects (Key Cleaner) and Liquify's viewer brush; mesh shadings and JPX images in
imported PDF/AI files and Illustrator procset EPS; and more codecs. Face tracking and a true Subspace Warp
landed in M13.3. ScriptUI (dialogs that block `show()`, palettes, dockable ScriptUI panels, File ▸ Scripts install
and sample scripts), puppet pin recording and the remaining "better than After Effects" items — a
versioned effect plug-in API with sandboxed WebAssembly plug-ins ([plugins.md](plugins.md)),
@ -128,19 +128,19 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit |
| Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control. Left: Opus is CELT-only, Photoshop sequence output, overflow for movies only checks at file creation |
| Audio | 85% | 0.5 | audio to keyframes |
| Import | 94% | 1.0 | text and images inside PDF/AI files, Illustrator EPS relying on Adobe procsets, smart-object perspective/warps, PSD 3D layers (PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) |
| Import | 96% | 0.6 | mesh shadings (types 4–7), JPX / CCITT / JBIG2 images and non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) |
| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' mirrored properties (CMP-7: Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
| Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Text | ≈ 94% | 0.5 | no extruded strokes, variable-axis animation changes outlines but not advances (OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Text | ≈ 95% | 0.4 | no extruded strokes, the Variable Font Axes animator changes outlines but not advances (M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
| Effects | ≈ 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 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 72% | 3.5 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Preview | 74% | 3.2 | wireframes and Advanced 3D compositing on the GPU (Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | ≈ 96% | 0.3 | face tracking is a classical (skin model + feature components + shape model) fitter, not a learned detector: profile views and occluded faces are weak; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
@ -180,8 +180,8 @@ Tate-Chu-Yoko, forced LTR paragraphs, the editing caret on animated / path text
style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and layer ends
(ANM-4; the reference graph existed); viewer ROI resize handles, Pan Behind snapping and 3D
Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry
taper/wave and variable axes don't change advances (Advanced 3D's DOF has iris shapes since
M13.8).
taper/wave and the Variable Font Axes animator doesn't change advances (the Character panel's
axes do since M13.6; Advanced 3D's DOF has iris shapes since M13.8).
## Update: M13.8 Preview panel, Align panel, Advanced 3D completion

View File

@ -61,6 +61,7 @@ const SAME_LAYER: &[(&str, &str)] = &[
("path", "raster"),
("text", "path"),
("pdf", "svg"),
("pdf", "text"),
("effects", "project"),
("effects", "text"),
("effects", "path"),