M13.6: PDF/AI text, images, tiling patterns, blend modes, soft masks, pages; clip groups to masks / Merge Paths

Text with embedded TrueType, CFF (Type1C / CIDFontType0C / OpenType), Type 1 and Type 3 fonts,
standard-14 fallback to the bundled OFL fonts; Flate / DCT / inline / stencil images with SMask,
Indexed and ICC alternates; luminosity / alpha soft masks and the 16 blend modes in the SVG
render tree; tiling patterns; calculator functions; file.import page and Footage::page;
Create Shapes from Vector Layer keeps clips (page clips as layer masks, nested clips as Merge
Paths Intersect).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 05:49:36 -04:00
parent ed46bc0cce
commit 7d944ca494
26 changed files with 4790 additions and 181 deletions

5
Cargo.lock generated
View File

@ -1405,9 +1405,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,6 +193,24 @@ 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"
@ -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

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

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

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,
@ -86,7 +91,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 +102,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

@ -769,6 +769,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 {
@ -800,6 +803,7 @@ impl Default for Footage {
linear_light: false,
data: None,
layer: None,
page: 0,
}
}
}

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

@ -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),
],
),
"layer.setTransform" if !has(p, &["value"]) => {
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));

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

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