Merge branch 'worktree-agent-a75983ccef1ea55ee'
This commit is contained in:
commit
95766755f3
|
|
@ -137,6 +137,7 @@ pub struct PluginManifest {
|
|||
impl PluginManifest {
|
||||
/// Check the manifest against this API version.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
const MAX_PARAMS: usize = 256;
|
||||
if self.api != PLUGIN_API_VERSION {
|
||||
return Err(format!("plug-in `{}` targets plug-in API {} but this build implements API {PLUGIN_API_VERSION}", self.id, self.api));
|
||||
}
|
||||
|
|
@ -147,6 +148,9 @@ impl PluginManifest {
|
|||
if self.name.trim().is_empty() || self.category.trim().is_empty() {
|
||||
return Err(format!("plug-in `{}` needs a name and a category", self.id));
|
||||
}
|
||||
if self.params.len() > MAX_PARAMS {
|
||||
return Err(format!("plug-in `{}` has {} parameters (at most {MAX_PARAMS})", self.id, self.params.len()));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for p in &self.params {
|
||||
if p.id.is_empty() || !p.id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || !seen.insert(p.id.as_str()) {
|
||||
|
|
@ -157,6 +161,12 @@ impl PluginManifest {
|
|||
{
|
||||
return Err(format!("plug-in `{}`: popup `{}` needs options and a default index in range", self.id, p.id));
|
||||
}
|
||||
if let PluginParamKind::Slider { default, min, max, slider_min, slider_max, .. } = &p.kind {
|
||||
let (lo, hi) = (slider_min.unwrap_or(*min), slider_max.unwrap_or(*max));
|
||||
if [*default, *min, *max, lo, hi].iter().any(|v| !v.is_finite()) || min > max || lo > hi || default < min || default > max {
|
||||
return Err(format!("plug-in `{}`: slider `{}` needs min ≤ default ≤ max (and sliderMin ≤ sliderMax)", self.id, p.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,14 +36,56 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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| {
|
||||
let l = crate::vector::shapes_from_vector(proj, &comp, &src, &doc);
|
||||
let id = l.id;
|
||||
insert_above(proj, cid, lid, l, true)?;
|
||||
st.selected_layers = vec![id];
|
||||
Ok(id)
|
||||
// Images become footage layers (PNG files written next to the source and
|
||||
// imported), parented to the shape layer below it.
|
||||
let (images, masks) = crate::vector::vector_images(&doc);
|
||||
if masks > 0 {
|
||||
skipped.push(format!("{}: {masks} soft mask(s) not converted (shape layers have no soft masks)", src.name));
|
||||
}
|
||||
let mut image_items = vec![];
|
||||
for (i, img) in images.iter().enumerate() {
|
||||
match image_footage(s, &f.path, i + 1, img) {
|
||||
Ok(item) => image_items.push((item, i)),
|
||||
Err(e) => skipped.push(format!("{}: image {} not converted ({e})", src.name, i + 1)),
|
||||
}
|
||||
if img.clipped {
|
||||
skipped.push(format!("{}: image {} is clipped or masked; its footage layer is not", src.name, i + 1));
|
||||
}
|
||||
}
|
||||
let mut notes = vec![];
|
||||
let ids = s.edit("Create Shapes from Vector Layer", None, |proj, st| {
|
||||
// Without images: one shape layer. With images: the shapes between them
|
||||
// in layers of their own, so the stack keeps the document's paint order.
|
||||
let segments = if image_items.is_empty() { vec![doc.clone()] } else { crate::vector::split_at_images(&doc) };
|
||||
let top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc));
|
||||
// Top to bottom: the last segment, image n, segment n − 1, … image 1, segment 0.
|
||||
let mut stack = vec![];
|
||||
for k in (0..segments.len().saturating_sub(1)).rev() {
|
||||
if let Some((item, i)) = image_items.iter().find(|(_, i)| *i == k) {
|
||||
let (il, note) = crate::vector::image_layer(proj, &comp, *item, &images[*i], &top);
|
||||
notes.extend(note);
|
||||
stack.push(il);
|
||||
}
|
||||
let seg = &segments[k];
|
||||
if seg.root.children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut sl = crate::vector::shapes_from_vector(proj, &comp, &src, seg);
|
||||
sl.name = format!("{} {}", top.name, k + 1);
|
||||
stack.push(sl);
|
||||
}
|
||||
let mut ids = vec![top.id];
|
||||
ids.extend(stack.iter().filter(|l| matches!(l.source, LayerSource::Shape)).map(|l| l.id));
|
||||
// Each directly above the source: the top layer first.
|
||||
insert_above(proj, cid, lid, top, true)?;
|
||||
for l in stack {
|
||||
insert_above(proj, cid, lid, l, true)?;
|
||||
}
|
||||
st.selected_layers = vec![ids[0]];
|
||||
Ok(ids)
|
||||
})?;
|
||||
created.push(id.0);
|
||||
skipped.extend(notes);
|
||||
created.extend(ids.iter().map(|i| i.0));
|
||||
}
|
||||
"shapesFromText" => {
|
||||
if !matches!(src.source, LayerSource::Text) {
|
||||
|
|
@ -118,6 +160,25 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"layers": created, "skipped": skipped}))
|
||||
}
|
||||
|
||||
/// An image of a vector file written as `<file> Image <n>.png` next to it and imported: the
|
||||
/// footage item.
|
||||
fn image_footage(s: &mut Session, source: &str, n: usize, img: &crate::vector::VectorImage) -> Result<effectcraft_project::ItemId> {
|
||||
let src = std::path::Path::new(source);
|
||||
let stem = src.file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_else(|| "Vector".into());
|
||||
let name = format!("{stem} Image {n}.png");
|
||||
let path = match src.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
Some(dir) => dir.join(&name).to_string_lossy().into_owned(),
|
||||
None => name,
|
||||
};
|
||||
let png = super::comp_more::encode_png(&img.rgba, img.width, img.height).map_err(EngineError::Other)?;
|
||||
s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
let r = super::file::import(s, &json!({"paths": [path]}))?;
|
||||
if let Some(e) = r["errors"].as_array().and_then(|e| e.first()) {
|
||||
return Err(EngineError::Other(e.as_str().unwrap_or("import failed").to_string()));
|
||||
}
|
||||
r["items"].get(0).and_then(Value::as_u64).map(effectcraft_project::ItemId).ok_or_else(|| EngineError::Other(format!("{path} was not imported")))
|
||||
}
|
||||
|
||||
/// Insert `l` directly above layer `below`; optionally switch the source's video off (as After
|
||||
/// Effects does after converting).
|
||||
fn insert_above(
|
||||
|
|
|
|||
|
|
@ -314,13 +314,32 @@ fn variable_font_axes_animator() {
|
|||
let l = comp.layer(LayerId(t)).unwrap();
|
||||
effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum::<f64>()
|
||||
};
|
||||
// The distance between the two H's left edges: the H advance.
|
||||
let gap = |s: &Session| {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
|
||||
let l = comp.layer(LayerId(t)).unwrap();
|
||||
let x0: Vec<f64> = effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::bounding_box(p).x0).collect();
|
||||
x0[1] - x0[0]
|
||||
};
|
||||
let before = area(&s);
|
||||
let gap_before = gap(&s);
|
||||
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap().clone();
|
||||
let pg = l.props.find_group(anim).unwrap().sub("properties").unwrap().props().next().unwrap().uid;
|
||||
let range = (axis.max - axis.min) as f64;
|
||||
s.execute("prop.set", json!({"layer": t, "prop": pg, "value": range})).unwrap();
|
||||
let after = area(&s);
|
||||
assert!((after - before).abs() > 1.0, "{} axis moved the outline: {before} → {after}", axis.tag);
|
||||
// Advances follow the axis too (the text is re-spaced, not only redrawn).
|
||||
let a = axes.iter().find(|a| a.tag == tag).unwrap();
|
||||
let f = effectcraft_text::fonts::face(face);
|
||||
let gid = f.glyph('H').unwrap();
|
||||
let upem = f.units_per_em() as f64;
|
||||
let units = |v: f32| effectcraft_text::variable::advance_units_at(face, gid, &[(a.tag.clone(), v)]).unwrap() as f64;
|
||||
let want = (units((a.default + range as f32).min(a.max)) - units(a.default)) * 60.0 / upem;
|
||||
let moved = gap(&s) - gap_before;
|
||||
assert!((moved - want).abs() < 0.5, "{} axis: advance change {moved}, expected {want}", a.tag);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!((area(&s) - before).abs() < 1e-6);
|
||||
let _ = info;
|
||||
|
|
|
|||
|
|
@ -162,7 +162,8 @@ fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[
|
|||
/// 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.
|
||||
/// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see
|
||||
/// [`split_at_images`]); 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, clips: &[BezPath]) -> Vec<PropGroup> {
|
||||
let mut out = vec![];
|
||||
|
|
@ -340,6 +341,131 @@ fn like_source(dst: &mut Layer, src: &Layer) {
|
|||
dst.switches.three_d = src.switches.three_d;
|
||||
}
|
||||
|
||||
/// A raster image of a vector document (Create Shapes from Vector Layer makes it a footage
|
||||
/// layer: shape layers have no images).
|
||||
pub struct VectorImage {
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Straight RGBA8.
|
||||
pub rgba: std::sync::Arc<Vec<u8>>,
|
||||
/// Image pixels → document pixels.
|
||||
pub transform: Affine,
|
||||
pub opacity: f64,
|
||||
/// Inside a clipping group or under a soft mask (not carried over).
|
||||
pub clipped: bool,
|
||||
}
|
||||
|
||||
/// The document's images, bottom first, and how many soft-masked groups it has.
|
||||
pub fn vector_images(doc: &Doc) -> (Vec<VectorImage>, usize) {
|
||||
fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec<VectorImage>, masks: &mut usize) {
|
||||
let m = m * g.transform;
|
||||
let opacity = opacity * g.opacity;
|
||||
let clipped = clipped || !g.clip.is_empty() || g.mask.is_some();
|
||||
if g.mask.is_some() {
|
||||
*masks += 1;
|
||||
}
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Group(sub) => walk(sub, m, opacity, clipped, out, masks),
|
||||
Node::Image(im) => out.push(VectorImage {
|
||||
name: im.name.clone(),
|
||||
width: im.width,
|
||||
height: im.height,
|
||||
rgba: im.rgba.clone(),
|
||||
transform: m * im.transform,
|
||||
opacity: opacity * im.opacity,
|
||||
clipped,
|
||||
}),
|
||||
Node::Shape(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, doc) = vector_clip_masks(doc);
|
||||
let mut out = vec![];
|
||||
let mut masks = 0;
|
||||
walk(&doc.root, Affine::IDENTITY, 1.0, false, &mut out, &mut masks);
|
||||
(out, masks)
|
||||
}
|
||||
|
||||
/// The document split at its images (paint order): segment `k` keeps the shapes drawn between
|
||||
/// image `k − 1` and image `k` (groups, clips and blend modes kept, images dropped), so shape
|
||||
/// layers and image footage layers can stack in the document's order. One more segment than
|
||||
/// images.
|
||||
pub fn split_at_images(doc: &Doc) -> Vec<Doc> {
|
||||
fn count(g: &effectcraft_svg::Group) -> usize {
|
||||
g.children
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
Node::Group(s) => count(s),
|
||||
Node::Image(_) => 1,
|
||||
Node::Shape(_) => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
/// Keep the shapes between images `seg − 1` and `seg`; `seen` counts images passed.
|
||||
fn prune(g: &effectcraft_svg::Group, seg: usize, seen: &mut usize) -> effectcraft_svg::Group {
|
||||
let mut out = g.clone();
|
||||
out.children = vec![];
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Image(_) => *seen += 1,
|
||||
Node::Shape(_) => {
|
||||
if *seen == seg {
|
||||
out.children.push(c.clone());
|
||||
}
|
||||
}
|
||||
Node::Group(s) => {
|
||||
let p = prune(s, seg, seen);
|
||||
if !p.children.is_empty() {
|
||||
out.children.push(Node::Group(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let n = count(&doc.root);
|
||||
(0..=n)
|
||||
.map(|seg| {
|
||||
let mut d = doc.clone();
|
||||
d.root = prune(&doc.root, seg, &mut 0);
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A footage layer for an image of a vector document, parented to `parent` (the shape layer,
|
||||
/// whose layer space is document pixels) and placed by the image's transform (anchor at its
|
||||
/// top-left corner; position, rotation and scale from the matrix). Returns a note when the
|
||||
/// placement has a skew, which layer transforms cannot express.
|
||||
pub fn image_layer(proj: &mut Project, comp: &Comp, item: ItemId, img: &VectorImage, parent: &Layer) -> (Layer, Option<String>) {
|
||||
let mut l = build::layer(proj, comp, &img.name, LayerSource::Footage { item }, (img.width, img.height), None);
|
||||
let c = img.transform.as_coeffs();
|
||||
let theta = c[1].atan2(c[0]);
|
||||
let (sn, cs) = theta.sin_cos();
|
||||
let sx = (c[0] * c[0] + c[1] * c[1]).sqrt();
|
||||
let sy = -c[2] * sn + c[3] * cs;
|
||||
let skew = c[2] * cs + c[3] * sn;
|
||||
let set = |l: &mut Layer, path: &str, v: Value| {
|
||||
if let Some(p) = l.props.prop_mut(path) {
|
||||
p.value = v;
|
||||
}
|
||||
};
|
||||
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
|
||||
set(&mut l, "transform/position", Value::Vec3([c[4], c[5], 0.0]));
|
||||
set(&mut l, "transform/scale", Value::Vec3([sx * 100.0, sy * 100.0, 100.0]));
|
||||
set(&mut l, "transform/rotation", Value::Scalar(theta.to_degrees()));
|
||||
set(&mut l, "transform/opacity", Value::Scalar((img.opacity * 100.0).clamp(0.0, 100.0)));
|
||||
l.start_time = parent.start_time;
|
||||
l.in_point = parent.in_point;
|
||||
l.out_point = parent.out_point;
|
||||
l.stretch = parent.stretch;
|
||||
l.parent = Some(parent.id);
|
||||
let note = (skew.abs() > 1e-3 * sy.abs().max(1e-9)).then(|| format!("{}: skewed image placed without its skew", img.name));
|
||||
(l, note)
|
||||
}
|
||||
|
||||
/// 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`]).
|
||||
|
|
|
|||
|
|
@ -104,3 +104,58 @@ fn example_wasm_plugin_loads_and_renders() {
|
|||
let list = s.execute("effect.plugins.list", json!({})).unwrap();
|
||||
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["source"].as_str().unwrap_or("").ends_with(".wasm")));
|
||||
}
|
||||
|
||||
/// A parameterless plug-in module (WebAssembly text): `render` is `ec_render`'s body.
|
||||
fn wat_plugin(id: &str, api: i32, render: &str) -> String {
|
||||
let manifest = format!(r#"{{"api":1,"id":"{id}","name":"Host {id}","category":"Test Plug-ins","version":"1","params":[]}}"#);
|
||||
let len = manifest.len();
|
||||
let escaped = manifest.replace('"', "\\\"");
|
||||
format!(
|
||||
r#"(module
|
||||
(memory (export "memory") 2)
|
||||
(data (i32.const 16) "{escaped}")
|
||||
(func (export "ec_api_version") (result i32) i32.const {api})
|
||||
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
|
||||
(func (export "ec_manifest_len") (result i32) i32.const {len})
|
||||
(func (export "ec_alloc") (param i32) (result i32) i32.const 1024)
|
||||
(func (export "ec_render") (param i32 i32 i32 i32 i32 f64 f64) (result i32) {render}))"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_loading_reports_bad_modules_and_survives_failing_renders() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-host-plugins-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let files = [
|
||||
("a-good.wasm", wat_plugin("org.test.host.good", 1, "i32.const 0").into_bytes()),
|
||||
("b-garbage.wasm", b"\0asm\x01\0\0\0garbage".to_vec()),
|
||||
("c-v2.wasm", wat_plugin("org.test.host.v2", 2, "i32.const 0").into_bytes()),
|
||||
("d-empty.wasm", vec![]),
|
||||
("e-trap.wasm", wat_plugin("org.test.host.trap", 1, "unreachable").into_bytes()),
|
||||
("notes.txt", b"not a plug-in".to_vec()),
|
||||
];
|
||||
for (name, bytes) in &files {
|
||||
std::fs::write(dir.join(name), bytes).unwrap();
|
||||
}
|
||||
let (mut s, l) = setup();
|
||||
let r = s.execute("effect.plugins.load", json!({"folder": dir.to_string_lossy()})).unwrap();
|
||||
let ids: Vec<&str> = r["loaded"].as_array().unwrap().iter().filter_map(|v| v["id"].as_str()).collect();
|
||||
assert_eq!(ids, ["org.test.host.good", "org.test.host.trap"], "{r}");
|
||||
let errs: Vec<String> = r["errors"].as_array().unwrap().iter().map(|e| e["path"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(errs.len(), 3, "{r}");
|
||||
assert!(errs.iter().all(|p| !p.ends_with(".txt")));
|
||||
assert!(r["errors"].to_string().contains("API 2"), "{r}");
|
||||
// A single file: errors come back as command errors; loading twice is refused.
|
||||
let good = dir.join("a-good.wasm").to_string_lossy().to_string();
|
||||
assert!(s.execute("effect.plugins.load", json!({"path": good})).unwrap_err().to_string().contains("already registered"));
|
||||
assert!(s.execute("effect.plugins.load", json!({"path": dir.join("missing.wasm").to_string_lossy()})).is_err());
|
||||
assert!(s.execute("effect.plugins.load", json!({"folder": dir.join("missing").to_string_lossy()})).is_err());
|
||||
assert!(s.execute("effect.plugins.load", json!({})).is_err());
|
||||
// A plug-in that traps on every frame leaves the layer as it was.
|
||||
let before = px(&s);
|
||||
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.trap"})).unwrap();
|
||||
assert_eq!(px(&s), before);
|
||||
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.good"})).unwrap();
|
||||
assert_eq!(px(&s), before);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -461,3 +461,64 @@ fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() {
|
|||
assert!(top[0] > 0.5 || top[1] > 0.5, "top centre covered {top:?}");
|
||||
assert!(img.get(21, 11)[0] < 0.05 && img.get(21, 11)[1] < 0.05, "corner region empty {:?}", img.get(21, 11));
|
||||
}
|
||||
|
||||
/// A PDF with two placed images (one turned a quarter) between a background and a shape.
|
||||
fn image_pdf() -> Vec<u8> {
|
||||
let content = "0.9 0.9 0.9 rg 0 0 120 80 re f\nq 60 0 0 30 10 40 cm /Im Do Q\nq 0 40 -20 0 110 10 cm /Im Do Q\n0 0 1 rg 20 5 30 20 re f\n";
|
||||
// 8×8 pixels in quadrants: red, green / white, black.
|
||||
let quad = |x: usize, y: usize| -> [u8; 3] { [[[255, 0, 0], [0, 255, 0]], [[255, 255, 255], [0, 0, 0]]][y / 4][x / 4] };
|
||||
let px: Vec<u8> = (0..64).flat_map(|i| quad(i % 8, i / 8)).collect();
|
||||
effectcraft_pdf::write::pdf(
|
||||
&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(), None),
|
||||
(4, "<< >>".into(), Some(content.as_bytes().to_vec())),
|
||||
(5, "<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(px)),
|
||||
],
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_from_vector_layer_keeps_images_as_footage_layers() {
|
||||
let bytes = image_pdf();
|
||||
let doc = effectcraft_pdf::parse(&bytes).unwrap();
|
||||
let (w, h) = doc.pixel_size();
|
||||
let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0);
|
||||
let path = tmp("images.pdf");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let item = s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "I", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let t = effectcraft_time::Tick::ZERO;
|
||||
let foot = s.render(cid, t, RenderOpts::default());
|
||||
assert!(mean_diff(&foot, &direct) < 1.0 / 255.0);
|
||||
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
|
||||
let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
let sid = r["layers"][0].as_u64().unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
// In the document's paint order: the shape drawn last, the two images (the later one
|
||||
// higher), the background drawn first, then the hidden source.
|
||||
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["images.pdf Outlines", "Image", "Image", "images.pdf Outlines 1", "images.pdf"]);
|
||||
assert_eq!(c.layers[0].id.0, sid);
|
||||
assert_eq!(r["layers"].as_array().unwrap().len(), 2);
|
||||
for l in &c.layers[1..3] {
|
||||
assert_eq!(l.parent.map(|p| p.0), Some(sid), "{}", l.name);
|
||||
assert!(matches!(l.source, effectcraft_project::LayerSource::Footage { .. }));
|
||||
}
|
||||
assert!(!c.layers[4].switches.video);
|
||||
let shapes = s.render(cid, t, RenderOpts::default());
|
||||
let d = mean_diff(&shapes, &direct);
|
||||
assert!(d < 0.02, "converted vs page: mean diff {d}");
|
||||
// Image pixels in place: quarters of the upright image and of the turned one, and the
|
||||
// shape over the background.
|
||||
for (x, y) in [(25, 17), (55, 17), (25, 32), (55, 32), (95, 60), (95, 40), (105, 40), (30, 65)] {
|
||||
let (a, b) = (shapes.get(x, y), direct.get(x, y));
|
||||
assert!((0..3).all(|k| (a[k] - b[k]).abs() < 0.05), "({x}, {y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,17 +12,22 @@ fonts, images, patterns, transparency (blend modes, soft masks) and optional con
|
|||
**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).
|
||||
PostScript File Format Specification 3.0** for EPS; **ITU-T T.4** (07/2003) and **ITU-T T.6**
|
||||
(11/1988) for CCITT fax images. 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/codec code was read; test files (including the CFF and Type 1 fonts and the
|
||||
CCITT data) are generated in code ([`write`](src/write.rs) and the tests). One CCITT fixture was
|
||||
produced once by libtiff's `tiffcp` (an external encoder used only as a fixture generator, like
|
||||
ffmpeg elsewhere) to check the code tables against an independent implementation.
|
||||
|
||||
## What is read
|
||||
|
||||
- **Structure:** indirect objects found by scanning (damaged or incremental cross-reference
|
||||
sections do not matter), object streams, the page tree with inherited resources, MediaBox /
|
||||
CropBox and `/Rotate`. Filters: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
|
||||
RunLength. Encrypted files are refused.
|
||||
RunLength, CCITTFax (Group 3 one- and two-dimensional, Group 4; `K`, `Columns`, `Rows`,
|
||||
`EndOfLine`, `EncodedByteAlign`, `EndOfBlock`, `BlackIs1`; damaged data keeps the rows
|
||||
decoded before the damage). Encrypted files are refused.
|
||||
- **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`,
|
||||
`LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd),
|
||||
clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by
|
||||
|
|
@ -39,7 +44,15 @@ are generated in code ([`write`](src/write.rs) and the tests).
|
|||
construction including `arc`/`arcn` and relative operators, `fill`/`eofill`/`stroke`,
|
||||
`clip`/`eoclip`, `rectfill`/`rectstroke`/`rectclip`, gray / RGB / CMYK / HSB colour. DOS EPS
|
||||
binary headers and `%%HiResBoundingBox` / `%%BoundingBox` are honoured; `%%BeginData` /
|
||||
`%%BeginBinary` / preview sections are skipped.
|
||||
`%%BeginBinary` / preview sections are skipped. **Text:** `findfont` / `findresource`,
|
||||
`scalefont`, `makefont`, `selectfont`, `setfont`, `currentfont`, `definefont` (re-encoded
|
||||
copies made with `forall` and a new `/Encoding`), `StandardEncoding` / `ISOLatin1Encoding`,
|
||||
`show`, `ashow`, `widthshow`, `awidthshow`, `xshow` / `yshow` / `xyshow`, `kshow` / `cshow`
|
||||
(the procedure is not run), `glyphshow`, `charpath` (glyph outlines join the path, e.g. for
|
||||
clipping) and `stringwidth`. Fonts embedded in the file — Type 1 programs (`… currentfile
|
||||
eexec … cleartomark`, binary or hexadecimal) and CFF font sets (`StartData`) — are found by
|
||||
name before the program runs; other fonts are drawn with the bundled fonts as for PDF. Each
|
||||
`show` becomes one compound shape named "Text: …".
|
||||
|
||||
- **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 /
|
||||
|
|
@ -47,8 +60,9 @@ are generated in code ([`write`](src/write.rs) and the tests).
|
|||
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) /
|
||||
with Identity-H / Identity-V and embedded CMaps (`cidrange` / `cidchar`, `/WMode`), `/W` and
|
||||
`/DW` widths, vertical metrics (`/W2`, `/DW2`: the vertical displacement and the position
|
||||
vector from the horizontal to the vertical origin) 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
|
||||
|
|
@ -64,29 +78,54 @@ are generated in code ([`write`](src/write.rs) and the tests).
|
|||
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`.
|
||||
tree, open until the state changes or `Q`: what is painted while a soft mask is set is
|
||||
rendered to an offscreen buffer and the mask applies to that combined result. Transparency
|
||||
group XObjects (§11.6.6) composite as one object: the current alpha, blend mode and soft mask
|
||||
apply to the group's result, and its contents start from the initial alpha. Isolated groups
|
||||
(`/I true`) composite over a transparent backdrop; non-isolated groups over the page, with
|
||||
the backdrop's contribution removed again (§11.4.8) so group alpha and masks apply only to
|
||||
the group; knockout groups (`/K true`) composite each child with the group's initial
|
||||
backdrop. Clipping groups are not transparency groups: blend modes inside them reach the
|
||||
backdrop.
|
||||
- **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.
|
||||
- **Shadings** (§8.7.4.5): axial and radial shadings become gradients. Function-based shadings
|
||||
(type 1, `Domain` and `Matrix`) and the mesh shadings — free-form (type 4, edge flags 0–2)
|
||||
and lattice-form (type 5) Gouraud-shaded triangle meshes, Coons patch meshes (type 6) and
|
||||
tensor-product patch meshes (type 7, flags 0–3 sharing the previous patch's edges) with
|
||||
`BitsPerCoordinate` / `BitsPerComponent` / `BitsPerFlag` / `Decode` and an optional
|
||||
parametric `Function` — are rasterised into an image node in page space (up to 4 pixels per
|
||||
point, at most 1024 pixels a side, 2×2 supersampled): patches are tessellated into triangles
|
||||
(up to 48×48 cells), colours (or `t`) interpolated across each triangle. They paint through
|
||||
`sh` (their own extent only) and as shading-pattern fills (clipped to the path).
|
||||
- **Functions:** sampled (one or several inputs, multilinear interpolation), 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
|
||||
|
||||
- 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`.
|
||||
- JPX and JBIG2 images, predefined CJK CMaps other than Identity-H / Identity-V, and transfer
|
||||
functions (`/TR`) are not read. JBIG2 is deferred. Predefined CMaps (`UniGB-UCS2-H`,
|
||||
`90ms-RKSJ-H` …) need the CMap data, and there is no permissively licensed pure-Rust source
|
||||
of it (Adobe's CMap resources are Adobe assets, which this project does not ship); text in
|
||||
such fonts is reported in `Doc::skipped`. Mesh shadings are raster images in the render tree
|
||||
(sharp to about 4× the document size). Knockout uses each child's coverage as its shape (a
|
||||
child with constant opacity knocks out fully where it covers). 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.
|
||||
EPS text is not drawn.
|
||||
EPS images are skipped; Type 3 (procedure) fonts defined in EPS files draw nothing (reported).
|
||||
- **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).
|
||||
modes become the groups' Blend Mode. Images become footage layers: each is written as
|
||||
`<file> Image <n>.png` next to the source and imported, placed by its matrix (anchor at its
|
||||
top-left corner; position, rotation and scale — a skew is reported) and parented to the shape
|
||||
layer; the shapes drawn between images go to shape layers of their own (`<name> Outlines
|
||||
<n>`), so the layer stack keeps the document's paint order. Soft masks, and the clips and
|
||||
masks of images, are not converted (the command's `skipped` list says so).
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ impl Builder {
|
|||
pub fn clip(&mut self, path: BezPath, rule: FillRule) {
|
||||
let mut g = group("Clip Group");
|
||||
g.clip.push((path, rule));
|
||||
// Clipping is not a transparency group: blend modes inside reach the backdrop.
|
||||
g.isolated = false;
|
||||
self.stack.push((g, Kind::Clip));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,798 @@
|
|||
//! `CCITTFaxDecode` (ISO 32000-1 §7.4.6): ITU-T T.4 Group 3 one-dimensional (Modified
|
||||
//! Huffman) and two-dimensional (Modified READ) coding and ITU-T T.6 Group 4 (Modified Modified
|
||||
//! READ), implemented from the ITU-T recommendations. The result is 1-bit rows, `0` black
|
||||
//! unless `BlackIs1`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The decoding parameters (`/DecodeParms`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Params {
|
||||
/// `< 0` Group 4, `0` Group 3 1-D, `> 0` Group 3 mixed 1-D / 2-D.
|
||||
pub k: i64,
|
||||
pub end_of_line: bool,
|
||||
pub byte_align: bool,
|
||||
pub columns: usize,
|
||||
/// `0`: as many rows as the data holds.
|
||||
pub rows: usize,
|
||||
pub end_of_block: bool,
|
||||
pub black_is_1: bool,
|
||||
}
|
||||
|
||||
impl Default for Params {
|
||||
fn default() -> Self {
|
||||
Params { k: 0, end_of_line: false, byte_align: false, columns: 1728, rows: 0, end_of_block: true, black_is_1: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// White terminating codes (T.4 Table 2), run lengths 0–63, as (code, length).
|
||||
const WHITE_TERM: [(u16, u8); 64] = [
|
||||
(0b00110101, 8),
|
||||
(0b000111, 6),
|
||||
(0b0111, 4),
|
||||
(0b1000, 4),
|
||||
(0b1011, 4),
|
||||
(0b1100, 4),
|
||||
(0b1110, 4),
|
||||
(0b1111, 4),
|
||||
(0b10011, 5),
|
||||
(0b10100, 5),
|
||||
(0b00111, 5),
|
||||
(0b01000, 5),
|
||||
(0b001000, 6),
|
||||
(0b000011, 6),
|
||||
(0b110100, 6),
|
||||
(0b110101, 6),
|
||||
(0b101010, 6),
|
||||
(0b101011, 6),
|
||||
(0b0100111, 7),
|
||||
(0b0001100, 7),
|
||||
(0b0001000, 7),
|
||||
(0b0010111, 7),
|
||||
(0b0000011, 7),
|
||||
(0b0000100, 7),
|
||||
(0b0101000, 7),
|
||||
(0b0101011, 7),
|
||||
(0b0010011, 7),
|
||||
(0b0100100, 7),
|
||||
(0b0011000, 7),
|
||||
(0b00000010, 8),
|
||||
(0b00000011, 8),
|
||||
(0b00011010, 8),
|
||||
(0b00011011, 8),
|
||||
(0b00010010, 8),
|
||||
(0b00010011, 8),
|
||||
(0b00010100, 8),
|
||||
(0b00010101, 8),
|
||||
(0b00010110, 8),
|
||||
(0b00010111, 8),
|
||||
(0b00101000, 8),
|
||||
(0b00101001, 8),
|
||||
(0b00101010, 8),
|
||||
(0b00101011, 8),
|
||||
(0b00101100, 8),
|
||||
(0b00101101, 8),
|
||||
(0b00000100, 8),
|
||||
(0b00000101, 8),
|
||||
(0b00001010, 8),
|
||||
(0b00001011, 8),
|
||||
(0b01010010, 8),
|
||||
(0b01010011, 8),
|
||||
(0b01010100, 8),
|
||||
(0b01010101, 8),
|
||||
(0b00100100, 8),
|
||||
(0b00100101, 8),
|
||||
(0b01011000, 8),
|
||||
(0b01011001, 8),
|
||||
(0b01011010, 8),
|
||||
(0b01011011, 8),
|
||||
(0b01001010, 8),
|
||||
(0b01001011, 8),
|
||||
(0b00110010, 8),
|
||||
(0b00110011, 8),
|
||||
(0b00110100, 8),
|
||||
];
|
||||
|
||||
/// White make-up codes (T.4 Table 3), run lengths 64, 128, … 1728.
|
||||
const WHITE_MAKEUP: [(u16, u8); 27] = [
|
||||
(0b11011, 5),
|
||||
(0b10010, 5),
|
||||
(0b010111, 6),
|
||||
(0b0110111, 7),
|
||||
(0b00110110, 8),
|
||||
(0b00110111, 8),
|
||||
(0b01100100, 8),
|
||||
(0b01100101, 8),
|
||||
(0b01101000, 8),
|
||||
(0b01100111, 8),
|
||||
(0b011001100, 9),
|
||||
(0b011001101, 9),
|
||||
(0b011010010, 9),
|
||||
(0b011010011, 9),
|
||||
(0b011010100, 9),
|
||||
(0b011010101, 9),
|
||||
(0b011010110, 9),
|
||||
(0b011010111, 9),
|
||||
(0b011011000, 9),
|
||||
(0b011011001, 9),
|
||||
(0b011011010, 9),
|
||||
(0b011011011, 9),
|
||||
(0b010011000, 9),
|
||||
(0b010011001, 9),
|
||||
(0b010011010, 9),
|
||||
(0b011000, 6),
|
||||
(0b010011011, 9),
|
||||
];
|
||||
|
||||
/// Black terminating codes (T.4 Table 2), run lengths 0–63.
|
||||
const BLACK_TERM: [(u16, u8); 64] = [
|
||||
(0b0000110111, 10),
|
||||
(0b010, 3),
|
||||
(0b11, 2),
|
||||
(0b10, 2),
|
||||
(0b011, 3),
|
||||
(0b0011, 4),
|
||||
(0b0010, 4),
|
||||
(0b00011, 5),
|
||||
(0b000101, 6),
|
||||
(0b000100, 6),
|
||||
(0b0000100, 7),
|
||||
(0b0000101, 7),
|
||||
(0b0000111, 7),
|
||||
(0b00000100, 8),
|
||||
(0b00000111, 8),
|
||||
(0b000011000, 9),
|
||||
(0b0000010111, 10),
|
||||
(0b0000011000, 10),
|
||||
(0b0000001000, 10),
|
||||
(0b00001100111, 11),
|
||||
(0b00001101000, 11),
|
||||
(0b00001101100, 11),
|
||||
(0b00000110111, 11),
|
||||
(0b00000101000, 11),
|
||||
(0b00000010111, 11),
|
||||
(0b00000011000, 11),
|
||||
(0b000011001010, 12),
|
||||
(0b000011001011, 12),
|
||||
(0b000011001100, 12),
|
||||
(0b000011001101, 12),
|
||||
(0b000001101000, 12),
|
||||
(0b000001101001, 12),
|
||||
(0b000001101010, 12),
|
||||
(0b000001101011, 12),
|
||||
(0b000011010010, 12),
|
||||
(0b000011010011, 12),
|
||||
(0b000011010100, 12),
|
||||
(0b000011010101, 12),
|
||||
(0b000011010110, 12),
|
||||
(0b000011010111, 12),
|
||||
(0b000001101100, 12),
|
||||
(0b000001101101, 12),
|
||||
(0b000011011010, 12),
|
||||
(0b000011011011, 12),
|
||||
(0b000001010100, 12),
|
||||
(0b000001010101, 12),
|
||||
(0b000001010110, 12),
|
||||
(0b000001010111, 12),
|
||||
(0b000001100100, 12),
|
||||
(0b000001100101, 12),
|
||||
(0b000001010010, 12),
|
||||
(0b000001010011, 12),
|
||||
(0b000000100100, 12),
|
||||
(0b000000110111, 12),
|
||||
(0b000000111000, 12),
|
||||
(0b000000100111, 12),
|
||||
(0b000000101000, 12),
|
||||
(0b000001011000, 12),
|
||||
(0b000001011001, 12),
|
||||
(0b000000101011, 12),
|
||||
(0b000000101100, 12),
|
||||
(0b000001011010, 12),
|
||||
(0b000001100110, 12),
|
||||
(0b000001100111, 12),
|
||||
];
|
||||
|
||||
/// Black make-up codes (T.4 Table 3), run lengths 64, 128, … 1728.
|
||||
const BLACK_MAKEUP: [(u16, u8); 27] = [
|
||||
(0b0000001111, 10),
|
||||
(0b000011001000, 12),
|
||||
(0b000011001001, 12),
|
||||
(0b000001011011, 12),
|
||||
(0b000000110011, 12),
|
||||
(0b000000110100, 12),
|
||||
(0b000000110101, 12),
|
||||
(0b0000001101100, 13),
|
||||
(0b0000001101101, 13),
|
||||
(0b0000001001010, 13),
|
||||
(0b0000001001011, 13),
|
||||
(0b0000001001100, 13),
|
||||
(0b0000001001101, 13),
|
||||
(0b0000001110010, 13),
|
||||
(0b0000001110011, 13),
|
||||
(0b0000001110100, 13),
|
||||
(0b0000001110101, 13),
|
||||
(0b0000001110110, 13),
|
||||
(0b0000001110111, 13),
|
||||
(0b0000001010010, 13),
|
||||
(0b0000001010011, 13),
|
||||
(0b0000001010100, 13),
|
||||
(0b0000001010101, 13),
|
||||
(0b0000001011010, 13),
|
||||
(0b0000001011011, 13),
|
||||
(0b0000001100100, 13),
|
||||
(0b0000001100101, 13),
|
||||
];
|
||||
|
||||
/// Extended make-up codes for both colours (T.4 Table 3a), run lengths 1792, 1856, … 2560.
|
||||
const EXT_MAKEUP: [(u16, u8); 13] = [
|
||||
(0b00000001000, 11),
|
||||
(0b00000001100, 11),
|
||||
(0b00000001101, 11),
|
||||
(0b000000010010, 12),
|
||||
(0b000000010011, 12),
|
||||
(0b000000010100, 12),
|
||||
(0b000000010101, 12),
|
||||
(0b000000010110, 12),
|
||||
(0b000000010111, 12),
|
||||
(0b000000011100, 12),
|
||||
(0b000000011101, 12),
|
||||
(0b000000011110, 12),
|
||||
(0b000000011111, 12),
|
||||
];
|
||||
|
||||
/// A colour's code table: (length, code) → run length.
|
||||
type Table = HashMap<(u8, u16), u32>;
|
||||
|
||||
fn table(term: &[(u16, u8); 64], makeup: &[(u16, u8); 27]) -> Table {
|
||||
let mut t = HashMap::new();
|
||||
for (run, (code, len)) in term.iter().enumerate() {
|
||||
t.insert((*len, *code), run as u32);
|
||||
}
|
||||
for (i, (code, len)) in makeup.iter().enumerate() {
|
||||
t.insert((*len, *code), 64 * (i as u32 + 1));
|
||||
}
|
||||
for (i, (code, len)) in EXT_MAKEUP.iter().enumerate() {
|
||||
t.insert((*len, *code), 1792 + 64 * i as u32);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
fn tables() -> &'static (Table, Table) {
|
||||
static T: OnceLock<(Table, Table)> = OnceLock::new();
|
||||
T.get_or_init(|| (table(&WHITE_TERM, &WHITE_MAKEUP), table(&BLACK_TERM, &BLACK_MAKEUP)))
|
||||
}
|
||||
|
||||
/// The code (code, length) for a run of `run` (≥ 0, any length) as make-up codes then a
|
||||
/// terminating code (used by the test encoder).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn run_codes(white: bool, mut run: usize) -> Vec<(u16, u8)> {
|
||||
let (term, makeup) = if white { (&WHITE_TERM, &WHITE_MAKEUP) } else { (&BLACK_TERM, &BLACK_MAKEUP) };
|
||||
let mut out = vec![];
|
||||
while run >= 2560 {
|
||||
out.push(EXT_MAKEUP[12]);
|
||||
run -= 2560;
|
||||
}
|
||||
if run >= 1792 {
|
||||
out.push(EXT_MAKEUP[(run - 1792) / 64]);
|
||||
run -= 1792 + (run - 1792) / 64 * 64;
|
||||
} else if run >= 64 {
|
||||
out.push(makeup[run / 64 - 1]);
|
||||
run %= 64;
|
||||
}
|
||||
out.push(term[run]);
|
||||
out
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
data: &'a [u8],
|
||||
bit: usize,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
fn bit_at(&self, i: usize) -> u16 {
|
||||
self.data.get(i / 8).map(|b| ((b >> (7 - i % 8)) & 1) as u16).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn peek(&self, n: usize) -> u16 {
|
||||
(0..n).fold(0, |v, k| (v << 1) | self.bit_at(self.bit + k))
|
||||
}
|
||||
|
||||
fn eof(&self) -> bool {
|
||||
self.bit >= self.data.len() * 8
|
||||
}
|
||||
|
||||
fn take(&mut self, n: usize) -> u16 {
|
||||
let v = self.peek(n);
|
||||
self.bit += n;
|
||||
v
|
||||
}
|
||||
|
||||
fn align(&mut self) {
|
||||
self.bit = self.bit.div_ceil(8) * 8;
|
||||
}
|
||||
|
||||
/// An EOL (eleven or more 0 bits then 1) at the read position: consume it.
|
||||
fn eol(&mut self) -> bool {
|
||||
let mut zeros = 0;
|
||||
while zeros < 64 && self.bit + zeros < self.data.len() * 8 && self.bit_at(self.bit + zeros) == 0 {
|
||||
zeros += 1;
|
||||
}
|
||||
if zeros >= 11 && self.bit + zeros < self.data.len() * 8 {
|
||||
self.bit += zeros + 1;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// One run (make-up codes then a terminating code), `None` on an invalid code.
|
||||
fn run(&mut self, t: &Table) -> Option<u32> {
|
||||
let mut total = 0u32;
|
||||
loop {
|
||||
if self.eof() {
|
||||
return None;
|
||||
}
|
||||
let mut found = None;
|
||||
for len in 2..=13u8 {
|
||||
if let Some(r) = t.get(&(len, self.peek(len as usize))) {
|
||||
found = Some((len, *r));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (len, r) = found?;
|
||||
self.bit += len as usize;
|
||||
total = total.saturating_add(r);
|
||||
if r < 64 {
|
||||
return Some(total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 2-D mode codes (T.4 Table 4).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
Pass,
|
||||
Horizontal,
|
||||
Vertical(i64),
|
||||
Eol,
|
||||
}
|
||||
|
||||
fn mode(r: &mut Reader) -> Option<Mode> {
|
||||
if r.eof() {
|
||||
return None;
|
||||
}
|
||||
let m = if r.peek(1) == 1 {
|
||||
r.bit += 1;
|
||||
Mode::Vertical(0)
|
||||
} else if r.peek(3) == 0b011 {
|
||||
r.bit += 3;
|
||||
Mode::Vertical(1)
|
||||
} else if r.peek(3) == 0b010 {
|
||||
r.bit += 3;
|
||||
Mode::Vertical(-1)
|
||||
} else if r.peek(3) == 0b001 {
|
||||
r.bit += 3;
|
||||
Mode::Horizontal
|
||||
} else if r.peek(4) == 0b0001 {
|
||||
r.bit += 4;
|
||||
Mode::Pass
|
||||
} else if r.peek(6) == 0b000011 {
|
||||
r.bit += 6;
|
||||
Mode::Vertical(2)
|
||||
} else if r.peek(6) == 0b000010 {
|
||||
r.bit += 6;
|
||||
Mode::Vertical(-2)
|
||||
} else if r.peek(7) == 0b0000011 {
|
||||
r.bit += 7;
|
||||
Mode::Vertical(3)
|
||||
} else if r.peek(7) == 0b0000010 {
|
||||
r.bit += 7;
|
||||
Mode::Vertical(-3)
|
||||
} else if r.eol() {
|
||||
Mode::Eol
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// The first changing element of `refl` after `a0` whose colour is the opposite of `white`
|
||||
/// (index parity: even indices start black runs), and the one after it.
|
||||
fn b1b2(refl: &[usize], a0: i64, white: bool, columns: usize) -> (usize, usize) {
|
||||
let want = if white { 0 } else { 1 };
|
||||
let mut i = 0;
|
||||
while i < refl.len() && (refl[i] as i64 <= a0 || i % 2 != want) {
|
||||
i += 1;
|
||||
}
|
||||
let b1 = refl.get(i).copied().unwrap_or(columns);
|
||||
let b2 = refl.get(i + 1).copied().unwrap_or(columns);
|
||||
(b1, b2)
|
||||
}
|
||||
|
||||
/// Decode a 1-D (Modified Huffman) row into changing elements.
|
||||
fn row_1d(r: &mut Reader, columns: usize) -> Option<Vec<usize>> {
|
||||
let (wt, bt) = tables();
|
||||
let mut changes = vec![];
|
||||
let mut a0 = 0usize;
|
||||
let mut white = true;
|
||||
while a0 < columns {
|
||||
let run = r.run(if white { wt } else { bt })? as usize;
|
||||
a0 = (a0 + run).min(columns);
|
||||
changes.push(a0);
|
||||
white = !white;
|
||||
}
|
||||
Some(changes)
|
||||
}
|
||||
|
||||
/// Decode a 2-D row against the reference row's changing elements. `Err(true)` at an EOL.
|
||||
fn row_2d(r: &mut Reader, refl: &[usize], columns: usize) -> Result<Vec<usize>, bool> {
|
||||
let (wt, bt) = tables();
|
||||
let mut changes: Vec<usize> = vec![];
|
||||
let mut a0: i64 = -1;
|
||||
let mut white = true;
|
||||
let mut guard = 0;
|
||||
while a0 < columns as i64 {
|
||||
guard += 1;
|
||||
if guard > 4 * columns + 16 {
|
||||
return Err(false);
|
||||
}
|
||||
let (b1, b2) = b1b2(refl, a0, white, columns);
|
||||
match mode(r).ok_or(false)? {
|
||||
Mode::Pass => {
|
||||
// The run continues under b2 (no change in the coding line).
|
||||
a0 = b2 as i64;
|
||||
}
|
||||
Mode::Horizontal => {
|
||||
let start = a0.max(0) as usize;
|
||||
let r1 = r.run(if white { wt } else { bt }).ok_or(false)? as usize;
|
||||
let r2 = r.run(if white { bt } else { wt }).ok_or(false)? as usize;
|
||||
let a1 = (start + r1).min(columns);
|
||||
let a2 = (a1 + r2).min(columns);
|
||||
changes.push(a1);
|
||||
changes.push(a2);
|
||||
a0 = a2 as i64;
|
||||
}
|
||||
Mode::Vertical(d) => {
|
||||
let a1 = (b1 as i64 + d).clamp(0, columns as i64);
|
||||
if a1 < a0 {
|
||||
return Err(false);
|
||||
}
|
||||
changes.push(a1 as usize);
|
||||
a0 = a1;
|
||||
white = !white;
|
||||
}
|
||||
Mode::Eol => return Err(true),
|
||||
}
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// Changing elements → packed bits (`black` bit value for black pixels).
|
||||
fn pack(changes: &[usize], columns: usize, black_is_1: bool, out: &mut Vec<u8>) {
|
||||
let row_bytes = columns.div_ceil(8);
|
||||
let base = out.len();
|
||||
out.resize(base + row_bytes, if black_is_1 { 0 } else { 0xFF });
|
||||
let mut x = 0usize;
|
||||
let mut white = true;
|
||||
for &c in changes.iter().chain(std::iter::once(&columns)) {
|
||||
let c = c.min(columns);
|
||||
if !white {
|
||||
for p in x..c {
|
||||
let byte = &mut out[base + p / 8];
|
||||
let m = 0x80u8 >> (p % 8);
|
||||
if black_is_1 { *byte |= m } else { *byte &= !m }
|
||||
}
|
||||
}
|
||||
x = x.max(c);
|
||||
white = !white;
|
||||
if x >= columns {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode CCITT fax data. Damaged data ends the image at the last good row (rows already
|
||||
/// decoded are kept); `None` only when no row decodes.
|
||||
pub fn decode(data: &[u8], p: &Params) -> Option<Vec<u8>> {
|
||||
let columns = p.columns.clamp(1, 1 << 16);
|
||||
let max_rows = if p.rows > 0 { p.rows.min(1 << 20) } else { 1 << 20 };
|
||||
let mut r = Reader { data, bit: 0 };
|
||||
let mut out = vec![];
|
||||
let mut refl: Vec<usize> = vec![];
|
||||
let mut rows = 0;
|
||||
while rows < max_rows && !r.eof() {
|
||||
let line: Option<Vec<usize>> = if p.k < 0 {
|
||||
if p.byte_align {
|
||||
r.align();
|
||||
}
|
||||
// An error is EOFB (two EOLs) or damage.
|
||||
row_2d(&mut r, &refl, columns).ok()
|
||||
} else {
|
||||
// Group 3: optional fill and EOL before each row, then a tag bit when K > 0.
|
||||
let had_eol = r.eol();
|
||||
if !had_eol && p.byte_align {
|
||||
r.align();
|
||||
r.eol();
|
||||
}
|
||||
// RTC: six consecutive EOLs (the first already consumed).
|
||||
if had_eol && p.end_of_block {
|
||||
let save = r.bit;
|
||||
if r.eol() {
|
||||
break;
|
||||
}
|
||||
r.bit = save;
|
||||
}
|
||||
if r.eof() {
|
||||
break;
|
||||
}
|
||||
let two_d = p.k > 0 && r.take(1) == 0;
|
||||
if two_d { row_2d(&mut r, &refl, columns).ok() } else { row_1d(&mut r, columns) }
|
||||
};
|
||||
let Some(line) = line else { break };
|
||||
pack(&line, columns, p.black_is_1, &mut out);
|
||||
refl = line;
|
||||
rows += 1;
|
||||
}
|
||||
// Rows missing from damaged or short data stay white.
|
||||
if p.rows > 0 && rows > 0 && rows < p.rows {
|
||||
let row_bytes = columns.div_ceil(8);
|
||||
out.resize(p.rows.min(1 << 20) * row_bytes, if p.black_is_1 { 0 } else { 0xFF });
|
||||
}
|
||||
(rows > 0).then_some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A bit writer for the test encoder.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct W {
|
||||
pub bytes: Vec<u8>,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl W {
|
||||
pub fn put(&mut self, code: u16, len: u8) {
|
||||
for k in (0..len).rev() {
|
||||
if self.n.is_multiple_of(8) {
|
||||
self.bytes.push(0);
|
||||
}
|
||||
if (code >> k) & 1 == 1 {
|
||||
*self.bytes.last_mut().unwrap() |= 0x80 >> (self.n % 8);
|
||||
}
|
||||
self.n += 1;
|
||||
}
|
||||
}
|
||||
fn run(&mut self, white: bool, run: usize) {
|
||||
for (c, l) in run_codes(white, run) {
|
||||
self.put(c, l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Changing elements of a row of pixels (`true` = black).
|
||||
fn changes(row: &[bool]) -> Vec<usize> {
|
||||
let mut out = vec![];
|
||||
let mut cur = false;
|
||||
for (i, &b) in row.iter().enumerate() {
|
||||
if b != cur {
|
||||
out.push(i);
|
||||
cur = b;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_1d(w: &mut W, row: &[bool]) {
|
||||
let cols = row.len();
|
||||
let mut ch = changes(row);
|
||||
ch.push(cols);
|
||||
let mut a0 = 0;
|
||||
let mut white = true;
|
||||
for c in ch {
|
||||
w.run(white, c - a0);
|
||||
a0 = c;
|
||||
white = !white;
|
||||
if a0 >= cols {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_2d(w: &mut W, row: &[bool], refl: &[usize]) {
|
||||
let cols = row.len();
|
||||
let cur = changes(row);
|
||||
let mut a0: i64 = -1;
|
||||
let mut white = true;
|
||||
let next = |a0: i64| cur.iter().copied().find(|&c| c as i64 > a0).unwrap_or(cols);
|
||||
while a0 < cols as i64 {
|
||||
let a1 = next(a0);
|
||||
let (b1, b2) = b1b2(refl, a0, white, cols);
|
||||
if b2 < a1 {
|
||||
w.put(0b0001, 4);
|
||||
a0 = b2 as i64;
|
||||
} else if (a1 as i64 - b1 as i64).abs() <= 3 {
|
||||
let d = a1 as i64 - b1 as i64;
|
||||
let (c, l) = match d {
|
||||
0 => (1, 1),
|
||||
1 => (0b011, 3),
|
||||
-1 => (0b010, 3),
|
||||
2 => (0b000011, 6),
|
||||
-2 => (0b000010, 6),
|
||||
3 => (0b0000011, 7),
|
||||
_ => (0b0000010, 7),
|
||||
};
|
||||
w.put(c, l);
|
||||
a0 = a1 as i64;
|
||||
white = !white;
|
||||
} else {
|
||||
let a2 = next(a1 as i64);
|
||||
w.put(0b001, 3);
|
||||
let start = a0.max(0) as usize;
|
||||
w.run(white, a1 - start);
|
||||
w.run(!white, a2 - a1);
|
||||
a0 = a2 as i64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode(img: &[Vec<bool>], k: i64) -> Vec<u8> {
|
||||
let mut w = W::default();
|
||||
let mut refl: Vec<usize> = vec![];
|
||||
for (i, row) in img.iter().enumerate() {
|
||||
match k {
|
||||
k if k < 0 => encode_2d(&mut w, row, &refl),
|
||||
0 => {
|
||||
w.put(1, 12);
|
||||
encode_1d(&mut w, row);
|
||||
}
|
||||
_ => {
|
||||
w.put(1, 12);
|
||||
if i as i64 % k == 0 {
|
||||
w.put(1, 1);
|
||||
encode_1d(&mut w, row);
|
||||
} else {
|
||||
w.put(0, 1);
|
||||
encode_2d(&mut w, row, &refl);
|
||||
}
|
||||
}
|
||||
}
|
||||
refl = changes(row);
|
||||
}
|
||||
if k < 0 {
|
||||
w.put(1, 12);
|
||||
w.put(1, 12);
|
||||
}
|
||||
w.bytes
|
||||
}
|
||||
|
||||
pub(crate) fn pattern(cols: usize, rows: usize, seed: u32) -> Vec<Vec<bool>> {
|
||||
let mut s = seed;
|
||||
let mut rnd = || {
|
||||
s = s.wrapping_mul(1_103_515_245).wrapping_add(12345);
|
||||
(s >> 16) & 0x7FFF
|
||||
};
|
||||
let mut img = vec![];
|
||||
let mut prev = vec![false; cols];
|
||||
for y in 0..rows {
|
||||
let mut row = prev.clone();
|
||||
// Mostly like the row above (2-D coding's case), with random edits and runs.
|
||||
for _ in 0..(rnd() % 5) {
|
||||
let a = rnd() as usize % cols;
|
||||
let len = 1 + rnd() as usize % (cols / 3).max(1);
|
||||
let v = rnd() % 2 == 0;
|
||||
for p in row.iter_mut().skip(a).take(len) {
|
||||
*p = v;
|
||||
}
|
||||
}
|
||||
if y % 7 == 3 {
|
||||
row.iter_mut().for_each(|p| *p = rnd() % 3 == 0);
|
||||
}
|
||||
img.push(row.clone());
|
||||
prev = row;
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn unpack(data: &[u8], cols: usize, rows: usize) -> Vec<Vec<bool>> {
|
||||
let rb = cols.div_ceil(8);
|
||||
(0..rows).map(|y| (0..cols).map(|x| data[y * rb + x / 8] & (0x80 >> (x % 8)) == 0).collect()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_tables_are_prefix_free() {
|
||||
for (white, term, makeup) in [(true, &WHITE_TERM, &WHITE_MAKEUP), (false, &BLACK_TERM, &BLACK_MAKEUP)] {
|
||||
let mut all: Vec<(u16, u8)> = term.iter().chain(makeup.iter()).chain(EXT_MAKEUP.iter()).copied().collect();
|
||||
all.push((1, 12)); // EOL
|
||||
let mut kraft = 0.0;
|
||||
for (i, a) in all.iter().enumerate() {
|
||||
kraft += 0.5f64.powi(a.1 as i32);
|
||||
for (j, b) in all.iter().enumerate() {
|
||||
if i != j && a.1 <= b.1 {
|
||||
assert_ne!(b.0 >> (b.1 - a.1), a.0, "white {white}: {a:?} is a prefix of {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(kraft <= 1.0, "white {white}: Kraft sum {kraft}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_group_3_and_group_4() {
|
||||
for (cols, rows, seed) in [(1, 3, 1), (8, 8, 2), (37, 29, 3), (300, 40, 4), (2600, 6, 5)] {
|
||||
let img = pattern(cols, rows, seed);
|
||||
for k in [-1, 0, 1, 3] {
|
||||
let data = encode(&img, k);
|
||||
let p = Params { k, columns: cols, rows, ..Params::default() };
|
||||
let out = decode(&data, &p).unwrap_or_else(|| panic!("{cols}×{rows} K {k}: no rows"));
|
||||
assert_eq!(unpack(&out, cols, rows), img, "{cols}×{rows} K {k}");
|
||||
// Rows unknown: stop at the end of the data / EOFB.
|
||||
let out = decode(&data, &Params { rows: 0, ..p.clone() }).unwrap();
|
||||
assert_eq!(unpack(&out, cols, rows), img, "{cols}×{rows} K {k}, no /Rows");
|
||||
// BlackIs1 inverts.
|
||||
let inv = decode(&data, &Params { black_is_1: true, ..p }).unwrap();
|
||||
assert!(inv.iter().zip(&decode(&data, &Params { k, columns: cols, rows, ..Params::default() }).unwrap()).all(|(a, b)| a ^ b == 0xFF));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
/// Data from an independent encoder (libtiff's `tiffcp -c g4 / g3:1d / g3:2d / g3:1d:fill`,
|
||||
/// run once as an external fixture generator) for a 61×23 generated image (1 = black).
|
||||
#[test]
|
||||
fn decodes_an_independent_encoders_output() {
|
||||
let raw = hex(
|
||||
"0000000000000008380000000000000042d05803696223a87fc00003697fffe87f00007ffd7fffe87f00007ffd7fffe87f00007fffffffe87f00007ffffffff85cc00128608c10505cc001f860007ff0fffffffffffffff8fffffffffffdfff8fffffffffffffff8f8040003fffffff84140e4180f5002d04140e4180f5002d05ffdfc180f5000005ffdfc180f5000005ffdfc180f5000005ffdf000000ff800270ca82855a00d78ffecf82855a00c00ffecf82855a00c00",
|
||||
);
|
||||
let cases = [
|
||||
(
|
||||
-1,
|
||||
"52f3408da23e4747111f34c8f91d17447cc2308c4082588c447fe2223e11ac13ffff88fe3943845f08a72872acf8541f0ac0412ff11e22108e844445c82b0f8e4e6555608e223a12f17408fe58e50e51ca1c104bffffffc8e150ffc447ffffffffc21116449b29cd76088e88e8e223a3688e88e88f8455191d11d8420f88ffffc4e06ffffffc004004",
|
||||
),
|
||||
(
|
||||
0,
|
||||
"0014b4002f2c8008ead0f8eb10fd31f1d3a1f850a21d0e8004710463e3a7438103a0011c75820e870207400238eb041d0e040e800471d61a43a0011c7586a0008e879f0d3a1d5f8a3c5887438008e879f0cafd028e0026a0b40026a0ac3838009a82d0009a9cd540d20023ac43ae9d67ec743a3a1f1d0e0023ac43ae9d67ec743a3a1f1d0e0023a1c28e3cfd8e87558008e870a38f3f63a1d560023a1c28e3cfd8e87558008e870a39a8126002e9ebdd0e875887568743a1f1d531d0ec004d428fb9e21d5a1d0e87c754fc004d428fb9e21d5a1d0e87c754fc",
|
||||
),
|
||||
(
|
||||
4,
|
||||
"001a5a001179800c75687c75887e98f8e9d0fc28510e87400288c447fe2223c00638eb041d0e040e8005ff800c71d61a43a00171800c743cf869d0eafc51e2c43a1c005fc47888423800cd416800520ac3c0066a0b400293995540063ac43ae9d67ec743a3a1f1d0e002ffffff800c743851c79fb1d0eab0017fff0018e870a38f3f63a1d56002f84222c893001ba7af743a1d621d5a1d0e87c754c743b0010420f88ffffc4e06e003350a3ee7887568743a1f1d53f0",
|
||||
),
|
||||
(
|
||||
0,
|
||||
"00014b4001796400011d5a1f1d621fa63e3a743f0a1443a1d000011c4118f8e9d0e040e800011c75820e87020740011c75820e87020740011c758690e800011c7586a000011d0f3e1a743abf1478b10e8700011d0f3e195fa051c0013505a001350561c1c0013505a0013539aa81a400011d621d74eb3f63a1d1d0f8e870011d621d74eb3f63a1d1d0f8e870011d0e1471e7ec743aac00011d0e1471e7ec743aac00011d0e1471e7ec743aac00011d0e14735024c00174f5ee8743ac43ab43a1d0f8ea98e8760001350a3ee7887568743a1f1d53f001350a3ee7887568743a1f1d53f0",
|
||||
),
|
||||
];
|
||||
for (i, (k, data)) in cases.iter().enumerate() {
|
||||
let p = Params { k: *k, columns: 61, rows: 23, black_is_1: true, end_of_line: *k >= 0, ..Params::default() };
|
||||
let out = decode(&hex(data), &p).unwrap();
|
||||
assert_eq!(out, raw, "case {i} (K {k})");
|
||||
let out = decode(&hex(data), &Params { rows: 0, ..p }).unwrap();
|
||||
assert_eq!(out, raw, "case {i} (K {k}), no /Rows");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_data_keeps_the_good_rows() {
|
||||
let img = pattern(64, 20, 9);
|
||||
let data = encode(&img, -1);
|
||||
for cut in [0, 1, data.len() / 3, data.len() - 3] {
|
||||
let out = decode(&data[..cut], &Params { k: -1, columns: 64, rows: 20, ..Params::default() });
|
||||
if let Some(out) = out {
|
||||
assert_eq!(out.len(), 20 * 8);
|
||||
}
|
||||
}
|
||||
let mut garbage = data.clone();
|
||||
for (i, b) in garbage.iter_mut().enumerate() {
|
||||
*b ^= (i * 37) as u8;
|
||||
}
|
||||
for k in [-1, 0, 2] {
|
||||
let _ = decode(&garbage, &Params { k, columns: 64, rows: 20, ..Params::default() });
|
||||
let _ = decode(&garbage, &Params { k, columns: 1, rows: 0, ..Params::default() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -366,6 +366,14 @@ pub struct Cff {
|
|||
pub encoding: [u16; 256],
|
||||
}
|
||||
|
||||
/// The first font name in a CFF program's Name INDEX.
|
||||
pub fn font_name(data: &[u8]) -> Option<String> {
|
||||
let hdr = *data.get(2)? as usize;
|
||||
let (names, _) = index(data, hdr)?;
|
||||
let r = names.first()?.clone();
|
||||
Some(String::from_utf8_lossy(data.get(r)?).into_owned())
|
||||
}
|
||||
|
||||
impl Cff {
|
||||
pub fn parse(data: &[u8]) -> Option<Cff> {
|
||||
let d = data;
|
||||
|
|
|
|||
|
|
@ -167,6 +167,16 @@ pub enum Func {
|
|||
decode: Vec<f64>,
|
||||
samples: Vec<f64>,
|
||||
},
|
||||
/// Type 0 with several inputs (function-based shadings): multilinear interpolation, the
|
||||
/// first input varying fastest in the sample table.
|
||||
SampledN {
|
||||
domain: Vec<f64>,
|
||||
size: Vec<usize>,
|
||||
outputs: usize,
|
||||
encode: Vec<f64>,
|
||||
decode: Vec<f64>,
|
||||
samples: Vec<f64>,
|
||||
},
|
||||
/// An array of one-output functions.
|
||||
Array(Vec<Func>),
|
||||
/// Type 4: a PostScript calculator program (§7.10.5).
|
||||
|
|
@ -385,20 +395,23 @@ impl Func {
|
|||
0 => {
|
||||
let Obj::Stream(sd, raw) = o else { return Func::Unsupported };
|
||||
let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported };
|
||||
let size = file.get(d, "Size").map(|x| file.nums(x)).and_then(|v| v.first().copied()).unwrap_or(0.0) as usize;
|
||||
let sizes: Vec<usize> = file.get(d, "Size").map(|x| file.nums(x)).unwrap_or_default().iter().map(|v| v.max(0.0) as usize).collect();
|
||||
let size = sizes.first().copied().unwrap_or(0);
|
||||
let bps = file.get_num(d, "BitsPerSample").unwrap_or(8.0) as usize;
|
||||
let range = file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default();
|
||||
let outputs = range.len() / 2;
|
||||
if size == 0 || outputs == 0 || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
|
||||
return Func::Unsupported;
|
||||
}
|
||||
let total = sizes.iter().try_fold(1usize, |a, b| a.checked_mul(*b)).filter(|t| *t > 0 && *t <= 1 << 22);
|
||||
let Some(total) = total else { return Func::Unsupported };
|
||||
let enc = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default();
|
||||
let encode = [enc.first().copied().unwrap_or(0.0), enc.get(1).copied().unwrap_or(size as f64 - 1.0)];
|
||||
let decode = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= outputs * 2).unwrap_or_else(|| range.clone());
|
||||
let max = ((1u64 << bps) - 1) as f64;
|
||||
let mut samples = Vec::with_capacity(size * outputs);
|
||||
let mut bit = 0usize;
|
||||
for _ in 0..size * outputs {
|
||||
for _ in 0..total * outputs {
|
||||
let mut v = 0u64;
|
||||
for _ in 0..bps {
|
||||
let byte = data.get(bit / 8).copied().unwrap_or(0);
|
||||
|
|
@ -407,6 +420,13 @@ impl Func {
|
|||
}
|
||||
samples.push(v as f64 / max);
|
||||
}
|
||||
if sizes.len() > 1 {
|
||||
let m = sizes.len();
|
||||
let encode =
|
||||
(0..m).flat_map(|i| [enc.get(2 * i).copied().unwrap_or(0.0), enc.get(2 * i + 1).copied().unwrap_or(sizes[i] as f64 - 1.0)]).collect();
|
||||
let domain = (0..m).flat_map(|i| [dom.get(2 * i).copied().unwrap_or(0.0), dom.get(2 * i + 1).copied().unwrap_or(1.0)]).collect();
|
||||
return Func::SampledN { domain, size: sizes, outputs, encode, decode, samples };
|
||||
}
|
||||
Func::Sampled { domain, size, outputs, encode, decode, samples }
|
||||
}
|
||||
4 => {
|
||||
|
|
@ -441,8 +461,50 @@ impl Func {
|
|||
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)
|
||||
match self {
|
||||
Func::SampledN { domain, size, outputs, encode, decode, samples } => {
|
||||
let m = size.len();
|
||||
// Each input → a sample-table coordinate, then the 2^m surrounding samples.
|
||||
let mut base = vec![0usize; m];
|
||||
let mut frac = vec![0.0f64; m];
|
||||
for i in 0..m {
|
||||
let x = input.get(i).copied().unwrap_or(0.0);
|
||||
let (d0, d1) = (domain[2 * i], domain[2 * i + 1]);
|
||||
let x = x.clamp(d0.min(d1), d0.max(d1));
|
||||
let e = if d1 != d0 { encode[2 * i] + (x - d0) / (d1 - d0) * (encode[2 * i + 1] - encode[2 * i]) } else { encode[2 * i] };
|
||||
let e = if e.is_finite() { e.clamp(0.0, size[i] as f64 - 1.0) } else { 0.0 };
|
||||
base[i] = e.floor() as usize;
|
||||
frac[i] = e - e.floor();
|
||||
}
|
||||
let corners = if m <= 6 { 1usize << m } else { 1 };
|
||||
let mut out = vec![0.0; *outputs];
|
||||
for c in 0..corners {
|
||||
let mut w = 1.0;
|
||||
let mut idx = 0usize;
|
||||
let mut stride = 1usize;
|
||||
for i in 0..m {
|
||||
let up = m <= 6 && (c >> i) & 1 == 1;
|
||||
w *= if up { frac[i] } else { 1.0 - frac[i] };
|
||||
let k = if up { (base[i] + 1).min(size[i] - 1) } else { base[i] };
|
||||
idx += k * stride;
|
||||
stride *= size[i];
|
||||
}
|
||||
if w == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (o, v) in out.iter_mut().enumerate() {
|
||||
*v += w * samples.get(idx * outputs + o).copied().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
for (o, v) in out.iter_mut().enumerate() {
|
||||
let (d0, d1) = (decode.get(2 * o).copied().unwrap_or(0.0), decode.get(2 * o + 1).copied().unwrap_or(1.0));
|
||||
*v = d0 + *v * (d1 - d0);
|
||||
}
|
||||
out
|
||||
}
|
||||
Func::Array(fs) if input.len() > 1 => fs.iter().map(|f| f.eval(input).first().copied().unwrap_or(0.0)).collect(),
|
||||
_ => self.eval1(input.first().copied().unwrap_or(0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn eval1(&self, t: f64) -> Vec<f64> {
|
||||
|
|
@ -481,7 +543,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::Calc { .. } | Func::SampledN { .. } => self.eval(&[t]),
|
||||
Func::Unsupported => vec![0.5],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ pub(crate) struct Font {
|
|||
codespace: Vec<(usize, u32, u32)>,
|
||||
cid_ranges: Vec<(u32, u32, u32)>,
|
||||
pub vertical: bool,
|
||||
/// Vertical metrics (`/W2`, in text space): CID → (w1y, vx, vy).
|
||||
cid_vmetrics: HashMap<u32, (f64, f64, f64)>,
|
||||
/// `/DW2`: default (vy, w1y).
|
||||
dw2: (f64, f64),
|
||||
cache: RefCell<HashMap<u32, Option<Arc<BezPath>>>>,
|
||||
/// Program glyph advances (font units → text space) for fonts without `/Widths`.
|
||||
has_widths: bool,
|
||||
|
|
@ -194,6 +198,8 @@ struct CMap {
|
|||
codespace: Vec<(usize, u32, u32)>,
|
||||
unicode: HashMap<u32, String>,
|
||||
cids: Vec<(u32, u32, u32)>,
|
||||
/// `/WMode 1`: vertical writing.
|
||||
wmode: bool,
|
||||
}
|
||||
|
||||
fn be(b: &[u8]) -> u32 {
|
||||
|
|
@ -285,6 +291,14 @@ fn parse_cmap(data: &[u8]) -> CMap {
|
|||
}
|
||||
mode = "";
|
||||
}
|
||||
"def" if mode.is_empty() => {
|
||||
if let [.., Obj::Name(k), Obj::Num(v)] = args.as_slice()
|
||||
&& k == "WMode"
|
||||
{
|
||||
m.wmode = *v == 1.0;
|
||||
}
|
||||
args.clear();
|
||||
}
|
||||
_ => {
|
||||
if mode.is_empty() {
|
||||
args.clear();
|
||||
|
|
@ -297,11 +311,9 @@ fn parse_cmap(data: &[u8]) -> CMap {
|
|||
|
||||
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",
|
||||
fn blank(base_name: &str, composite: bool) -> Font {
|
||||
Font {
|
||||
composite,
|
||||
program: Program::None,
|
||||
matrix: Affine::scale(0.001),
|
||||
first_char: 0,
|
||||
|
|
@ -315,10 +327,48 @@ impl Font {
|
|||
codespace: vec![],
|
||||
cid_ranges: vec![],
|
||||
vertical: false,
|
||||
cid_vmetrics: HashMap::new(),
|
||||
dw2: (0.88, -1.0),
|
||||
cache: RefCell::new(HashMap::new()),
|
||||
has_widths: false,
|
||||
base_name: base_name.clone(),
|
||||
base_name: base_name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple font outside a PDF (EPS text): `program` is an embedded font program (CFF when
|
||||
/// the flag is set, else Type 1), otherwise the bundled stand-in for `name` is used;
|
||||
/// `names` is the encoding (code → glyph name), else the program's built-in encoding or
|
||||
/// StandardEncoding. Advances are the program's.
|
||||
pub fn standalone(name: &str, program: Option<(&[u8], bool)>, names: Option<Vec<Option<String>>>) -> Font {
|
||||
let mut f = Font::blank(name, false);
|
||||
f.program = match program {
|
||||
Some((data, true)) => Cff::parse(data).map(|c| Program::Cff(Box::new(c))),
|
||||
Some((data, false)) => Type1::parse(data).map(|t| Program::Type1(Box::new(t))),
|
||||
None => None,
|
||||
}
|
||||
.unwrap_or_else(|| Program::Fallback(fallback(name)));
|
||||
match &f.program {
|
||||
Program::Cff(c) => f.matrix = Affine::new(c.font_matrix),
|
||||
Program::Type1(t) => f.matrix = t.font_matrix.map(Affine::new).unwrap_or(f.matrix),
|
||||
_ => {}
|
||||
}
|
||||
let symbol = matches!(f.program, Program::Fallback(_)) && (name.contains("Symbol") || name.contains("Dingbats"));
|
||||
f.names = match names {
|
||||
// Unencoded codes draw .notdef (not the built-in encoding).
|
||||
Some(n) => (0..256).map(|c| Some(n.get(c).cloned().flatten().unwrap_or_else(|| ".notdef".into()))).collect(),
|
||||
None => match &f.program {
|
||||
Program::Type1(t) if !t.encoding.is_empty() => (0..256).map(|c| t.encoding.get(&(c as u8)).cloned()).collect(),
|
||||
_ if symbol => vec![None; 256],
|
||||
_ => (0..256).map(|c| Base::Standard.name(c as u8)).collect(),
|
||||
},
|
||||
};
|
||||
f
|
||||
}
|
||||
|
||||
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::blank(&base_name, subtype == "Type0");
|
||||
if let Some(Obj::Stream(sd, raw)) = file.get(d, "ToUnicode")
|
||||
&& let Some(data) = decode_stream(file, sd, raw)
|
||||
{
|
||||
|
|
@ -366,6 +416,37 @@ impl Font {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Vertical metrics (§9.7.4.3): /DW2 [vy w1y] and /W2 entries
|
||||
// `c [w1y vx vy …]` or `cfirst clast w1y vx vy`.
|
||||
let dw2 = file.get(&desc_dict, "DW2").map(|x| file.nums(x)).unwrap_or_default();
|
||||
if dw2.len() == 2 {
|
||||
f.dw2 = (dw2[0] / 1000.0, dw2[1] / 1000.0);
|
||||
}
|
||||
if let Some(w) = file.get(&desc_dict, "W2").and_then(Obj::array) {
|
||||
let num = |i: usize| w.get(i).and_then(|x| file.resolve(x).num());
|
||||
let mut i = 0;
|
||||
while i < w.len() {
|
||||
let Some(first) = num(i) else { break };
|
||||
let first = first.max(0.0) as u32;
|
||||
match w.get(i + 1).map(|x| file.resolve(x)) {
|
||||
Some(Obj::Array(list)) => {
|
||||
let v: Vec<f64> = list.iter().filter_map(|x| file.resolve(x).num()).collect();
|
||||
for (k, t) in v.chunks_exact(3).enumerate() {
|
||||
f.cid_vmetrics.insert(first + k as u32, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0));
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
Some(Obj::Num(last)) => {
|
||||
let (Some(w1), Some(vx), Some(vy)) = (num(i + 2), num(i + 3), num(i + 4)) else { break };
|
||||
for c in first..=(last.max(0.0) as u32).min(first + 65535) {
|
||||
f.cid_vmetrics.insert(c, (w1 / 1000.0, vx / 1000.0, vy / 1000.0));
|
||||
}
|
||||
i += 5;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
f.has_widths = true;
|
||||
// Encoding CMap.
|
||||
match file.get(d, "Encoding") {
|
||||
|
|
@ -378,6 +459,7 @@ impl Font {
|
|||
Some(Obj::Stream(sd, raw)) => {
|
||||
if let Some(data) = decode_stream(file, sd, raw) {
|
||||
let cm = parse_cmap(&data);
|
||||
f.vertical = cm.wmode || file.get_num(sd, "WMode") == Some(1.0);
|
||||
f.codespace = cm.codespace;
|
||||
f.cid_ranges = cm.cids;
|
||||
}
|
||||
|
|
@ -530,6 +612,17 @@ impl Font {
|
|||
self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map(|(lo, _, c)| c + (code - lo)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Vertical metrics of a code in text space (before the font size): the vertical
|
||||
/// displacement `w1y` (negative: downwards) and the position vector `(vx, vy)` from the
|
||||
/// horizontal origin to the vertical origin (§9.7.4.3; defaults from `/DW2`, `vx` half the
|
||||
/// horizontal advance).
|
||||
pub fn vmetrics(&self, code: u32) -> (f64, f64, f64) {
|
||||
match self.cid_vmetrics.get(&self.cid(code)) {
|
||||
Some(m) => *m,
|
||||
None => (self.dw2.1, self.width(code) / 2.0, self.dw2.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The advance of a code in text space (before the font size).
|
||||
pub fn width(&self, code: u32) -> f64 {
|
||||
if self.composite {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ fn samples(file: &File, d: &Dict, raw: &[u8]) -> Result<(Vec<u8>, Option<usize>)
|
|||
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})")),
|
||||
Some(f @ ("JPXDecode" | "JBIG2Decode")) => Err(format!("image ({f})")),
|
||||
_ => decode_stream(file, d, raw).map(|b| (b, None)).ok_or_else(|| "image (filters)".to_string()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
//! (Illustrator layers), with content outside them gathered into groups of its own.
|
||||
|
||||
mod build;
|
||||
mod ccitt;
|
||||
mod cff;
|
||||
mod color;
|
||||
mod encoding;
|
||||
|
|
@ -16,6 +17,7 @@ mod image;
|
|||
mod object;
|
||||
mod page;
|
||||
mod ps;
|
||||
mod shading;
|
||||
mod type1;
|
||||
pub mod write;
|
||||
|
||||
|
|
@ -192,3 +194,9 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_groups;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_shading;
|
||||
|
|
|
|||
|
|
@ -328,6 +328,7 @@ pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option<Vec<u8>> {
|
|||
lzw(&data, parms.get(i).and_then(|p| p.as_ref()).and_then(|p| p.get("EarlyChange")).and_then(Obj::num).unwrap_or(1.0) != 0.0)
|
||||
}
|
||||
"RunLengthDecode" | "RL" => run_length(&data),
|
||||
"CCITTFaxDecode" | "CCF" => crate::ccitt::decode(&data, &ccitt_params(file, parms.get(i).and_then(|p| p.as_ref())))?,
|
||||
_ => return None,
|
||||
};
|
||||
if let Some(Some(p)) = parms.get(i)
|
||||
|
|
@ -339,6 +340,24 @@ pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option<Vec<u8>> {
|
|||
Some(data)
|
||||
}
|
||||
|
||||
/// `/DecodeParms` of a `CCITTFaxDecode` filter.
|
||||
fn ccitt_params(file: &File, d: Option<&Dict>) -> crate::ccitt::Params {
|
||||
let mut p = crate::ccitt::Params::default();
|
||||
let Some(d) = d else { return p };
|
||||
let flag = |k: &str, default: bool| match file.get(d, k) {
|
||||
Some(Obj::Bool(b)) => *b,
|
||||
_ => default,
|
||||
};
|
||||
p.k = file.get_num(d, "K").unwrap_or(0.0) as i64;
|
||||
p.columns = file.get_num(d, "Columns").map(|c| c.max(1.0) as usize).unwrap_or(1728);
|
||||
p.rows = file.get_num(d, "Rows").map(|r| r.max(0.0) as usize).unwrap_or(0);
|
||||
p.end_of_line = flag("EndOfLine", false);
|
||||
p.byte_align = flag("EncodedByteAlign", false);
|
||||
p.end_of_block = flag("EndOfBlock", true);
|
||||
p.black_is_1 = flag("BlackIs1", false);
|
||||
p
|
||||
}
|
||||
|
||||
pub fn inflate(data: &[u8]) -> Option<Vec<u8>> {
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| {
|
||||
// Truncated streams: keep what inflates.
|
||||
|
|
@ -569,7 +588,7 @@ impl File {
|
|||
Obj::Dict(d) => {
|
||||
let save = lx.pos;
|
||||
lx.skip_ws();
|
||||
if data[lx.pos..].starts_with(b"stream") {
|
||||
if data.get(lx.pos..).is_some_and(|d| d.starts_with(b"stream")) {
|
||||
let mut s = lx.pos + 6;
|
||||
if data.get(s) == Some(&b'\r') {
|
||||
s += 1;
|
||||
|
|
@ -578,8 +597,9 @@ impl File {
|
|||
s += 1;
|
||||
}
|
||||
let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize);
|
||||
let s = s.min(data.len());
|
||||
let end = match declared.filter(|l| {
|
||||
s + l <= data.len() && {
|
||||
s.checked_add(*l).is_some_and(|e| e <= data.len()) && {
|
||||
let mut k = s + l;
|
||||
while k < data.len() && is_white(data[k]) {
|
||||
k += 1;
|
||||
|
|
@ -599,6 +619,7 @@ impl File {
|
|||
e
|
||||
}
|
||||
};
|
||||
let end = end.max(s);
|
||||
i = end;
|
||||
Obj::Stream(d, data[s..end].to_vec())
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -90,11 +90,20 @@ pub(crate) struct Tiling {
|
|||
uncolored: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
/// A shading pattern without a gradient equivalent (function-based or mesh shadings),
|
||||
/// rasterised when it fills a path.
|
||||
pub(crate) struct ShadingFill {
|
||||
sh: Obj,
|
||||
/// Shading space → page.
|
||||
space: Affine,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Fill {
|
||||
Color([f64; 3]),
|
||||
Paint(Paint),
|
||||
Tiling(Rc<Tiling>),
|
||||
Shading(Rc<ShadingFill>),
|
||||
None,
|
||||
}
|
||||
|
||||
|
|
@ -650,6 +659,10 @@ impl<'a> Interp<'a> {
|
|||
self.b.skip("tiling pattern stroke");
|
||||
Some(Paint::Color(t.uncolored.unwrap_or([0.5; 3])))
|
||||
}
|
||||
Fill::Shading(_) => {
|
||||
self.b.skip("shading pattern stroke");
|
||||
Some(Paint::Color([0.5; 3]))
|
||||
}
|
||||
Fill::None => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -681,6 +694,21 @@ impl<'a> Interp<'a> {
|
|||
self.tile(&t, area, depth);
|
||||
self.close_to(d);
|
||||
}
|
||||
Fill::Shading(sf) => {
|
||||
let page = ctm * path.clone();
|
||||
let d = self.b.depth();
|
||||
let mut sk = vec![];
|
||||
let img = crate::shading::shading_image(self.file, &sf.sh, sf.space, self.page_box, &mut sk);
|
||||
for s in sk {
|
||||
self.b.skip(&s);
|
||||
}
|
||||
if let Some(mut img) = img {
|
||||
img.opacity = self.gs.fill_alpha;
|
||||
self.b.clip(page, rule);
|
||||
self.b.push(Node::Image(img));
|
||||
self.close_to(d);
|
||||
}
|
||||
}
|
||||
f => fill_paint = self.paint_of(&f).map(|p| (p, self.gs.fill_alpha, rule)),
|
||||
}
|
||||
}
|
||||
|
|
@ -811,7 +839,11 @@ impl<'a> Interp<'a> {
|
|||
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 Some(sh) = f.get(p, "Shading").and_then(Obj::dict) else { return Fill::None };
|
||||
let Some(sh_obj) = f.get(p, "Shading") else { return Fill::None };
|
||||
let Some(sh) = sh_obj.dict() else { return Fill::None };
|
||||
if !matches!(f.get_num(sh, "ShadingType").unwrap_or(0.0) as i32, 2 | 3) {
|
||||
return Fill::Shading(Rc::new(ShadingFill { sh: sh_obj.clone(), space: base * m }));
|
||||
}
|
||||
let sh = sh.clone();
|
||||
match self.shading_gradient(&sh) {
|
||||
// Gradient space → shape user space (shapes carry the CTM).
|
||||
|
|
@ -848,7 +880,21 @@ impl<'a> Interp<'a> {
|
|||
|
||||
fn shade(&mut self, res: &Dict, name: &str) {
|
||||
let f = self.file;
|
||||
let Some(sh) = f.get_dict(res, "Shading").and_then(|s| f.get_dict(s, name)).cloned() else { return };
|
||||
let Some(sh_obj) = f.get_dict(res, "Shading").and_then(|s| f.get(s, name)) else { return };
|
||||
let Some(sh) = sh_obj.dict().cloned() else { return };
|
||||
if !matches!(f.get_num(&sh, "ShadingType").unwrap_or(0.0) as i32, 2 | 3) {
|
||||
// Function-based and mesh shadings paint only their own extent.
|
||||
let mut sk = vec![];
|
||||
let img = crate::shading::shading_image(f, sh_obj, self.gs.ctm, self.page_box, &mut sk);
|
||||
for s in sk {
|
||||
self.b.skip(&s);
|
||||
}
|
||||
if let Some(mut img) = img {
|
||||
img.opacity = self.gs.fill_alpha;
|
||||
self.b.push(Node::Image(img));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(g) = self.shading_gradient(&sh) else { return };
|
||||
// Fill everything (the current clip limits it): the page box in user space.
|
||||
let inv = self.gs.ctm.inverse();
|
||||
|
|
@ -876,12 +922,33 @@ impl<'a> Interp<'a> {
|
|||
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;
|
||||
// A transparency group XObject (§11.6.6) composites as one object: the current alpha
|
||||
// (and the blend-mode and soft-mask groups already open around it) apply to its
|
||||
// result, and its contents start from the initial alpha.
|
||||
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 bbox_path = |ctm: Affine| (bb.len() == 4).then(|| ctm * m * kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]).to_path(0.1));
|
||||
let mut clipped = false;
|
||||
if let Some(tg) = f.get_dict(d, "Group").filter(|g| f.get(g, "S").and_then(Obj::name) == Some("Transparency")) {
|
||||
let mut grp = Group::new("Transparency Group");
|
||||
// The bounding box clips the group itself (its children stay its direct children,
|
||||
// as knockout needs).
|
||||
if let Some(p) = bbox_path(self.gs.ctm) {
|
||||
grp.clip.push((p, FillRule::NonZero));
|
||||
clipped = true;
|
||||
}
|
||||
grp.opacity = self.gs.fill_alpha;
|
||||
grp.isolated = matches!(f.get(tg, "I"), Some(Obj::Bool(true)));
|
||||
grp.knockout = matches!(f.get(tg, "K"), Some(Obj::Bool(true)));
|
||||
self.b.open_group(grp);
|
||||
self.gs.fill_alpha = 1.0;
|
||||
self.gs.stroke_alpha = 1.0;
|
||||
self.gs.blend_depth = None;
|
||||
self.gs.mask_depth = None;
|
||||
}
|
||||
if !clipped && let Some(p) = bbox_path(self.gs.ctm) {
|
||||
self.b.clip(p, FillRule::NonZero);
|
||||
}
|
||||
self.gs.ctm *= m;
|
||||
let base = self.gs.ctm;
|
||||
let path = std::mem::take(&mut self.path);
|
||||
let (tm, tlm) = (self.tm, self.tlm);
|
||||
|
|
@ -980,7 +1047,8 @@ impl<'a> Interp<'a> {
|
|||
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 };
|
||||
let (w1y, vx, vy) = if font.vertical { font.vmetrics(code) } else { (0.0, 0.0, 0.0) };
|
||||
let trm = if font.vertical { trm * Affine::translate((-vx, -vy)) } 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());
|
||||
|
|
@ -993,7 +1061,7 @@ impl<'a> Interp<'a> {
|
|||
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)));
|
||||
self.tm *= Affine::translate((0.0, w1y * fs - self.gs.char_spacing - tw));
|
||||
} else {
|
||||
self.tm *= Affine::translate(((w0 * fs + self.gs.char_spacing + tw) * th, 0.0));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
//! edition, and the EPSF 3.0 specification) covering what exported vector EPS files use: the
|
||||
//! operand and dictionary stacks, procedures and `def`, arithmetic, control flow, arrays and
|
||||
//! dictionaries, the graphics state, path construction (including arcs) and painting, clipping
|
||||
//! and device colours. Text is skipped, as are images (their inline data is stepped over) and
|
||||
//! and device colours, and text (`findfont` / `selectfont`, `show` and its variants, `charpath`,
|
||||
//! `stringwidth`, re-encoded fonts) with embedded Type 1 and CFF programs or the bundled
|
||||
//! stand-in fonts. Images are skipped (their inline data is stepped over), as is
|
||||
//! Level 3 smooth shading (`shfill`).
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
|
@ -13,6 +15,7 @@ use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke};
|
|||
use kurbo::{PathEl, Point};
|
||||
|
||||
use crate::build::Builder;
|
||||
use crate::font::Font;
|
||||
use crate::object::{ascii85, is_white, parse_num};
|
||||
|
||||
type DictRef = Rc<RefCell<HashMap<String, V>>>;
|
||||
|
|
@ -79,6 +82,8 @@ struct GState {
|
|||
cur: Option<Point>,
|
||||
start: Option<Point>,
|
||||
depth: usize,
|
||||
/// The current font dictionary.
|
||||
font: Option<DictRef>,
|
||||
}
|
||||
|
||||
/// Source tokenizer (the data source for `currentfile`).
|
||||
|
|
@ -309,6 +314,14 @@ pub(crate) struct Interp {
|
|||
gs: GState,
|
||||
saved: Vec<GState>,
|
||||
budget: usize,
|
||||
/// Nested procedure calls (recursion is cut off at [`MAX_CALL_DEPTH`]).
|
||||
calls: usize,
|
||||
/// Fonts by name: `definefont`d dictionaries.
|
||||
font_dir: HashMap<String, DictRef>,
|
||||
/// Embedded font programs found in the file (name → (program, CFF)).
|
||||
embedded: HashMap<String, (Rc<Vec<u8>>, bool)>,
|
||||
/// Fonts built for drawing, by base font and encoding.
|
||||
font_cache: HashMap<String, Rc<Font>>,
|
||||
page: [f64; 4],
|
||||
pub unknown: Vec<String>,
|
||||
}
|
||||
|
|
@ -330,6 +343,9 @@ fn matrix_val(m: Affine) -> V {
|
|||
array(m.as_coeffs().iter().map(|c| V::Num(*c)).collect())
|
||||
}
|
||||
|
||||
/// The deepest procedure nesting run (deeper recursion stops the program).
|
||||
const MAX_CALL_DEPTH: usize = 256;
|
||||
|
||||
impl Interp {
|
||||
pub fn new(page: [f64; 4]) -> Interp {
|
||||
Interp {
|
||||
|
|
@ -348,15 +364,21 @@ impl Interp {
|
|||
cur: None,
|
||||
start: None,
|
||||
depth: 1,
|
||||
font: None,
|
||||
},
|
||||
saved: vec![],
|
||||
budget: 8_000_000,
|
||||
calls: 0,
|
||||
font_dir: HashMap::new(),
|
||||
embedded: HashMap::new(),
|
||||
font_cache: HashMap::new(),
|
||||
page,
|
||||
unknown: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(&mut self, data: &[u8]) {
|
||||
self.embedded = embedded_fonts(data);
|
||||
let mut src = Src { d: data, pos: 0 };
|
||||
while let Some(t) = src.token(0) {
|
||||
if let Err(Flow::Quit) = self.exec_token(t, &mut src) {
|
||||
|
|
@ -396,13 +418,25 @@ impl Interp {
|
|||
fn exec_obj(&mut self, v: V, src: &mut Src) -> Result<(), Flow> {
|
||||
match v {
|
||||
V::Proc(p) => {
|
||||
if self.calls >= MAX_CALL_DEPTH {
|
||||
self.b.skip("PostScript (recursion limit)");
|
||||
return Err(Flow::Quit);
|
||||
}
|
||||
self.calls += 1;
|
||||
let mut r = Ok(());
|
||||
for x in p.iter() {
|
||||
match x {
|
||||
V::Name(n) => self.exec_name(n, src)?,
|
||||
V::Name(n) => {
|
||||
r = self.exec_name(n, src);
|
||||
if r.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
other => self.push(other.clone()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
self.calls -= 1;
|
||||
r
|
||||
}
|
||||
V::Name(n) => self.exec_name(&n, src),
|
||||
other => {
|
||||
|
|
@ -906,16 +940,24 @@ impl Interp {
|
|||
"countdictstack" => self.push(V::Num(self.dicts.len() as f64)),
|
||||
"cleardictstack" => self.dicts.truncate(1),
|
||||
"findresource" | "findfont" | "findencoding" => {
|
||||
if n == "findresource" {
|
||||
self.pop();
|
||||
let category = if n == "findresource" { self.pop().key() } else { String::new() };
|
||||
let key = self.pop().key();
|
||||
match (n, category.as_str()) {
|
||||
("findfont", _) | (_, "Font") => {
|
||||
let f = self.find_font(&key);
|
||||
self.push(V::Dict(f));
|
||||
}
|
||||
("findencoding", _) | (_, "Encoding") => self.push(encoding_array(&key)),
|
||||
_ => self.push(V::Dict(new_dict())),
|
||||
}
|
||||
self.pop();
|
||||
self.push(V::Dict(new_dict()));
|
||||
}
|
||||
"defineresource" => {
|
||||
self.pop();
|
||||
let category = self.pop().key();
|
||||
let v = self.pop();
|
||||
self.pop();
|
||||
let key = self.pop().key();
|
||||
if let (V::Dict(d), "Font") = (&v, category.as_str()) {
|
||||
self.font_dir.insert(key, d.clone());
|
||||
}
|
||||
self.push(v);
|
||||
}
|
||||
"resourcestatus" => {
|
||||
|
|
@ -1337,55 +1379,10 @@ impl Interp {
|
|||
self.pop();
|
||||
self.b.skip("smooth shading (shfill)");
|
||||
}
|
||||
// Text (skipped).
|
||||
"scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "setcachedevice" | "setcharwidth" => {
|
||||
match n {
|
||||
"scalefont" | "makefont" => {
|
||||
self.pop();
|
||||
}
|
||||
"selectfont" => {
|
||||
self.pop();
|
||||
self.pop();
|
||||
}
|
||||
"charpath" => {
|
||||
self.pop();
|
||||
self.pop();
|
||||
}
|
||||
"setcachedevice" => {
|
||||
for _ in 0..6 {
|
||||
self.pop();
|
||||
}
|
||||
}
|
||||
"setcharwidth" => {
|
||||
self.pop();
|
||||
self.pop();
|
||||
}
|
||||
"rootfont" => self.push(V::Dict(new_dict())),
|
||||
_ => {
|
||||
self.pop();
|
||||
}
|
||||
}
|
||||
if matches!(n, "show" | "charpath" | "glyphshow") {
|
||||
self.b.skip("text");
|
||||
}
|
||||
}
|
||||
"currentfont" => self.push(V::Dict(new_dict())),
|
||||
"ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" => {
|
||||
let k = match n {
|
||||
"ashow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" => 3,
|
||||
"widthshow" => 4,
|
||||
_ => 6,
|
||||
};
|
||||
for _ in 0..k.min(self.st.len()) {
|
||||
self.pop();
|
||||
}
|
||||
self.b.skip("text");
|
||||
}
|
||||
"stringwidth" => {
|
||||
self.pop();
|
||||
self.push(V::Num(0.0));
|
||||
self.push(V::Num(0.0));
|
||||
}
|
||||
// Text (see `text_op`).
|
||||
"scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "currentfont" | "setcachedevice"
|
||||
| "setcharwidth" | "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" | "stringwidth" | "definefont"
|
||||
| "StandardEncoding" | "ISOLatin1Encoding" | "eexec" | "StartData" | "undefinefont" => self.text_op(n, src)?,
|
||||
// Images: operands popped, inline data skipped.
|
||||
"image" | "imagemask" | "colorimage" => {
|
||||
let mut reads_file = false;
|
||||
|
|
@ -1460,3 +1457,369 @@ pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> {
|
|||
let bbox = find_box(b"%%HiResBoundingBox:").or_else(|| find_box(b"%%BoundingBox:"))?;
|
||||
Some((ps, bbox))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ text
|
||||
|
||||
/// An encoding array of glyph names (`StandardEncoding`; `ISOLatin1Encoding` and others are
|
||||
/// approximated by WinAnsi, which agrees with ISO Latin-1 on the printable codes).
|
||||
fn encoding_array(name: &str) -> V {
|
||||
let base = if name == "StandardEncoding" { crate::encoding::Base::Standard } else { crate::encoding::Base::WinAnsi };
|
||||
array((0..256).map(|c| V::Lit(base.name(c as u8).unwrap_or_else(|| ".notdef".into()).into())).collect())
|
||||
}
|
||||
|
||||
fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from)
|
||||
}
|
||||
|
||||
fn rfind_before(hay: &[u8], needle: &[u8], to: usize) -> Option<usize> {
|
||||
hay.get(..to)?.windows(needle.len()).rposition(|w| w == needle)
|
||||
}
|
||||
|
||||
/// The name after `key` (`/FontName /Name`).
|
||||
fn name_after(b: &[u8], key: &[u8]) -> Option<String> {
|
||||
let p = find_from(b, key, 0)? + key.len();
|
||||
let rest = &b[p..];
|
||||
let s = rest.iter().position(|c| *c == b'/')? + 1;
|
||||
let e = rest[s..].iter().position(|c| is_white(*c) || matches!(c, b'/' | b'[' | b'(' | b'{')).map_or(rest.len(), |q| s + q);
|
||||
(e > s).then(|| String::from_utf8_lossy(&rest[s..e]).into_owned())
|
||||
}
|
||||
|
||||
/// Embedded font programs of an EPS file: Type 1 fonts (`… /FontName /X … currentfile eexec
|
||||
/// … cleartomark`, hexadecimal or binary) and CFF font sets (`… <length> StartData <bytes>`).
|
||||
fn embedded_fonts(ps: &[u8]) -> HashMap<String, (Rc<Vec<u8>>, bool)> {
|
||||
let mut out = HashMap::new();
|
||||
let mut from = 0;
|
||||
while let Some(ee) = find_from(ps, b"eexec", from) {
|
||||
from = ee + 5;
|
||||
let start = [b"%!PS-AdobeFont".as_slice(), b"%!FontType1"]
|
||||
.iter()
|
||||
.filter_map(|h| rfind_before(ps, h, ee))
|
||||
.max()
|
||||
.or_else(|| rfind_before(ps, b"/FontName", ee).map(|p| p.saturating_sub(512)));
|
||||
let Some(start) = start else { continue };
|
||||
let end = find_from(ps, b"cleartomark", ee).map_or(ps.len(), |p| p + 11);
|
||||
if let Some(name) = name_after(&ps[start..ee], b"/FontName") {
|
||||
out.insert(name, (Rc::new(ps[start..end].to_vec()), false));
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
let mut from = 0;
|
||||
while let Some(sd) = find_from(ps, b"StartData", from) {
|
||||
from = sd + 9;
|
||||
let head = String::from_utf8_lossy(&ps[sd.saturating_sub(64)..sd]).into_owned();
|
||||
let Some(len) = head.split_whitespace().last().and_then(|t| t.parse::<usize>().ok()) else { continue };
|
||||
let s = sd + 10;
|
||||
let Some(data) = ps.get(s..s.saturating_add(len)) else { continue };
|
||||
if let Some(name) = crate::cff::font_name(data) {
|
||||
out.insert(name, (Rc::new(data.to_vec()), true));
|
||||
}
|
||||
from = s + len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn dict_matrix(d: &DictRef, key: &str) -> Affine {
|
||||
d.borrow().get(key).and_then(matrix_of).unwrap_or(Affine::IDENTITY)
|
||||
}
|
||||
|
||||
/// A copy of a font dictionary with `m` applied after its matrix (`scalefont`, `makefont`).
|
||||
fn transformed(font: &DictRef, m: Affine) -> DictRef {
|
||||
let copy: HashMap<String, V> = font.borrow().clone();
|
||||
let d = Rc::new(RefCell::new(copy));
|
||||
let old = dict_matrix(&d, "__m");
|
||||
d.borrow_mut().insert("__m".into(), matrix_val(m * old));
|
||||
d
|
||||
}
|
||||
|
||||
impl Interp {
|
||||
/// A font dictionary by name: one `definefont` made, else a new dictionary for an
|
||||
/// embedded program or a bundled stand-in.
|
||||
fn find_font(&mut self, key: &str) -> DictRef {
|
||||
if let Some(d) = self.font_dir.get(key) {
|
||||
return d.clone();
|
||||
}
|
||||
let d = new_dict();
|
||||
{
|
||||
let mut m = d.borrow_mut();
|
||||
m.insert("FontName".into(), V::Lit(key.into()));
|
||||
m.insert("FontType".into(), V::Num(1.0));
|
||||
m.insert("FontMatrix".into(), matrix_val(Affine::scale(0.001)));
|
||||
m.insert("FID".into(), V::Null);
|
||||
m.insert("__base".into(), V::Lit(key.into()));
|
||||
m.insert("__m".into(), matrix_val(Affine::IDENTITY));
|
||||
}
|
||||
self.font_dir.insert(key.to_string(), d.clone());
|
||||
d
|
||||
}
|
||||
|
||||
/// The drawable font of a font dictionary (its base program and encoding) and the matrix
|
||||
/// from text space (font size 1) to user space.
|
||||
fn font_of(&mut self, d: &DictRef) -> Option<(Rc<Font>, Affine)> {
|
||||
if matches!(d.borrow().get("FontType"), Some(V::Num(t)) if *t == 3.0) {
|
||||
// Type 3 fonts (glyph procedures) are not run.
|
||||
self.b.skip("EPS Type 3 font");
|
||||
return None;
|
||||
}
|
||||
let (base, names) = {
|
||||
let m = d.borrow();
|
||||
let base = m.get("__base").or_else(|| m.get("FontName")).map(V::key)?;
|
||||
let names: Option<Vec<Option<String>>> = m.get("Encoding").and_then(V::items).map(|a| {
|
||||
a.iter()
|
||||
.map(|v| match v {
|
||||
V::Lit(n) | V::Name(n) if &**n != ".notdef" => Some(n.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
(base, names)
|
||||
};
|
||||
let key = match &names {
|
||||
Some(n) => format!("{base}\u{0}{}", n.iter().map(|x| x.as_deref().unwrap_or("")).collect::<Vec<_>>().join(" ")),
|
||||
None => base.clone(),
|
||||
};
|
||||
let font = match self.font_cache.get(&key) {
|
||||
Some(f) => f.clone(),
|
||||
None => {
|
||||
// Not embedded: a bundled stand-in, as for PDF.
|
||||
let prog = self.embedded.get(&base).cloned();
|
||||
let f = Rc::new(Font::standalone(&base, prog.as_ref().map(|(d, cff)| (d.as_slice(), *cff)), names));
|
||||
self.font_cache.insert(key, f.clone());
|
||||
f
|
||||
}
|
||||
};
|
||||
Some((font, dict_matrix(d, "__m")))
|
||||
}
|
||||
|
||||
/// A string's glyph outlines (device space) from the current point, with an extra
|
||||
/// displacement `extra(index, code)` (user space) after each glyph; moves the current
|
||||
/// point.
|
||||
fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Option<(BezPath, String)> {
|
||||
let d = self.gs.font.clone()?;
|
||||
let (font, m) = self.font_of(&d)?;
|
||||
let mut p = self.current_user();
|
||||
let mut path = BezPath::new();
|
||||
let mut text = String::new();
|
||||
let ctm = self.gs.ctm;
|
||||
for (i, &code) in bytes.iter().enumerate() {
|
||||
let code = code as u32;
|
||||
if let Some(g) = font.glyph(code) {
|
||||
path.extend(ctm * Affine::translate(p.to_vec2()) * m * (*g).clone());
|
||||
}
|
||||
text.push_str(&font.unicode(code));
|
||||
let adv = m * Point::new(font.width(code), 0.0) - m * Point::ZERO;
|
||||
let (ex, ey) = extra(i, code);
|
||||
p += adv + kurbo::Vec2::new(ex, ey);
|
||||
}
|
||||
self.move_to(p);
|
||||
Some((path, text))
|
||||
}
|
||||
|
||||
fn fill_text(&mut self, path: BezPath, text: &str) {
|
||||
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()) };
|
||||
self.b.fill_stroke_named(&name, path, Affine::IDENTITY, Some((Paint::Color(self.gs.color), 1.0, FillRule::NonZero)), None);
|
||||
}
|
||||
|
||||
fn pop_string(&mut self) -> Vec<u8> {
|
||||
match self.pop() {
|
||||
V::Str(s) => s.borrow().clone(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) {
|
||||
match self.text_path(s, extra) {
|
||||
Some((path, text)) => self.fill_text(path, &text),
|
||||
None => self.b.skip("text (no font)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text and font operators (PostScript Language Reference §5.1, §8.2).
|
||||
fn text_op(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> {
|
||||
match n {
|
||||
"scalefont" | "makefont" => {
|
||||
let m = if n == "scalefont" {
|
||||
Affine::scale(self.popn())
|
||||
} else {
|
||||
let v = self.pop();
|
||||
matrix_of(&v).unwrap_or(Affine::IDENTITY)
|
||||
};
|
||||
let font = match self.pop() {
|
||||
V::Dict(d) => d,
|
||||
_ => self.find_font("Helvetica"),
|
||||
};
|
||||
self.push(V::Dict(transformed(&font, m)));
|
||||
}
|
||||
"selectfont" => {
|
||||
let size = self.pop();
|
||||
let key = self.pop().key();
|
||||
let font = self.find_font(&key);
|
||||
let m = match &size {
|
||||
V::Num(s) => Affine::scale(*s),
|
||||
other => matrix_of(other).unwrap_or(Affine::IDENTITY),
|
||||
};
|
||||
self.gs.font = Some(transformed(&font, m));
|
||||
}
|
||||
"setfont" => {
|
||||
if let V::Dict(d) = self.pop() {
|
||||
self.gs.font = Some(d);
|
||||
}
|
||||
}
|
||||
"currentfont" | "rootfont" => {
|
||||
let d = self.gs.font.clone().unwrap_or_else(new_dict);
|
||||
self.push(V::Dict(d));
|
||||
}
|
||||
"definefont" => {
|
||||
let v = self.pop();
|
||||
let key = self.pop().key();
|
||||
if let V::Dict(d) = &v {
|
||||
if !d.borrow().contains_key("__base") {
|
||||
// A font the file defines itself: its embedded program, by name.
|
||||
d.borrow_mut().insert("__base".into(), V::Lit(key.as_str().into()));
|
||||
d.borrow_mut().insert("__m".into(), matrix_val(Affine::IDENTITY));
|
||||
}
|
||||
self.font_dir.insert(key, d.clone());
|
||||
}
|
||||
self.push(v);
|
||||
}
|
||||
"undefinefont" => {
|
||||
let key = self.pop().key();
|
||||
self.font_dir.remove(&key);
|
||||
}
|
||||
"StandardEncoding" | "ISOLatin1Encoding" => self.push(encoding_array(n)),
|
||||
"show" => {
|
||||
let s = self.pop_string();
|
||||
self.show_with(&s, |_, _| (0.0, 0.0));
|
||||
}
|
||||
"ashow" => {
|
||||
let s = self.pop_string();
|
||||
let ay = self.popn();
|
||||
let ax = self.popn();
|
||||
self.show_with(&s, |_, _| (ax, ay));
|
||||
}
|
||||
"widthshow" => {
|
||||
let s = self.pop_string();
|
||||
let ch = self.popn() as u32;
|
||||
let cy = self.popn();
|
||||
let cx = self.popn();
|
||||
self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) });
|
||||
}
|
||||
"awidthshow" => {
|
||||
let s = self.pop_string();
|
||||
let ay = self.popn();
|
||||
let ax = self.popn();
|
||||
let ch = self.popn() as u32;
|
||||
let cy = self.popn();
|
||||
let cx = self.popn();
|
||||
self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) });
|
||||
}
|
||||
"kshow" | "cshow" => {
|
||||
// The procedure between characters is not run.
|
||||
let s = self.pop_string();
|
||||
self.pop();
|
||||
self.show_with(&s, |_, _| (0.0, 0.0));
|
||||
}
|
||||
"xshow" | "yshow" | "xyshow" => {
|
||||
let disp: Vec<f64> = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect();
|
||||
let s = self.pop_string();
|
||||
self.explicit_show(&s, &disp, n);
|
||||
}
|
||||
"glyphshow" => {
|
||||
let name = self.pop().key();
|
||||
let Some(d) = self.gs.font.clone() else { return Ok(()) };
|
||||
// Drawn through a one-glyph encoding.
|
||||
let mut e: Vec<V> = (0..256).map(|_| V::Lit(".notdef".into())).collect();
|
||||
e[65] = V::Lit(name.as_str().into());
|
||||
let tmp = transformed(&d, Affine::IDENTITY);
|
||||
tmp.borrow_mut().insert("Encoding".into(), array(e));
|
||||
self.gs.font = Some(tmp);
|
||||
self.show_with(b"A", |_, _| (0.0, 0.0));
|
||||
self.gs.font = Some(d);
|
||||
}
|
||||
"charpath" => {
|
||||
self.pop();
|
||||
let s = self.pop_string();
|
||||
let saved = std::mem::take(&mut self.gs.path);
|
||||
let start = self.gs.start;
|
||||
match self.text_path(&s, |_, _| (0.0, 0.0)) {
|
||||
Some((glyphs, _)) => {
|
||||
let mut p = saved;
|
||||
p.extend(glyphs);
|
||||
if let Some(c) = self.gs.cur {
|
||||
p.move_to(c);
|
||||
}
|
||||
self.gs.path = p;
|
||||
self.gs.start = start;
|
||||
}
|
||||
None => self.gs.path = saved,
|
||||
}
|
||||
}
|
||||
"stringwidth" => {
|
||||
let s = self.pop_string();
|
||||
let (mut w, mut h) = (0.0, 0.0);
|
||||
if let Some(d) = self.gs.font.clone()
|
||||
&& let Some((font, m)) = self.font_of(&d)
|
||||
{
|
||||
for &c in &s {
|
||||
let v = m * Point::new(font.width(c as u32), 0.0) - m * Point::ZERO;
|
||||
w += v.x;
|
||||
h += v.y;
|
||||
}
|
||||
}
|
||||
self.push(V::Num(w));
|
||||
self.push(V::Num(h));
|
||||
}
|
||||
"setcachedevice" => {
|
||||
for _ in 0..6 {
|
||||
self.pop();
|
||||
}
|
||||
}
|
||||
"setcharwidth" => {
|
||||
self.pop();
|
||||
self.pop();
|
||||
}
|
||||
// An embedded Type 1 program's encrypted part (read by `embedded_fonts`).
|
||||
"eexec" => {
|
||||
if matches!(self.pop(), V::File) {
|
||||
let rest = &src.d[src.pos.min(src.d.len())..];
|
||||
src.pos += rest.windows(11).position(|w| w == b"cleartomark").map_or(rest.len(), |p| p + 11);
|
||||
}
|
||||
}
|
||||
// A CFF font set's binary data (read by `embedded_fonts`).
|
||||
"StartData" => {
|
||||
let len = self.popn().max(0.0) as usize;
|
||||
self.pop();
|
||||
src.pos = src.pos.saturating_add(1).saturating_add(len).min(src.d.len());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `xshow` / `yshow` / `xyshow`: glyph displacements from a number array instead of the
|
||||
/// advances.
|
||||
fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) {
|
||||
let step = if n == "xyshow" { 2 } else { 1 };
|
||||
let d: Vec<(f64, f64)> = (0..s.len())
|
||||
.map(|k| {
|
||||
let i = k * step;
|
||||
let v = |j: usize| disp.get(j).copied().unwrap_or(0.0);
|
||||
match n {
|
||||
"xshow" => (v(i), 0.0),
|
||||
"yshow" => (0.0, v(i)),
|
||||
_ => (v(i), v(i + 1)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let Some(fd) = self.gs.font.clone() else {
|
||||
self.b.skip("text (no font)");
|
||||
return;
|
||||
};
|
||||
let Some((font, m)) = self.font_of(&fd) else { return };
|
||||
self.show_with(s, move |k, c| {
|
||||
let adv = m * Point::new(font.width(c), 0.0) - m * Point::ZERO;
|
||||
let (dx, dy) = d.get(k).copied().unwrap_or((0.0, 0.0));
|
||||
(dx - adv.x, dy - adv.y)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,580 @@
|
|||
//! Shadings the render tree has no gradient for (ISO 32000-1 §8.7.4.5): function-based
|
||||
//! shadings (type 1) and the mesh shadings — free-form and lattice-form Gouraud-shaded triangle
|
||||
//! meshes (types 4 and 5), Coons patch meshes (type 6) and tensor-product patch meshes (type 7).
|
||||
//! Patches are tessellated into triangles; triangles are rasterised with their colours (or the
|
||||
//! parametric `t` of a shading `Function`) interpolated across them, into an image node in page
|
||||
//! space (2×2 supersampled, so the mesh's outer edges are anti-aliased without seams between
|
||||
//! triangles).
|
||||
|
||||
use effectcraft_svg::{Affine, Image as SvgImage};
|
||||
use kurbo::Point;
|
||||
|
||||
use crate::color::{Cs, Func, color_space};
|
||||
use crate::object::{Dict, File, Obj, decode_stream};
|
||||
|
||||
/// The longest side of a rasterised shading, in image pixels.
|
||||
const MAX_SIDE: f64 = 1024.0;
|
||||
/// The most image pixels per PDF point.
|
||||
const MAX_SCALE: f64 = 4.0;
|
||||
/// The most triangles one shading draws.
|
||||
const MAX_TRIANGLES: usize = 2_000_000;
|
||||
|
||||
/// A mesh vertex: page-space position and its value (RGB, or `t` in `[0]` with a function).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct V {
|
||||
p: Point,
|
||||
c: [f64; 3],
|
||||
}
|
||||
|
||||
/// How vertex values become colours.
|
||||
struct Colors {
|
||||
cs: Cs,
|
||||
/// With a function: `t` (domain `[t0, t1]`) → RGB through a 1024-entry table.
|
||||
lut: Option<(f64, f64, Vec<[u8; 3]>)>,
|
||||
}
|
||||
|
||||
impl Colors {
|
||||
fn new(file: &File, sh: &Dict) -> (Colors, usize) {
|
||||
let cs = file.get(sh, "ColorSpace").map(|c| color_space(file, c, None)).unwrap_or(Cs::Rgb);
|
||||
let n = cs.components().max(1);
|
||||
match file.get(sh, "Function") {
|
||||
Some(fo) => {
|
||||
let func = Func::read(file, fo);
|
||||
let dom = file.get(sh, "Domain").map(|d| file.nums(d)).filter(|d| d.len() == 2).unwrap_or_else(|| vec![0.0, 1.0]);
|
||||
let (t0, t1) = (dom[0], dom[1]);
|
||||
let lut = (0..1024)
|
||||
.map(|i| {
|
||||
let t = t0 + (t1 - t0) * i as f64 / 1023.0;
|
||||
to8(cs.to_rgb(&func.eval(&[t])))
|
||||
})
|
||||
.collect();
|
||||
(Colors { cs, lut: Some((t0, t1, lut)) }, 1)
|
||||
}
|
||||
None => (Colors { cs, lut: None }, n),
|
||||
}
|
||||
}
|
||||
|
||||
/// A vertex value from its components (RGB, or `t`).
|
||||
fn value(&self, comps: &[f64]) -> [f64; 3] {
|
||||
match self.lut {
|
||||
Some(_) => [comps.first().copied().unwrap_or(0.0), 0.0, 0.0],
|
||||
None => self.cs.to_rgb(comps),
|
||||
}
|
||||
}
|
||||
|
||||
/// An interpolated value → RGB8.
|
||||
fn rgb(&self, v: [f64; 3]) -> [u8; 3] {
|
||||
match &self.lut {
|
||||
Some((t0, t1, lut)) => {
|
||||
let k = if t1 != t0 { (v[0] - t0) / (t1 - t0) } else { 0.0 };
|
||||
let k = if k.is_finite() { k.clamp(0.0, 1.0) } else { 0.0 };
|
||||
lut[(k * 1023.0).round() as usize]
|
||||
}
|
||||
None => to8(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to8(c: [f64; 3]) -> [u8; 3] {
|
||||
c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
}
|
||||
|
||||
/// Big-endian bit reader over mesh data.
|
||||
struct Bits<'a> {
|
||||
data: &'a [u8],
|
||||
bit: usize,
|
||||
}
|
||||
|
||||
impl Bits<'_> {
|
||||
fn left(&self) -> usize {
|
||||
(self.data.len() * 8).saturating_sub(self.bit)
|
||||
}
|
||||
|
||||
fn read(&mut self, n: usize) -> Option<u64> {
|
||||
if n == 0 || n > 32 || self.left() < n {
|
||||
return None;
|
||||
}
|
||||
let mut v = 0u64;
|
||||
for _ in 0..n {
|
||||
let byte = self.data[self.bit / 8];
|
||||
v = (v << 1) | ((byte >> (7 - self.bit % 8)) & 1) as u64;
|
||||
self.bit += 1;
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn align(&mut self) {
|
||||
self.bit = self.bit.div_ceil(8) * 8;
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout of a mesh's vertex data (§8.7.4.5.5 Table 83 and following).
|
||||
struct Layout {
|
||||
bpc: usize,
|
||||
bpcomp: usize,
|
||||
bpf: usize,
|
||||
/// Value components per vertex (1 with a function).
|
||||
n: usize,
|
||||
decode: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
fn read(file: &File, sh: &Dict, n: usize) -> Option<Layout> {
|
||||
let bpc = file.get_num(sh, "BitsPerCoordinate")? as usize;
|
||||
let bpcomp = file.get_num(sh, "BitsPerComponent")? as usize;
|
||||
let bpf = file.get_num(sh, "BitsPerFlag").unwrap_or(8.0) as usize;
|
||||
let decode = file.get(sh, "Decode").map(|d| file.nums(d)).unwrap_or_default();
|
||||
let ok = |b: usize| matches!(b, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32);
|
||||
if !ok(bpc) || !ok(bpcomp) || !matches!(bpf, 2 | 4 | 8) || decode.len() < 4 + 2 * n {
|
||||
return None;
|
||||
}
|
||||
Some(Layout { bpc, bpcomp, bpf, n, decode })
|
||||
}
|
||||
|
||||
fn scale(raw: u64, bits: usize, lo: f64, hi: f64) -> f64 {
|
||||
let max = ((1u64 << bits) - 1) as f64;
|
||||
lo + raw as f64 * (hi - lo) / max
|
||||
}
|
||||
|
||||
fn point(&self, r: &mut Bits) -> Option<Point> {
|
||||
let x = r.read(self.bpc)?;
|
||||
let y = r.read(self.bpc)?;
|
||||
let d = &self.decode;
|
||||
Some(Point::new(Self::scale(x, self.bpc, d[0], d[1]), Self::scale(y, self.bpc, d[2], d[3])))
|
||||
}
|
||||
|
||||
fn comps(&self, r: &mut Bits) -> Option<Vec<f64>> {
|
||||
(0..self.n)
|
||||
.map(|i| {
|
||||
let v = r.read(self.bpcomp)?;
|
||||
Some(Self::scale(v, self.bpcomp, self.decode[4 + 2 * i], self.decode[5 + 2 * i]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A shading rasterised into an image node in page space, for `sh` or a shading-pattern fill.
|
||||
/// `sh` is the shading dictionary or stream, `to_page` maps shading space to page space and
|
||||
/// `page_box` limits the image (page space). `None` with a note in `skip` when nothing draws.
|
||||
pub(crate) fn shading_image(file: &File, sh: &Obj, to_page: Affine, page_box: [f64; 4], skip: &mut Vec<String>) -> Option<SvgImage> {
|
||||
let sh_obj = file.resolve(sh);
|
||||
let d = sh_obj.dict()?;
|
||||
let ty = file.get_num(d, "ShadingType").unwrap_or(0.0) as i32;
|
||||
if !to_page.as_coeffs().iter().all(|v| v.is_finite()) || to_page.determinant().abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let tris = match ty {
|
||||
1 => return function_shading(file, d, to_page, page_box, skip),
|
||||
4..=7 => {
|
||||
let Obj::Stream(_, raw) = sh_obj else {
|
||||
skip.push(format!("shading type {ty} (no data)"));
|
||||
return None;
|
||||
};
|
||||
let Some(data) = decode_stream(file, d, raw) else {
|
||||
skip.push(format!("shading type {ty} (filters)"));
|
||||
return None;
|
||||
};
|
||||
let (colors, n) = Colors::new(file, d);
|
||||
let Some(layout) = Layout::read(file, d, n) else {
|
||||
skip.push(format!("shading type {ty} (layout)"));
|
||||
return None;
|
||||
};
|
||||
let mut r = Bits { data: &data, bit: 0 };
|
||||
let tris = match ty {
|
||||
4 => free_form(&mut r, &layout, &colors, to_page),
|
||||
5 => {
|
||||
let per_row = file.get_num(d, "VerticesPerRow").unwrap_or(0.0) as usize;
|
||||
lattice(&mut r, &layout, &colors, to_page, per_row)
|
||||
}
|
||||
_ => patches(&mut r, &layout, &colors, to_page, ty == 7, page_box),
|
||||
};
|
||||
(tris, colors)
|
||||
}
|
||||
_ => {
|
||||
skip.push(format!("shading type {ty}"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (tris, colors) = tris;
|
||||
if tris.is_empty() {
|
||||
return None;
|
||||
}
|
||||
rasterize(&tris, &colors, page_box)
|
||||
}
|
||||
|
||||
/// Type 4: free-form triangles with edge flags (0 starts a triangle; 1 and 2 share an edge with
|
||||
/// the previous one).
|
||||
fn free_form(r: &mut Bits, l: &Layout, colors: &Colors, m: Affine) -> Vec<[V; 3]> {
|
||||
let mut out = vec![];
|
||||
let mut pending: Vec<V> = vec![];
|
||||
let mut last: Option<[V; 3]> = None;
|
||||
while out.len() < MAX_TRIANGLES {
|
||||
let Some(flag) = r.read(l.bpf) else { break };
|
||||
let (Some(p), Some(c)) = (l.point(r), l.comps(r)) else { break };
|
||||
r.align();
|
||||
let v = V { p: m * p, c: colors.value(&c) };
|
||||
// A triangle being started takes the next two vertices whatever their flags.
|
||||
if !pending.is_empty() || flag == 0 {
|
||||
pending.push(v);
|
||||
if pending.len() == 3 {
|
||||
let t = [pending[0], pending[1], pending[2]];
|
||||
out.push(t);
|
||||
last = Some(t);
|
||||
pending.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some([a, b, c]) = last else { continue };
|
||||
let t = if flag == 1 { [b, c, v] } else { [a, c, v] };
|
||||
out.push(t);
|
||||
last = Some(t);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Type 5: a lattice of `per_row` vertices per row, two triangles per cell.
|
||||
fn lattice(r: &mut Bits, l: &Layout, colors: &Colors, m: Affine, per_row: usize) -> Vec<[V; 3]> {
|
||||
if per_row < 2 {
|
||||
return vec![];
|
||||
}
|
||||
let mut rows: Vec<Vec<V>> = vec![];
|
||||
let mut row = vec![];
|
||||
while let (Some(p), Some(c)) = (l.point(r), l.comps(r)) {
|
||||
r.align();
|
||||
row.push(V { p: m * p, c: colors.value(&c) });
|
||||
if row.len() == per_row {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
if rows.len() * per_row > MAX_TRIANGLES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for w in rows.windows(2) {
|
||||
for i in 0..per_row - 1 {
|
||||
let (a, b, c, d) = (w[0][i], w[0][i + 1], w[1][i], w[1][i + 1]);
|
||||
out.push([a, b, c]);
|
||||
out.push([b, d, c]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn bez(p: [Point; 4], t: f64) -> Point {
|
||||
let s = 1.0 - t;
|
||||
let (a, b, c, d) = (s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t);
|
||||
Point::new(a * p[0].x + b * p[1].x + c * p[2].x + d * p[3].x, a * p[0].y + b * p[1].y + c * p[2].y + d * p[3].y)
|
||||
}
|
||||
|
||||
/// Types 6 and 7: Coons and tensor-product patches. The 12 boundary points run around the
|
||||
/// patch from its first corner (`p00 p01 p02 p03 p13 p23 p33 p32 p31 p30 p20 p10`); tensor
|
||||
/// patches add `p11 p12 p22 p21`. Corner colours belong to `p00`, `p03`, `p33` and `p30`.
|
||||
/// Flags 1–3 reuse the previous patch's second, third or fourth side as the first.
|
||||
fn patches(r: &mut Bits, l: &Layout, colors: &Colors, m: Affine, tensor: bool, page_box: [f64; 4]) -> Vec<[V; 3]> {
|
||||
let mut out = vec![];
|
||||
let mut prev: Option<([Point; 12], [[f64; 3]; 4])> = None;
|
||||
while out.len() < MAX_TRIANGLES {
|
||||
let Some(flag) = r.read(l.bpf) else { break };
|
||||
let mut lp = [Point::ZERO; 12];
|
||||
let mut cs = [[0.0; 3]; 4];
|
||||
let first = match (flag, &prev) {
|
||||
(0, _) => 0,
|
||||
(1..=3, Some((pp, pc))) => {
|
||||
let (k, ci) = match flag {
|
||||
1 => (3, 1),
|
||||
2 => (6, 2),
|
||||
_ => (9, 3),
|
||||
};
|
||||
for i in 0..4 {
|
||||
lp[i] = pp[(k + i) % 12];
|
||||
}
|
||||
cs[0] = pc[ci];
|
||||
cs[1] = pc[(ci + 1) % 4];
|
||||
4
|
||||
}
|
||||
_ => break,
|
||||
};
|
||||
let mut ok = true;
|
||||
for p in lp.iter_mut().skip(first) {
|
||||
match l.point(r) {
|
||||
Some(q) => *p = q,
|
||||
None => ok = false,
|
||||
}
|
||||
}
|
||||
let mut inner = [Point::ZERO; 4];
|
||||
if tensor {
|
||||
for p in &mut inner {
|
||||
match l.point(r) {
|
||||
Some(q) => *p = q,
|
||||
None => ok = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in cs.iter_mut().skip(if first == 0 { 0 } else { 2 }) {
|
||||
match l.comps(r) {
|
||||
Some(v) => *c = colors.value(&v),
|
||||
None => ok = false,
|
||||
}
|
||||
}
|
||||
r.align();
|
||||
if !ok {
|
||||
break;
|
||||
}
|
||||
// Shading space → page space before tessellating (the rasteriser works in page space).
|
||||
let lp_page = lp.map(|p| m * p);
|
||||
let inner_page = inner.map(|p| m * p);
|
||||
// The 4×4 grid g[i][j]: row i = 0 is p00 … p03.
|
||||
let mut g = [[Point::ZERO; 4]; 4];
|
||||
g[0] = [lp_page[0], lp_page[1], lp_page[2], lp_page[3]];
|
||||
g[1][3] = lp_page[4];
|
||||
g[2][3] = lp_page[5];
|
||||
g[3] = [lp_page[9], lp_page[8], lp_page[7], lp_page[6]];
|
||||
g[2][0] = lp_page[10];
|
||||
g[1][0] = lp_page[11];
|
||||
g[1][1] = inner_page[0];
|
||||
g[1][2] = inner_page[1];
|
||||
g[2][2] = inner_page[2];
|
||||
g[2][1] = inner_page[3];
|
||||
tessellate(&g, tensor, cs, page_box, &mut out);
|
||||
prev = Some((lp, cs));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A patch → `n`×`n` cells of two triangles; `u` runs along `p00 → p03`, `v` along `p00 → p30`.
|
||||
fn tessellate(g: &[[Point; 4]; 4], tensor: bool, cs: [[f64; 3]; 4], page_box: [f64; 4], out: &mut Vec<[V; 3]>) {
|
||||
let (mut lo, mut hi) = (Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN));
|
||||
for p in g.iter().flatten() {
|
||||
lo = Point::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Point::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
if !(lo.x.is_finite() && hi.x.is_finite() && lo.y.is_finite() && hi.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
// Patches entirely off the page draw nothing.
|
||||
if hi.x < page_box[0] || lo.x > page_box[2] || hi.y < page_box[1] || lo.y > page_box[3] {
|
||||
return;
|
||||
}
|
||||
let size = (hi.x - lo.x).max(hi.y - lo.y).min(page_box[2] - page_box[0] + page_box[3] - page_box[1]);
|
||||
let n = ((size * MAX_SCALE / 6.0).ceil() as usize).clamp(2, 48);
|
||||
let eval = |u: f64, v: f64| -> Point {
|
||||
if tensor {
|
||||
let rows = g.map(|row| bez(row, u));
|
||||
bez(rows, v)
|
||||
} else {
|
||||
let cb = bez(g[0], u);
|
||||
let ct = bez(g[3], u);
|
||||
let cl = bez([g[0][0], g[1][0], g[2][0], g[3][0]], v);
|
||||
let cr = bez([g[0][3], g[1][3], g[2][3], g[3][3]], v);
|
||||
let corner = |a: Point, w: f64| a.to_vec2() * w;
|
||||
let s = cb.to_vec2() * (1.0 - v) + ct.to_vec2() * v + cl.to_vec2() * (1.0 - u) + cr.to_vec2() * u
|
||||
- corner(g[0][0], (1.0 - u) * (1.0 - v))
|
||||
- corner(g[0][3], u * (1.0 - v))
|
||||
- corner(g[3][0], (1.0 - u) * v)
|
||||
- corner(g[3][3], u * v);
|
||||
s.to_point()
|
||||
}
|
||||
};
|
||||
let color = |u: f64, v: f64| -> [f64; 3] {
|
||||
// c0 (0,0), c1 (1,0), c2 (1,1), c3 (0,1).
|
||||
let mut c = [0.0; 3];
|
||||
for (k, ck) in c.iter_mut().enumerate() {
|
||||
*ck = cs[0][k] * (1.0 - u) * (1.0 - v) + cs[1][k] * u * (1.0 - v) + cs[2][k] * u * v + cs[3][k] * (1.0 - u) * v;
|
||||
}
|
||||
c
|
||||
};
|
||||
let mut grid = Vec::with_capacity((n + 1) * (n + 1));
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
|
||||
grid.push(V { p: eval(u, v), c: color(u, v) });
|
||||
}
|
||||
}
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let a = grid[j * (n + 1) + i];
|
||||
let b = grid[j * (n + 1) + i + 1];
|
||||
let c = grid[(j + 1) * (n + 1) + i];
|
||||
let d = grid[(j + 1) * (n + 1) + i + 1];
|
||||
out.push([a, b, c]);
|
||||
out.push([b, d, c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The page-space raster frame for content covering `lo..hi`: origin (top-left, page space),
|
||||
/// scale (pixels per point) and size, or `None` when it misses the page.
|
||||
fn frame(lo: Point, hi: Point, page_box: [f64; 4]) -> Option<(Point, f64, usize, usize)> {
|
||||
let x0 = lo.x.max(page_box[0]);
|
||||
let y0 = lo.y.max(page_box[1]);
|
||||
let x1 = hi.x.min(page_box[2]);
|
||||
let y1 = hi.y.min(page_box[3]);
|
||||
let finite = x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite();
|
||||
if !finite || x1 <= x0 || y1 <= y0 {
|
||||
return None;
|
||||
}
|
||||
let s = (MAX_SIDE / (x1 - x0).max(y1 - y0)).min(MAX_SCALE);
|
||||
let w = ((x1 - x0) * s).ceil().max(1.0) as usize;
|
||||
let h = ((y1 - y0) * s).ceil().max(1.0) as usize;
|
||||
Some((Point::new(x0, y1), s, w, h))
|
||||
}
|
||||
|
||||
/// The image node for an RGBA8 raster with origin `o` (page space, top-left) and `s` px/pt.
|
||||
fn image_node(rgba: Vec<u8>, w: usize, h: usize, o: Point, s: f64) -> SvgImage {
|
||||
SvgImage {
|
||||
name: "Shading".into(),
|
||||
transform: Affine::new([1.0 / s, 0.0, 0.0, -1.0 / s, o.x, o.y]),
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
rgba: std::sync::Arc::new(rgba),
|
||||
opacity: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gouraud-shade the triangles (page space) into an image: 2×2 samples per pixel, later
|
||||
/// triangles over earlier ones.
|
||||
fn rasterize(tris: &[[V; 3]], colors: &Colors, page_box: [f64; 4]) -> Option<SvgImage> {
|
||||
let (mut lo, mut hi) = (Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN));
|
||||
for v in tris.iter().flatten() {
|
||||
if v.p.x.is_finite() && v.p.y.is_finite() {
|
||||
lo = Point::new(lo.x.min(v.p.x), lo.y.min(v.p.y));
|
||||
hi = Point::new(hi.x.max(v.p.x), hi.y.max(v.p.y));
|
||||
}
|
||||
}
|
||||
let (o, s, w, h) = frame(lo, hi, page_box)?;
|
||||
// Supersampled buffer: value + covered.
|
||||
let (sw, sh) = (w * 2, h * 2);
|
||||
let ss = s * 2.0;
|
||||
let mut val = vec![[0.0f32; 3]; sw * sh];
|
||||
let mut cov = vec![false; sw * sh];
|
||||
for t in tris {
|
||||
let q = t.map(|v| Point::new((v.p.x - o.x) * ss, (o.y - v.p.y) * ss));
|
||||
let area = (q[1].x - q[0].x) * (q[2].y - q[0].y) - (q[2].x - q[0].x) * (q[1].y - q[0].y);
|
||||
if area.abs() < 1e-12 || !area.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let bx0 = q.iter().map(|p| p.x).fold(f64::MAX, f64::min).floor().max(0.0) as usize;
|
||||
let by0 = q.iter().map(|p| p.y).fold(f64::MAX, f64::min).floor().max(0.0) as usize;
|
||||
let bx1 = (q.iter().map(|p| p.x).fold(f64::MIN, f64::max).ceil().max(0.0) as usize).min(sw);
|
||||
let by1 = (q.iter().map(|p| p.y).fold(f64::MIN, f64::max).ceil().max(0.0) as usize).min(sh);
|
||||
let eps = 1e-9 * area.abs();
|
||||
for y in by0..by1 {
|
||||
for x in bx0..bx1 {
|
||||
let p = Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let w0 = ((q[1].x - p.x) * (q[2].y - p.y) - (q[2].x - p.x) * (q[1].y - p.y)) / area;
|
||||
let w1 = ((q[2].x - p.x) * (q[0].y - p.y) - (q[0].x - p.x) * (q[2].y - p.y)) / area;
|
||||
let w2 = 1.0 - w0 - w1;
|
||||
if w0 < -eps || w1 < -eps || w2 < -eps {
|
||||
continue;
|
||||
}
|
||||
let i = y * sw + x;
|
||||
for k in 0..3 {
|
||||
val[i][k] = (t[0].c[k] * w0 + t[1].c[k] * w1 + t[2].c[k] * w2) as f32;
|
||||
}
|
||||
cov[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolve: average the covered samples' colours, alpha = covered fraction.
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = [0.0f64; 3];
|
||||
let mut n = 0;
|
||||
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||
let i = (2 * y + dy) * sw + 2 * x + dx;
|
||||
if cov[i] {
|
||||
let c = colors.rgb(val[i].map(f64::from));
|
||||
for k in 0..3 {
|
||||
acc[k] += c[k] as f64;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
let o = (y * w + x) * 4;
|
||||
for k in 0..3 {
|
||||
rgba[o + k] = (acc[k] / n as f64).round() as u8;
|
||||
}
|
||||
rgba[o + 3] = (n * 255 / 4) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(image_node(rgba, w, h, o, s))
|
||||
}
|
||||
|
||||
/// Type 1: `Function(x, y)` over `Domain` `[x0 x1 y0 y1]`, mapped by `Matrix`.
|
||||
fn function_shading(file: &File, d: &Dict, to_page: Affine, page_box: [f64; 4], skip: &mut Vec<String>) -> Option<SvgImage> {
|
||||
let Some(fo) = file.get(d, "Function") else {
|
||||
skip.push("shading type 1 (no function)".into());
|
||||
return None;
|
||||
};
|
||||
let func = Func::read(file, fo);
|
||||
if func == Func::Unsupported {
|
||||
skip.push("shading function".into());
|
||||
return None;
|
||||
}
|
||||
let cs = file.get(d, "ColorSpace").map(|c| color_space(file, c, None)).unwrap_or(Cs::Rgb);
|
||||
let dom = file.get(d, "Domain").map(|x| file.nums(x)).filter(|v| v.len() == 4).unwrap_or_else(|| vec![0.0, 1.0, 0.0, 1.0]);
|
||||
let mtx = file.get(d, "Matrix").map(|x| file.nums(x)).filter(|v| v.len() == 6).map(|v| Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]));
|
||||
let full = to_page * mtx.unwrap_or(Affine::IDENTITY);
|
||||
if full.determinant().abs() < 1e-12 || !full.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let corners = [(dom[0], dom[2]), (dom[1], dom[2]), (dom[1], dom[3]), (dom[0], dom[3])].map(|(x, y)| full * Point::new(x, y));
|
||||
let lo = Point::new(corners.iter().map(|p| p.x).fold(f64::MAX, f64::min), corners.iter().map(|p| p.y).fold(f64::MAX, f64::min));
|
||||
let hi = Point::new(corners.iter().map(|p| p.x).fold(f64::MIN, f64::max), corners.iter().map(|p| p.y).fold(f64::MIN, f64::max));
|
||||
let (o, s, w, h) = frame(lo, hi, page_box)?;
|
||||
let inv = full.inverse();
|
||||
let (xa, xb) = (dom[0].min(dom[1]), dom[0].max(dom[1]));
|
||||
let (ya, yb) = (dom[2].min(dom[3]), dom[2].max(dom[3]));
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// 2×2 samples: anti-aliased domain edges.
|
||||
let mut acc = [0.0f64; 3];
|
||||
let mut n = 0;
|
||||
for (dx, dy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
|
||||
let page = Point::new(o.x + (x as f64 + dx) / s, o.y - (y as f64 + dy) / s);
|
||||
let q = inv * page;
|
||||
if q.x < xa - 1e-9 || q.x > xb + 1e-9 || q.y < ya - 1e-9 || q.y > yb + 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let c = cs.to_rgb(&func.eval(&[q.x, q.y]));
|
||||
for k in 0..3 {
|
||||
acc[k] += c[k];
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
if n > 0 {
|
||||
let i = (y * w + x) * 4;
|
||||
let c = to8(acc.map(|v| v / n as f64));
|
||||
rgba[i..i + 3].copy_from_slice(&c);
|
||||
rgba[i + 3] = (n * 255 / 4) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(image_node(rgba, w, h, o, s))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bits_reader_and_alignment() {
|
||||
let mut r = Bits { data: &[0b1010_0000, 0xFF], bit: 0 };
|
||||
assert_eq!(r.read(3), Some(0b101));
|
||||
r.align();
|
||||
assert_eq!(r.read(8), Some(0xFF));
|
||||
assert_eq!(r.read(1), None);
|
||||
assert_eq!(r.read(33), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bezier_ends() {
|
||||
let p = [Point::new(0.0, 0.0), Point::new(1.0, 2.0), Point::new(2.0, 2.0), Point::new(3.0, 0.0)];
|
||||
assert_eq!(bez(p, 0.0), p[0]);
|
||||
assert_eq!(bez(p, 1.0), p[3]);
|
||||
}
|
||||
}
|
||||
|
|
@ -119,7 +119,8 @@ showpage\n%%EOF\n";
|
|||
fn eps_postscript_subset() {
|
||||
let doc = parse(EPS.as_bytes()).unwrap();
|
||||
assert_eq!((doc.width, doc.height), (100.0, 100.0));
|
||||
assert!(doc.skipped.contains(&"text".to_string()));
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
assert!(shape_names(&doc).contains(&"Text: text".to_string()), "EPS text is drawn: {:?}", shape_names(&doc));
|
||||
let at = |x, y| px(&doc, x, y);
|
||||
assert!(at(25, 75)[0] > 0.99 && at(25, 75)[3] > 0.99, "red box {:?}", at(25, 75));
|
||||
assert!(at(70, 50)[2] > 0.99, "blue disc {:?}", at(70, 50));
|
||||
|
|
@ -152,11 +153,11 @@ fn filters_decode() {
|
|||
// ------------------------------------------------------------------ M13.6: text, images,
|
||||
// patterns, soft masks, blend modes, pages
|
||||
|
||||
type Objs = Vec<(u32, String, Option<Vec<u8>>)>;
|
||||
pub(crate) 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> {
|
||||
pub(crate) 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),
|
||||
|
|
@ -167,12 +168,12 @@ fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec<u8> {
|
|||
pdf(&objs, 1)
|
||||
}
|
||||
|
||||
fn render(doc: &Doc) -> effectcraft_raster::Image {
|
||||
pub(crate) 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 {
|
||||
pub(crate) 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 {
|
||||
|
|
@ -182,7 +183,7 @@ fn coverage(img: &effectcraft_raster::Image, x0: i64, y0: i64, x1: i64, y1: i64)
|
|||
s
|
||||
}
|
||||
|
||||
fn shape_names(doc: &Doc) -> Vec<String> {
|
||||
pub(crate) fn shape_names(doc: &Doc) -> Vec<String> {
|
||||
fn walk(g: &effectcraft_svg::Group, out: &mut Vec<String>) {
|
||||
for c in &g.children {
|
||||
match c {
|
||||
|
|
@ -223,7 +224,7 @@ fn square_cff() -> Vec<u8> {
|
|||
|
||||
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 {
|
||||
pub(crate) fn square_font_objs() -> Objs {
|
||||
vec![
|
||||
(10, SQUARE_FONT.into(), None),
|
||||
(11, "<< /Type /FontDescriptor /FontName /Square /Flags 32 /FontFile3 12 0 R >>".into(), None),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
//! M13.12: transparency groups (isolated, knockout, group alpha) and soft masks on a group's
|
||||
//! combined result.
|
||||
|
||||
use super::*;
|
||||
use crate::tests::{page_pdf, render};
|
||||
|
||||
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
(0..4).all(|i| (a[i] - b[i]).abs() < 0.02)
|
||||
}
|
||||
|
||||
/// A page painting form `Fm` with graphics state `GS` set; the form has `group` as its
|
||||
/// `/Group` entry body and `content`.
|
||||
fn group_page(page: &str, gs: &str, group: &str, content: &str) -> Doc {
|
||||
let bytes = page_pdf(
|
||||
page,
|
||||
&format!("/ExtGState << /GS << {gs} >> /M << /BM /Multiply >> /H << /ca 0.5 >> >> /XObject << /Fm 10 0 R >>"),
|
||||
vec![(
|
||||
10,
|
||||
format!(
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] {group} /Resources << /ExtGState << /M << /BM /Multiply >> /H << /ca 0.5 >> >> >> >>"
|
||||
),
|
||||
Some(content.as_bytes().to_vec()),
|
||||
)],
|
||||
);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_alpha_applies_to_the_combined_result() {
|
||||
let content = "1 0 0 rg 0 0 100 100 re f 0 0 1 rg 50 0 100 100 re f";
|
||||
// A transparency group: the overlap is blue at half opacity (no red shows through).
|
||||
let doc = group_page("/GS gs /Fm Do", "/ca 0.5", "/Group << /S /Transparency >>", content);
|
||||
let img = render(&doc);
|
||||
assert!(near(img.get(75, 50), [0.0, 0.0, 0.5, 0.5]), "{:?}", img.get(75, 50));
|
||||
assert!(near(img.get(25, 50), [0.5, 0.0, 0.0, 0.5]), "{:?}", img.get(25, 50));
|
||||
// A plain form: each rectangle at half opacity.
|
||||
let doc = group_page("/GS gs /Fm Do", "/ca 0.5", "", content);
|
||||
let img = render(&doc);
|
||||
assert!(near(img.get(75, 50), [0.25, 0.0, 0.5, 0.75]), "{:?}", img.get(75, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knockout_groups_composite_with_the_initial_backdrop() {
|
||||
// Red, then half-opaque blue over it: knocked out, the overlap shows only the blue.
|
||||
let content = "1 0 0 rg 0 0 100 100 re f /H gs 0 0 1 rg 50 0 100 100 re f";
|
||||
let ko = group_page("/Fm Do", "", "/Group << /S /Transparency /I true /K true >>", content);
|
||||
let img = render(&ko);
|
||||
assert!(near(img.get(75, 50), [0.0, 0.0, 0.5, 0.5]), "knockout {:?}", img.get(75, 50));
|
||||
assert!(near(img.get(25, 50), [1.0, 0.0, 0.0, 1.0]), "{:?}", img.get(25, 50));
|
||||
let plain = group_page("/Fm Do", "", "/Group << /S /Transparency /I true >>", content);
|
||||
let img = render(&plain);
|
||||
assert!(near(img.get(75, 50), [0.5, 0.0, 0.5, 1.0]), "plain {:?}", img.get(75, 50));
|
||||
// Non-isolated knockout over a white page: the overlap is half blue over the page.
|
||||
let ko_bd = group_page("1 g 0 0 200 100 re f /Fm Do", "", "/Group << /S /Transparency /K true >>", content);
|
||||
let img = render(&ko_bd);
|
||||
assert!(near(img.get(75, 50), [0.5, 0.5, 1.0, 1.0]), "non-isolated knockout {:?}", img.get(75, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_and_non_isolated_groups_with_blend_modes() {
|
||||
// Yellow page; the group multiplies cyan over it at half group opacity.
|
||||
let content = "/M gs 0 1 1 rg 0 0 200 100 re f";
|
||||
let non = group_page("1 1 0 rg 0 0 200 100 re f /GS gs /Fm Do", "/ca 0.5", "/Group << /S /Transparency >>", content);
|
||||
let img = render(&non);
|
||||
// yellow × cyan = green, half over yellow.
|
||||
assert!(near(img.get(100, 50), [0.5, 1.0, 0.0, 1.0]), "non-isolated {:?}", img.get(100, 50));
|
||||
let iso = group_page("1 1 0 rg 0 0 200 100 re f /GS gs /Fm Do", "/ca 0.5", "/Group << /S /Transparency /I true >>", content);
|
||||
let img = render(&iso);
|
||||
// Isolated: multiply against nothing = cyan, half over yellow.
|
||||
assert!(near(img.get(100, 50), [0.5, 1.0, 0.5, 1.0]), "isolated {:?}", img.get(100, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_modes_inside_clips_reach_the_backdrop() {
|
||||
let bytes = page_pdf("1 1 0 rg 0 0 200 100 re f q 0 0 100 100 re W n /M gs 0 1 1 rg 0 0 200 100 re f Q", "/ExtGState << /M << /BM /Multiply >> >>", vec![]);
|
||||
let img = render(&parse(&bytes).unwrap());
|
||||
assert!(near(img.get(50, 50), [0.0, 1.0, 0.0, 1.0]), "{:?}", img.get(50, 50));
|
||||
assert!(near(img.get(150, 50), [1.0, 1.0, 0.0, 1.0]), "{:?}", img.get(150, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_mask_applies_to_the_masked_group_result() {
|
||||
// Two overlapping opaque rectangles under a 50 % grey luminosity mask: the overlap is
|
||||
// the top rectangle at half opacity (the mask applies to their composite).
|
||||
let bytes = page_pdf(
|
||||
"q /GS1 gs 1 0 0 rg 0 0 100 100 re f 0 0 1 rg 50 0 100 100 re f Q",
|
||||
"/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"0.5 g 0 0 200 100 re f".to_vec()),
|
||||
)],
|
||||
);
|
||||
let img = render(&parse(&bytes).unwrap());
|
||||
let p = img.get(75, 50);
|
||||
assert!((p[3] - 0.5).abs() < 0.02 && p[0] < 0.02 && (p[2] - 0.5).abs() < 0.02, "{p:?}");
|
||||
}
|
||||
|
||||
/// The ink bounding box `(x0, y0, x1, y1)` of a render (pixels with alpha > 0.5).
|
||||
fn ink_box(img: &effectcraft_raster::Image) -> (i64, i64, i64, i64) {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN);
|
||||
for y in 0..img.height as i64 {
|
||||
for x in 0..img.width as i64 {
|
||||
if img.get(x, y)[3] > 0.5 {
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
(x0, y0, x1, y1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_metrics_w2_and_dw2() {
|
||||
use skrifa::MetadataProvider;
|
||||
let inter = effectcraft_text::fonts::INTER_REGULAR.to_vec();
|
||||
let font = skrifa::FontRef::new(&inter).unwrap();
|
||||
let h = font.charmap().map('H').unwrap().to_u32();
|
||||
let doc = |desc_extra: &str| {
|
||||
let bytes = page_pdf(
|
||||
&format!("BT /F1 20 Tf 100 95 Td <{h:04X}{h:04X}> Tj ET"),
|
||||
"/Font << /F1 10 0 R >>",
|
||||
vec![
|
||||
(10, "<< /Type /Font /Subtype /Type0 /BaseFont /Inter /Encoding /Identity-V /DescendantFonts [13 0 R] >>".into(), None),
|
||||
(
|
||||
13,
|
||||
format!(
|
||||
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Inter /CIDToGIDMap /Identity /W [{h} [700]] {desc_extra} /FontDescriptor 11 0 R >>"
|
||||
),
|
||||
None,
|
||||
),
|
||||
(11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None),
|
||||
(12, "<< >>".into(), Some(inter.clone())),
|
||||
],
|
||||
);
|
||||
let d = parse(&bytes).unwrap();
|
||||
assert!(d.skipped.is_empty(), "{:?}", d.skipped);
|
||||
ink_box(&render(&d))
|
||||
};
|
||||
// Defaults: an advance of 1 em down; the vertical origin (350, 880) from the horizontal one.
|
||||
let base = doc("");
|
||||
// /W2: a 2 em advance and vx 250.
|
||||
let w2 = doc(&format!("/W2 [{h} [-2000 250 880]]"));
|
||||
assert_eq!(w2.1, base.1, "the first glyph's top stays: {base:?} {w2:?}");
|
||||
assert!(((w2.3 - base.3) - 20).abs() <= 1, "second glyph 20 pt lower: {base:?} {w2:?}");
|
||||
assert!(((w2.0 - base.0) - 2).abs() <= 1, "vx 250 vs 350 → 2 pt right: {base:?} {w2:?}");
|
||||
// The range form gives the same; /DW2 with vy 780 draws the glyphs 2 pt higher.
|
||||
let range = doc(&format!("/W2 [{h} {h} -2000 250 880]"));
|
||||
assert_eq!(range, w2);
|
||||
let dw2 = doc("/DW2 [780 -1000]");
|
||||
assert!(((base.1 - dw2.1) - 2).abs() <= 1, "vy 780 → 2 pt higher: {base:?} {dw2:?}");
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
//! M13.12: CCITT images in PDF pages, EPS text and parser robustness over truncated and
|
||||
//! corrupt inputs (generated fixtures).
|
||||
|
||||
use super::*;
|
||||
use crate::tests::{coverage, page_pdf, render, shape_names};
|
||||
|
||||
fn eps(body: &[u8]) -> Doc {
|
||||
let mut b = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n".to_vec();
|
||||
b.extend_from_slice(body);
|
||||
let doc = parse(&b).unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
doc
|
||||
}
|
||||
|
||||
/// The ink columns `(x0, x1)` of a render (alpha > 0.5).
|
||||
fn ink_x(img: &effectcraft_raster::Image) -> (i64, i64) {
|
||||
let (mut x0, mut x1) = (i64::MAX, i64::MIN);
|
||||
for y in 0..img.height as i64 {
|
||||
for x in 0..img.width as i64 {
|
||||
if img.get(x, y)[3] > 0.5 {
|
||||
(x0, x1) = (x0.min(x), x1.max(x));
|
||||
}
|
||||
}
|
||||
}
|
||||
(x0, x1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_text_with_bundled_fonts_and_show_variants() {
|
||||
// Not embedded: drawn with the bundled stand-in, like the same text in a PDF.
|
||||
let doc = eps(b"/Helvetica findfont 40 scalefont setfont 10 30 moveto (Hi) show\n");
|
||||
assert_eq!(shape_names(&doc), vec!["Text: Hi"]);
|
||||
let pdf = parse(&page_pdf("BT /F1 40 Tf 10 30 Td (Hi) Tj ET", "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>", vec![])).unwrap();
|
||||
let (a, b) = (render(&doc), render(&pdf));
|
||||
let (ia, ib) = (coverage(&a, 0, 0, 200, 100), coverage(&b, 0, 0, 200, 100));
|
||||
assert!(ia > 100.0 && (ia - ib).abs() < 1.0, "eps {ia} vs pdf {ib}");
|
||||
assert_eq!(ink_x(&a), ink_x(&b));
|
||||
// ashow spreads the letters; widthshow widens spaces only; selectfont; stringwidth centres.
|
||||
let plain = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (HHH) show\n")));
|
||||
let spread = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto 10 0 (HHH) ashow\n")));
|
||||
assert_eq!(spread.0, plain.0);
|
||||
assert!(((spread.1 - plain.1) - 20).abs() <= 1, "{plain:?} {spread:?}");
|
||||
let ws = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto 15 0 72 (HHH) widthshow\n")));
|
||||
assert!(((ws.1 - plain.1) - 30).abs() <= 1, "{plain:?} {ws:?}");
|
||||
let centred = ink_x(&render(&eps(b"/Helvetica 30 selectfont 100 (HHH) stringwidth pop 2 div sub 30 moveto (HHH) show\n")));
|
||||
let mid = (centred.0 + centred.1) as f64 / 2.0;
|
||||
assert!((mid - 100.0).abs() < 3.0, "{centred:?}");
|
||||
// xshow: explicit advances.
|
||||
let xs = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (HH) [50 0] xshow\n")));
|
||||
let one = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (H) show\n")));
|
||||
assert!(((xs.1 - one.1) - 50).abs() <= 1, "{one:?} {xs:?}");
|
||||
// makefont mirrors; charpath clips; colour applies.
|
||||
let doc = eps(b"1 0 0 setrgbcolor /Helvetica findfont [40 0 0 40 0 0] makefont setfont 10 30 moveto (I) false charpath clip 0 0 200 100 rectfill\n");
|
||||
let img = render(&doc);
|
||||
assert!(coverage(&img, 0, 0, 200, 100) > 50.0);
|
||||
assert!(coverage(&img, 60, 0, 200, 100) < 1.0, "only the glyph is painted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_text_with_an_embedded_type1_font_and_reencoding() {
|
||||
// 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 raw = crate::type1::write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(66, "box")]);
|
||||
// Give the program a name.
|
||||
let mut font = vec![];
|
||||
let nl = raw.iter().position(|c| *c == b'\n').unwrap() + 1;
|
||||
font.extend_from_slice(&raw[..nl]);
|
||||
font.extend_from_slice(b"12 dict begin /FontName /BoxFont def /FontType 1 def\n");
|
||||
font.extend_from_slice(&raw[nl..]);
|
||||
let mut body = font.clone();
|
||||
body.extend_from_slice(b"\n/BoxFont findfont 100 scalefont setfont 0 0 moveto (BB) show\n");
|
||||
let doc = eps(&body);
|
||||
let img = render(&doc);
|
||||
// Boxes 50..550 / 650..1150 font units at size 100: x 5..55 and 65..115, y 0..50.
|
||||
for (x, on) in [(30, true), (60, false), (90, true), (120, false)] {
|
||||
assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}");
|
||||
}
|
||||
// Re-encoded copy (`forall` / `definefont`): code 65 → box.
|
||||
let mut body = font;
|
||||
body.extend_from_slice(
|
||||
b"\n/BoxFont findfont dup length dict begin { 1 index /FID ne { def } { pop pop } ifelse } forall \
|
||||
/Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for Encoding 65 /box put currentdict end \
|
||||
/BoxA exch definefont pop /BoxA findfont 100 scalefont setfont 0 0 moveto (AB) show\n",
|
||||
);
|
||||
let img = render(&eps(&body));
|
||||
assert!(img.get(30, 75)[3] > 0.99, "A is the box now");
|
||||
assert!(img.get(90, 75)[3] < 0.01, "B is unencoded");
|
||||
}
|
||||
|
||||
/// An image XObject of `img` (black = true) CCITT-encoded with `k`, drawn over the page.
|
||||
fn ccitt_page(img: &[Vec<bool>], k: i64, mask: bool) -> Doc {
|
||||
let (w, h) = (img[0].len(), img.len());
|
||||
let data = crate::ccitt::tests::encode(img, k);
|
||||
let kind = if mask { "/ImageMask true" } else { "/ColorSpace /DeviceGray /BitsPerComponent 1" };
|
||||
let bytes = page_pdf(
|
||||
"0 0 1 rg q 200 0 0 100 0 0 cm /Im Do Q",
|
||||
"/XObject << /Im 10 0 R >>",
|
||||
vec![(
|
||||
10,
|
||||
format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {w} /Height {h} {kind} /Filter /CCITTFaxDecode /DecodeParms << /K {k} /Columns {w} /Rows {h} /EndOfLine {} >> >>",
|
||||
k >= 0
|
||||
),
|
||||
Some(data),
|
||||
)],
|
||||
);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccitt_images_and_stencil_masks() {
|
||||
// Left half black, a white bar on rows 4..6 (20×10 pixels → 10 PDF units per pixel).
|
||||
let img: Vec<Vec<bool>> = (0..10).map(|y| (0..20).map(|x| x < 10 && !(4..6).contains(&y)).collect()).collect();
|
||||
for k in [-1, 0, 2] {
|
||||
let doc = ccitt_page(&img, k, false);
|
||||
let r = render(&doc);
|
||||
let px = |x: usize, y: usize| r.get(x as i64 * 10 + 5, y as i64 * 10 + 5);
|
||||
assert!(px(2, 2)[0] < 0.01 && px(2, 2)[3] > 0.99, "K {k}: black {:?}", px(2, 2));
|
||||
assert!(px(15, 2)[0] > 0.99, "K {k}: white {:?}", px(15, 2));
|
||||
assert!(px(2, 4)[0] > 0.99, "K {k}: the white bar {:?}", px(2, 4));
|
||||
// Stencil mask: black (sample 0) pixels paint the fill colour, the rest is clear.
|
||||
let doc = ccitt_page(&img, k, true);
|
||||
let r = render(&doc);
|
||||
let px = |x: usize, y: usize| r.get(x as i64 * 10 + 5, y as i64 * 10 + 5);
|
||||
assert!(px(2, 2)[2] > 0.99 && px(2, 2)[3] > 0.99, "K {k}: mask {:?}", px(2, 2));
|
||||
assert_eq!(px(15, 2)[3], 0.0, "K {k}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte-level mutations of `bytes`: truncations, flipped bytes, deleted and duplicated
|
||||
/// spans (deterministic).
|
||||
fn mutations(bytes: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = vec![];
|
||||
let n = bytes.len();
|
||||
for k in 1..24 {
|
||||
out.push(bytes[..n * k / 24].to_vec());
|
||||
}
|
||||
let mut s = 0x9E37_79B9u32;
|
||||
let mut rnd = move || {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
s
|
||||
};
|
||||
for _ in 0..60 {
|
||||
let mut b = bytes.to_vec();
|
||||
for _ in 0..1 + rnd() % 6 {
|
||||
let i = rnd() as usize % n;
|
||||
b[i] = match rnd() % 4 {
|
||||
0 => b[i] ^ 0xFF,
|
||||
1 => b'0' + (rnd() % 10) as u8,
|
||||
2 => b"[]<>()/{}% \n"[(rnd() % 12) as usize],
|
||||
_ => rnd() as u8,
|
||||
};
|
||||
}
|
||||
out.push(b);
|
||||
}
|
||||
for _ in 0..30 {
|
||||
let mut b = bytes.to_vec();
|
||||
let i = rnd() as usize % n;
|
||||
let len = (rnd() as usize % 64).min(n - i);
|
||||
if rnd() % 2 == 0 {
|
||||
b.drain(i..i + len);
|
||||
} else {
|
||||
let span = b[i..i + len].to_vec();
|
||||
b.splice(i..i, span);
|
||||
}
|
||||
out.push(b);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse and render every page of every mutation: errors are fine, panics are not.
|
||||
fn survive(name: &str, bytes: &[u8]) {
|
||||
for (i, m) in mutations(bytes).into_iter().enumerate() {
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
let pages = page_count(&m);
|
||||
for p in 0..pages.clamp(1, 3) {
|
||||
if let Ok(doc) = parse_page(&m, p) {
|
||||
let (w, h) = doc.pixel_size();
|
||||
let s = 64.0 / (w.max(h).max(1) as f64);
|
||||
let _ = effectcraft_svg::rasterize(&doc, 64, 64, s);
|
||||
let _ = layer_names(&doc);
|
||||
}
|
||||
}
|
||||
});
|
||||
assert!(r.is_ok(), "{name}: mutation {i} panicked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_corrupt_pdfs_never_panic() {
|
||||
survive("sample", &crate::tests::sample_pdf(false));
|
||||
survive("object streams", &crate::tests::sample_pdf(true));
|
||||
let mut fonts = crate::tests::square_font_objs();
|
||||
fonts.push((13, "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(vec![255; 12])));
|
||||
survive(
|
||||
"text and images",
|
||||
&page_pdf("BT /F1 40 Tf 10 10 Td (AAA) Tj ET q 50 0 0 50 0 0 cm /Im Do Q", "/Font << /F1 10 0 R >> /XObject << /Im 13 0 R >>", fonts),
|
||||
);
|
||||
let img: Vec<Vec<bool>> = (0..8).map(|y| (0..16).map(|x| (x + y) % 3 == 0).collect()).collect();
|
||||
let data = crate::ccitt::tests::encode(&img, -1);
|
||||
survive(
|
||||
"ccitt",
|
||||
&page_pdf(
|
||||
"q 100 0 0 50 0 0 cm /Im Do Q",
|
||||
"/XObject << /Im 10 0 R >>",
|
||||
vec![(
|
||||
10,
|
||||
"<< /Type /XObject /Subtype /Image /Width 16 /Height 8 /ImageMask true /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 16 >> >>".into(),
|
||||
Some(data),
|
||||
)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_structures_return_errors_or_draw_nothing() {
|
||||
// Not a PDF / EPS at all, empty, and headers alone.
|
||||
for b in [&b""[..], b"%PDF-", b"%PDF-1.7\n%%EOF", b"%!PS-Adobe-3.0 EPSF-3.0\n", b"\xC5\xD0\xD3\xC6", b"hello"] {
|
||||
let _ = parse(b);
|
||||
let _ = page_count(b);
|
||||
}
|
||||
assert_eq!(parse(b"hello"), Err(Error::NotVector));
|
||||
assert_eq!(parse(b"%PDF-1.7\n%%EOF"), Err(Error::NoPages));
|
||||
// A page tree that refers to itself, a content stream that is a reference loop, deep
|
||||
// form recursion and absurd numbers.
|
||||
let objs: crate::tests::Objs = vec![
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
|
||||
(2, "<< /Type /Pages /Kids [2 0 R 3 0 R] /Count 1 >>".into(), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 1e300 -1e300] /Contents 5 0 R /Resources << /XObject << /F 4 0 R >> >> >>".into(), None),
|
||||
(
|
||||
4,
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /F 4 0 R >> >> >>".into(),
|
||||
Some(b"/F Do 1e308 1e308 m 1e308 0 l f".to_vec()),
|
||||
),
|
||||
(5, "<< >>".into(), Some(b"/F Do q q q q Q Q Q Q Q Q 99999999999 w [1 0 0 1 0 0] cm".to_vec())),
|
||||
];
|
||||
let bytes = crate::write::pdf(&objs, 1);
|
||||
if let Ok(doc) = parse(&bytes) {
|
||||
let _ = effectcraft_svg::rasterize(&doc, 32, 32, 1e-300);
|
||||
}
|
||||
// Images with absurd sizes and filters.
|
||||
let bytes = page_pdf(
|
||||
"/A Do /B Do /C Do /D Do",
|
||||
"/XObject << /A 10 0 R /B 11 0 R /C 12 0 R /D 13 0 R >>",
|
||||
vec![
|
||||
(10, "<< /Subtype /Image /Width 100000 /Height 100000 /BitsPerComponent 8 /ColorSpace /DeviceRGB >>".into(), Some(vec![0; 4])),
|
||||
(11, "<< /Subtype /Image /Width 4 /Height 4 /BitsPerComponent 7 /ColorSpace /DeviceRGB >>".into(), Some(vec![0; 4])),
|
||||
(
|
||||
12,
|
||||
"<< /Subtype /Image /Width 4 /Height 4 /BitsPerComponent 1 /ImageMask true /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 0 >> >>"
|
||||
.into(),
|
||||
Some(vec![0xFF; 4]),
|
||||
),
|
||||
(13, "<< /Subtype /Image /Width 4 /Height 4 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 300 <00>] >>".into(), Some(vec![9; 16])),
|
||||
],
|
||||
);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
let _ = render(&doc);
|
||||
assert!(!doc.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_corrupt_eps_never_panic() {
|
||||
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n\
|
||||
/sq { newpath 0 0 moveto 10 0 lineto 10 10 lineto closepath } bind def\n\
|
||||
1 0 0 setrgbcolor 0 1 9 { gsave dup 10 mul 0 translate sq fill grestore } for\n\
|
||||
/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hello) show\n\
|
||||
[1 2] 0 setdash 0 0 moveto 100 100 lineto stroke\n\
|
||||
1 1 1 { pop } repeat { exit } loop 0 0 50 0 360 arc closepath eofill\n%%EOF\n";
|
||||
survive("eps", eps);
|
||||
// Stack underflow, unbalanced procedures, runaway loops and huge repeats.
|
||||
for prog in [
|
||||
&b"pop pop pop add mul def"[..],
|
||||
b"{ { { { {",
|
||||
b"} } ] >> )",
|
||||
b"{ } loop",
|
||||
b"0 1 1e9 { pop } for",
|
||||
b"1e9 { 1 } repeat",
|
||||
b"/a { a } def a",
|
||||
b"(unterminated",
|
||||
b"<abc",
|
||||
b"100 100 scale 0 0 moveto 1e308 1e308 lineto stroke",
|
||||
] {
|
||||
let mut b = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n".to_vec();
|
||||
b.extend_from_slice(prog);
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
if let Ok(doc) = parse(&b) {
|
||||
let _ = effectcraft_svg::rasterize(&doc, 10, 10, 1.0);
|
||||
}
|
||||
});
|
||||
assert!(r.is_ok(), "{}", String::from_utf8_lossy(prog));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
//! M13.12: function-based and mesh shadings (generated fixtures).
|
||||
|
||||
use super::*;
|
||||
use crate::tests::{Objs, page_pdf, render};
|
||||
|
||||
/// Mesh vertex data: 8-bit flags, 16-bit coordinates (Decode `[0 65535 0 65535 …]`, so raw
|
||||
/// values are page units) and 8-bit components.
|
||||
#[derive(Default)]
|
||||
struct Mesh(Vec<u8>);
|
||||
|
||||
impl Mesh {
|
||||
fn flag(&mut self, f: u8) -> &mut Self {
|
||||
self.0.push(f);
|
||||
self
|
||||
}
|
||||
fn pt(&mut self, x: u16, y: u16) -> &mut Self {
|
||||
self.0.extend_from_slice(&x.to_be_bytes());
|
||||
self.0.extend_from_slice(&y.to_be_bytes());
|
||||
self
|
||||
}
|
||||
fn c(&mut self, c: &[u8]) -> &mut Self {
|
||||
self.0.extend_from_slice(c);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const RGB_DECODE: &str = "/Decode [0 65535 0 65535 0 1 0 1 0 1]";
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 3], tol: f32) -> bool {
|
||||
a[3] > 0.99 && (0..3).all(|i| (a[i] - b[i]).abs() < tol)
|
||||
}
|
||||
|
||||
fn sh_page(shading: &str, data: Vec<u8>) -> Doc {
|
||||
let bytes = page_pdf("/Sh sh", "/Shading << /Sh 10 0 R >>", vec![(10, shading.into(), Some(data))]);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_form_triangle_mesh_with_edge_flags() {
|
||||
let mut m = Mesh::default();
|
||||
// Triangle (20,20) red, (80,20) green, (20,80) blue; then flag 1: (80,20) (20,80) (80,80) white.
|
||||
m.flag(0).pt(20, 20).c(&[255, 0, 0]);
|
||||
m.flag(0).pt(80, 20).c(&[0, 255, 0]);
|
||||
m.flag(0).pt(20, 80).c(&[0, 0, 255]);
|
||||
m.flag(1).pt(80, 80).c(&[255, 255, 255]);
|
||||
// Flag 2 off the shared edge (a, c): (80,20) (80,80) → (140,20) black.
|
||||
m.flag(2).pt(140, 20).c(&[0, 0, 0]);
|
||||
let doc = sh_page(&format!("<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 {RGB_DECODE} >>"), m.0);
|
||||
let img = render(&doc);
|
||||
let at = |x: i64, y: i64| img.get(x, 99 - y);
|
||||
assert!(close(at(21, 21), [1.0, 0.0, 0.0], 0.06), "{:?}", at(21, 21));
|
||||
// Centroid of the first triangle: the average of its colours.
|
||||
assert!(close(at(40, 40), [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], 0.04), "{:?}", at(40, 40));
|
||||
assert!(close(at(79, 79), [1.0, 1.0, 1.0], 0.06), "{:?}", at(79, 79));
|
||||
// Third triangle (a = (80,20) green, c = (80,80) white, (140,20) black).
|
||||
assert!(close(at(135, 22), [0.0, 0.0, 0.0], 0.1), "{:?}", at(135, 22));
|
||||
// Outside the mesh: nothing.
|
||||
assert_eq!(at(10, 50)[3], 0.0);
|
||||
assert_eq!(at(150, 90)[3], 0.0);
|
||||
// The shading is an image node (no Gouraud primitive in the render tree).
|
||||
assert!(crate::tests::shape_names(&doc).contains(&"Shading".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lattice_mesh_with_a_function() {
|
||||
// Two rows of three vertices; t = 0 at the left, 1 at the right; grey ramp function.
|
||||
let mut m = Mesh::default();
|
||||
for y in [10u16, 90] {
|
||||
for (x, t) in [(10u16, 0u8), (100, 128), (190, 255)] {
|
||||
m.pt(x, y).c(&[t]);
|
||||
}
|
||||
}
|
||||
let doc = sh_page(
|
||||
"<< /ShadingType 5 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /VerticesPerRow 3 /Decode [0 65535 0 65535 0 1] \
|
||||
/Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 1 1] /N 1 >> >>",
|
||||
m.0,
|
||||
);
|
||||
let img = render(&doc);
|
||||
let at = |x: i64, y: i64| img.get(x, 99 - y);
|
||||
for x in [20i64, 60, 100, 140, 180] {
|
||||
let want = (x as f32 + 0.5 - 10.0) / 180.0;
|
||||
let p = at(x, 50);
|
||||
assert!(p[3] > 0.99 && (p[0] - want).abs() < 0.03 && (p[1] - p[0]).abs() < 0.01, "x {x}: {p:?} want {want}");
|
||||
}
|
||||
assert_eq!(at(5, 50)[3], 0.0);
|
||||
}
|
||||
|
||||
/// A straight-sided rectangle as the 12 boundary points from `(x0, y0)` up the left side,
|
||||
/// along the top, down the right side and back along the bottom.
|
||||
fn rect_loop(x0: u16, y0: u16, x1: u16, y1: u16) -> Vec<(u16, u16)> {
|
||||
let lerp = |a: u16, b: u16, k: u16| ((a as u32 * (3 - k as u32) + b as u32 * k as u32) / 3) as u16;
|
||||
let mut v = vec![];
|
||||
for k in 0..3 {
|
||||
v.push((x0, lerp(y0, y1, k)));
|
||||
}
|
||||
for k in 0..3 {
|
||||
v.push((lerp(x0, x1, k), y1));
|
||||
}
|
||||
for k in 0..3 {
|
||||
v.push((x1, lerp(y1, y0, k)));
|
||||
}
|
||||
for k in 0..3 {
|
||||
v.push((lerp(x1, x0, k), y0));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn patch_doc(tensor: bool) -> Doc {
|
||||
let mut m = Mesh::default();
|
||||
// Patch 1 over x 100..140, y 20..80: corners (100,20) red, (100,80) green, (140,80) blue,
|
||||
// (140,20) black.
|
||||
m.flag(0);
|
||||
let l1 = rect_loop(100, 20, 140, 80);
|
||||
for (x, y) in &l1 {
|
||||
m.pt(*x, *y);
|
||||
}
|
||||
if tensor {
|
||||
// Interior points p11 p12 p22 p21 at the thirds.
|
||||
for (x, y) in [(113, 40), (113, 60), (127, 60), (127, 40)] {
|
||||
m.pt(x, y);
|
||||
}
|
||||
}
|
||||
m.c(&[255, 0, 0]).c(&[0, 255, 0]).c(&[0, 0, 255]).c(&[0, 0, 0]);
|
||||
// Patch 2 (flag 2): shares patch 1's third side (140,80) → (140,20); then (180,20) white,
|
||||
// (180,80) red.
|
||||
m.flag(2);
|
||||
// The 8 new points: from (140,20) along the bottom to (180,20), up to (180,80), back
|
||||
// along the top towards (140,80).
|
||||
let new_pts = [(153, 20), (167, 20), (180, 20), (180, 40), (180, 60), (180, 80), (167, 80), (153, 80)];
|
||||
for (x, y) in new_pts {
|
||||
m.pt(x, y);
|
||||
}
|
||||
if tensor {
|
||||
for (x, y) in [(153, 60), (153, 40), (167, 40), (167, 60)] {
|
||||
m.pt(x, y);
|
||||
}
|
||||
}
|
||||
m.c(&[255, 255, 255]).c(&[255, 0, 0]);
|
||||
let ty = if tensor { 7 } else { 6 };
|
||||
sh_page(&format!("<< /ShadingType {ty} /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 {RGB_DECODE} >>"), m.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coons_and_tensor_patch_meshes() {
|
||||
for tensor in [false, true] {
|
||||
let doc = patch_doc(tensor);
|
||||
let img = render(&doc);
|
||||
let at = |x: i64, y: i64| img.get(x, 99 - y);
|
||||
let k = if tensor { "tensor" } else { "coons" };
|
||||
assert!(close(at(100, 20), [1.0, 0.0, 0.0], 0.08), "{k} {:?}", at(100, 20));
|
||||
assert!(close(at(100, 79), [0.0, 1.0, 0.0], 0.08), "{k} {:?}", at(100, 79));
|
||||
assert!(close(at(139, 79), [0.0, 0.0, 1.0], 0.08), "{k} {:?}", at(139, 79));
|
||||
// Patch centre: the average of the corners.
|
||||
assert!(close(at(120, 50), [0.25, 0.25, 0.25], 0.04), "{k} {:?}", at(120, 50));
|
||||
// Patch 2 inherits blue / black on its shared side.
|
||||
assert!(close(at(179, 20), [1.0, 1.0, 1.0], 0.08), "{k} {:?}", at(179, 20));
|
||||
assert!(close(at(179, 79), [1.0, 0.0, 0.0], 0.08), "{k} {:?}", at(179, 79));
|
||||
assert!(close(at(141, 22), [0.0, 0.0, 0.0], 0.1), "{k} {:?}", at(141, 22));
|
||||
assert_eq!(at(90, 50)[3], 0.0);
|
||||
assert_eq!(at(190, 50)[3], 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_coons_patch_follows_its_boundary() {
|
||||
// A patch whose top side bulges up to y = 95 between x 40 and 60.
|
||||
let mut m = Mesh::default();
|
||||
m.flag(0);
|
||||
let pts = [(20, 20), (20, 40), (20, 60), (20, 80), (40, 107), (60, 107), (80, 80), (80, 60), (80, 40), (80, 20), (60, 20), (40, 20)];
|
||||
for (x, y) in pts {
|
||||
m.pt(x, y);
|
||||
}
|
||||
m.c(&[255, 0, 0]).c(&[255, 0, 0]).c(&[255, 0, 0]).c(&[255, 0, 0]);
|
||||
let doc = sh_page(&format!("<< /ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 {RGB_DECODE} >>"), m.0);
|
||||
let img = render(&doc);
|
||||
let at = |x: i64, y: i64| img.get(x, 99 - y);
|
||||
// The top curve peaks at 80 + 0.75·27 ≈ 100 at x = 50.
|
||||
assert!(at(50, 95)[3] > 0.99, "{:?}", at(50, 95));
|
||||
assert_eq!(at(22, 95)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_based_shadings_calculator_and_sampled() {
|
||||
let bytes = page_pdf(
|
||||
"/A sh /B sh",
|
||||
"/Shading << /A 10 0 R /B 11 0 R >>",
|
||||
vec![
|
||||
(10, "<< /ShadingType 1 /ColorSpace /DeviceRGB /Matrix [100 0 0 50 0 0] /Function 12 0 R >>".into(), None),
|
||||
(11, "<< /ShadingType 1 /ColorSpace /DeviceRGB /Domain [0 1 0 1] /Matrix [100 0 0 50 100 50] /Function 13 0 R >>".into(), None),
|
||||
(12, "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] >>".into(), Some(b"{ 0 }".to_vec())),
|
||||
(
|
||||
13,
|
||||
"<< /FunctionType 0 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Size [2 2] /BitsPerSample 8 >>".into(),
|
||||
Some(vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]),
|
||||
),
|
||||
],
|
||||
);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
let img = render(&doc);
|
||||
let at = |x: i64, y: i64| img.get(x, 99 - y);
|
||||
// Calculator: (x, y) → (x, y, 0).
|
||||
let p = at(90, 10);
|
||||
assert!(close(p, [0.905, 0.21, 0.0], 0.03), "{p:?}");
|
||||
// Only the domain is painted.
|
||||
assert_eq!(at(50, 75)[3], 0.0);
|
||||
// Sampled 2-in function, bilinear: red (0,0), green (1,0), blue (0,1), white (1,1).
|
||||
assert!(close(at(150, 75), [0.5, 0.5, 0.5], 0.03), "{:?}", at(150, 75));
|
||||
assert!(close(at(100, 50), [1.0, 0.0, 0.0], 0.04), "{:?}", at(100, 50));
|
||||
assert!(close(at(199, 99), [1.0, 1.0, 1.0], 0.04), "{:?}", at(199, 99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_shading_pattern_fills_a_path() {
|
||||
let mut m = Mesh::default();
|
||||
m.flag(0).pt(0, 0).c(&[255, 0, 0]);
|
||||
m.flag(0).pt(200, 0).c(&[255, 0, 0]);
|
||||
m.flag(0).pt(0, 200).c(&[255, 0, 0]);
|
||||
let objs: Objs = vec![
|
||||
(10, "<< /PatternType 2 /Shading 11 0 R >>".into(), None),
|
||||
(11, format!("<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 {RGB_DECODE} >>"), Some(m.0)),
|
||||
];
|
||||
let bytes = page_pdf("/Pattern cs /P scn 10 10 30 30 re f", "/Pattern << /P 10 0 R >>", objs);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
let img = render(&doc);
|
||||
let at = |x: i64, y: i64| img.get(x, 99 - y);
|
||||
assert!(close(at(20, 20), [1.0, 0.0, 0.0], 0.02), "{:?}", at(20, 20));
|
||||
// Clipped to the path.
|
||||
assert_eq!(at(50, 20)[3], 0.0);
|
||||
assert_eq!(at(5, 5)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_mesh_data_draws_what_parses() {
|
||||
// Truncated and garbage vertex data never panic.
|
||||
for ty in [4, 5, 6, 7] {
|
||||
for data in [vec![], vec![0u8; 3], vec![0xFF; 7], (0..=255u8).collect::<Vec<u8>>(), vec![3u8; 200]] {
|
||||
let bytes = page_pdf(
|
||||
"/Sh sh /Pattern cs /P scn 0 0 50 50 re f",
|
||||
"/Shading << /Sh 10 0 R >> /Pattern << /P << /PatternType 2 /Shading 10 0 R >> >>",
|
||||
vec![(
|
||||
10,
|
||||
format!(
|
||||
"<< /ShadingType {ty} /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 /VerticesPerRow 2 {RGB_DECODE} >>"
|
||||
),
|
||||
Some(data),
|
||||
)],
|
||||
);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
let _ = render(&doc);
|
||||
}
|
||||
}
|
||||
// Bad layouts are reported.
|
||||
let bytes = page_pdf(
|
||||
"/Sh sh",
|
||||
"/Shading << /Sh 10 0 R >>",
|
||||
vec![(10, "<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 7 >>".into(), Some(vec![0; 16]))],
|
||||
);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
assert!(doc.skipped.iter().any(|s| s.contains("layout")), "{:?}", doc.skipped);
|
||||
}
|
||||
|
|
@ -12,11 +12,20 @@ fn load_err(r: Result<&'static effectcraft_effects::EffectSpec, String>) -> Stri
|
|||
}
|
||||
}
|
||||
|
||||
/// The gain plug-in's manifest.
|
||||
fn gain_manifest(id: &str) -> String {
|
||||
format!(
|
||||
r#"{{"api":1,"id":"{id}","name":"Gain {id}","category":"Test Plug-ins","version":"1.0","params":[{{"id":"gain","name":"Gain","type":"slider","default":0.5,"min":0,"max":4}},{{"id":"on","name":"Lift","type":"checkbox","default":false}},{{"id":"tint","name":"Tint","type":"color","default":[0,0,1,1]}}]}}"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A plug-in in WebAssembly text: multiplies RGB by `gain`, adds `lift` × `tint` when `on`.
|
||||
fn gain_module(id: &str, render_body: &str) -> String {
|
||||
let manifest = format!(
|
||||
r#"{{"api":1,"id":"{id}","name":"Gain {id}","category":"Test Plug-ins","version":"1.0","params":[{{"id":"gain","name":"Gain","type":"slider","default":0.5,"min":0,"max":4}},{{"id":"on","name":"Lift","type":"checkbox","default":false}},{{"id":"tint","name":"Tint","type":"color","default":[0,0,1,1]}}]}}"#
|
||||
);
|
||||
module_with(&gain_manifest(id), render_body)
|
||||
}
|
||||
|
||||
/// The gain module with its own manifest JSON.
|
||||
fn module_with(manifest: &str, render_body: &str) -> String {
|
||||
let len = manifest.len();
|
||||
let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!(
|
||||
|
|
@ -162,3 +171,149 @@ fn wasm_plugins_are_sandboxed() {
|
|||
let reserved = gain_module("ec.blur.mine", "");
|
||||
assert!(load_err(load_wasm(reserved.as_bytes(), "reserved.wat")).contains("reserved"));
|
||||
}
|
||||
|
||||
/// `base` with `from` replaced by `to` (which must occur).
|
||||
fn edit(base: &str, from: &str, to: &str) -> String {
|
||||
assert!(base.contains(from), "{from}");
|
||||
base.replace(from, to)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abi_violations_fail_to_load_with_reasons() {
|
||||
let base = gain_module("org.test.abi", "");
|
||||
// Missing exports.
|
||||
for export in ["ec_render", "ec_alloc", "ec_manifest_ptr", "ec_manifest_len", "ec_api_version"] {
|
||||
let m = edit(&base, &format!("(export \"{export}\")"), "");
|
||||
let e = load_err(load_wasm(m.as_bytes(), "x.wat"));
|
||||
assert!(e.contains(export), "{export}: {e}");
|
||||
}
|
||||
let m = edit(&base, "(memory (export \"memory\") 2)", "(memory 2)");
|
||||
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("memory"));
|
||||
// Wrong signatures.
|
||||
let m = edit(&base, "(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result f64) f64.const 1)");
|
||||
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_api_version"));
|
||||
let m = edit(&base, "(param $scale f64) (result i32)", "(param $scale f32) (result i32)");
|
||||
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_render"));
|
||||
// API versions other than 1.
|
||||
for v in ["0", "-1", "2147483647"] {
|
||||
let m = edit(
|
||||
&base,
|
||||
"(func (export \"ec_api_version\") (result i32) i32.const 1)",
|
||||
&format!("(func (export \"ec_api_version\") (result i32) i32.const {v})"),
|
||||
);
|
||||
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains(&format!("API {v}")), "{v}");
|
||||
}
|
||||
// Manifest out of bounds, negative or absurd lengths, not JSON.
|
||||
let ptr = "(func (export \"ec_manifest_ptr\") (result i32) i32.const 16)";
|
||||
let m = edit(&base, ptr, "(func (export \"ec_manifest_ptr\") (result i32) i32.const 200000)");
|
||||
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("out of bounds"));
|
||||
let len = gain_manifest("org.test.abi").len();
|
||||
for bad in ["-5", "2000000000"] {
|
||||
let m = edit(&base, &format!("(result i32) i32.const {len})"), &format!("(result i32) i32.const {bad})"));
|
||||
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("manifest length"), "{bad}");
|
||||
}
|
||||
let m = edit(&base, ptr, "(func (export \"ec_manifest_ptr\") (result i32) i32.const 4000)");
|
||||
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("manifest JSON"));
|
||||
// Traps and runaway code while loading.
|
||||
let version = "(func (export \"ec_api_version\") (result i32) i32.const 1)";
|
||||
let m = edit(&base, version, "(func (export \"ec_api_version\") (result i32) unreachable)");
|
||||
assert!(load_wasm(m.as_bytes(), "x.wat").is_err());
|
||||
let m = edit(&base, version, "(func (export \"ec_api_version\") (result i32) (loop $l br $l) i32.const 1)");
|
||||
assert!(load_wasm(m.as_bytes(), "x.wat").is_err(), "fuel runs out in ec_api_version");
|
||||
let m = edit(&base, "(memory (export \"memory\") 2)", "(memory (export \"memory\") 2) (func $s (loop $l br $l)) (start $s)");
|
||||
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("instantiation"), "fuel runs out in the start function");
|
||||
// Memory beyond the sandbox's limit.
|
||||
let m = edit(&base, "(memory (export \"memory\") 2)", "(memory (export \"memory\") 20000)");
|
||||
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("instantiation"));
|
||||
// Not a module at all.
|
||||
for junk in [&b""[..], b"\0asm", b"\0asm\x01\0\0\0\xff\xff", b"(module", b"(module (func (export \"x\") unknown_op))"] {
|
||||
assert!(load_wasm(junk, "junk").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_parameter_schemas_are_validated() {
|
||||
let p = |params: &str| format!(r#"{{"api":1,"id":"org.test.schema","name":"Schema","category":"Test Plug-ins","version":"1","params":[{params}]}}"#);
|
||||
let bad = [
|
||||
(r#"{"id":"a","name":"A","type":"checkbox"},{"id":"a","name":"B","type":"checkbox"}"#, "duplicate"),
|
||||
(r#"{"id":"a b","name":"A","type":"checkbox"}"#, "parameter id"),
|
||||
(r#"{"id":"","name":"A","type":"checkbox"}"#, "parameter id"),
|
||||
(r#"{"id":"p","name":"P","type":"popup","options":[],"default":0}"#, "popup"),
|
||||
(r#"{"id":"p","name":"P","type":"popup","options":["x"],"default":3}"#, "popup"),
|
||||
(r#"{"id":"s","name":"S","type":"slider","default":1,"min":5,"max":0}"#, "slider"),
|
||||
(r#"{"id":"s","name":"S","type":"slider","default":9,"min":0,"max":4}"#, "slider"),
|
||||
(r#"{"id":"s","name":"S","type":"slider","default":1,"min":0,"max":4,"sliderMin":3,"sliderMax":2}"#, "slider"),
|
||||
(r#"{"id":"s","name":"S","type":"wobble"}"#, "manifest JSON"),
|
||||
(r#"{"id":"s","name":"S","type":"slider"}"#, "manifest JSON"),
|
||||
];
|
||||
for (params, want) in bad {
|
||||
let m = module_with(&p(params), "");
|
||||
let e = load_err(load_wasm(m.as_bytes(), "schema.wat"));
|
||||
assert!(e.contains(want), "{params}: {e}");
|
||||
}
|
||||
let many: Vec<String> = (0..300).map(|i| format!(r#"{{"id":"c{i}","name":"C","type":"checkbox"}}"#)).collect();
|
||||
let e = load_err(load_wasm(module_with(&p(&many.join(",")), "").as_bytes(), "many.wat"));
|
||||
assert!(e.contains("at most"), "{e}");
|
||||
// Missing name, another API in the manifest.
|
||||
let e = load_err(load_wasm(module_with(r#"{"api":1,"id":"org.test.noname","name":" ","category":"X","version":"1","params":[]}"#, "").as_bytes(), "x.wat"));
|
||||
assert!(e.contains("name"), "{e}");
|
||||
let e = load_err(load_wasm(module_with(r#"{"api":3,"id":"org.test.api3","name":"N","category":"X","version":"1","params":[]}"#, "").as_bytes(), "x.wat"));
|
||||
assert!(e.contains("API 3"), "{e}");
|
||||
// A valid schema loads.
|
||||
let ok = p(
|
||||
r#"{"id":"s","name":"S","type":"slider","default":2,"min":0,"max":4,"sliderMin":1,"sliderMax":3},{"id":"p","name":"P","type":"popup","options":["x","y"],"default":1}"#,
|
||||
);
|
||||
let spec = load_wasm(module_with(&ok, "").as_bytes(), "ok.wat").unwrap();
|
||||
assert_eq!(spec.params.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_failures_leave_the_frame_and_recover() {
|
||||
let orig = [0.8, 0.6, 0.2, 1.0];
|
||||
// ec_render traps (when time > 0.5), returns an error code, or ec_alloc hands back memory
|
||||
// out of bounds: the frame passes through unchanged, and the plug-in keeps working.
|
||||
let trap = load_wasm(gain_module("org.test.trap", "local.get $t f64.const 0.5 f64.gt if unreachable end").as_bytes(), "trap.wat").unwrap();
|
||||
let mut next = 1;
|
||||
let g = effectcraft_effects::instantiate(trap, &mut Ids(&mut next), trap.name, [4.0, 2.0]);
|
||||
let params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone());
|
||||
let at = |time: f64| {
|
||||
let ctx = EffectCtx { params: ¶ms, time, layer_size: [4.0, 2.0], seed: 1, adjustment: false, env: EffectEnv::default() };
|
||||
let buf = Buf { img: Image::filled(4, 2, orig), offset: [0.0; 2], scale: 1.0 };
|
||||
(trap.render)(&ctx, buf).img
|
||||
};
|
||||
assert_eq!(at(1.0).get(0, 0), orig, "trapped");
|
||||
assert_eq!(at(0.0).get(0, 0), [0.4, 0.3, 0.1, 1.0], "a fresh instance after the trap");
|
||||
assert_eq!(at(1.0).get(0, 0), orig);
|
||||
assert_eq!(at(0.0).get(1, 1), [0.4, 0.3, 0.1, 1.0]);
|
||||
let code = load_wasm(gain_module("org.test.code", "i32.const 7 return").as_bytes(), "code.wat").unwrap();
|
||||
assert_eq!(render(code, 0.5, false).get(0, 0), orig);
|
||||
let oob = edit(&gain_module("org.test.oob", ""), " i32.const 65536)", " i32.const -16)");
|
||||
let oob = load_wasm(oob.as_bytes(), "oob.wat").unwrap();
|
||||
assert_eq!(render(oob, 0.5, false).get(0, 0), orig);
|
||||
// Runaway rendering: out of fuel every time, never hangs.
|
||||
let spin = load_wasm(gain_module("org.test.spin2", "(loop $spin br $spin)").as_bytes(), "spin2.wat").unwrap();
|
||||
for _ in 0..3 {
|
||||
assert_eq!(render(spin, 0.5, false).get(0, 0), orig);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_rendering_is_deterministic() {
|
||||
// Float edge cases (NaN from sqrt(−x), infinities from x / 0) come out bit-identical
|
||||
// across instances, threads and repeated runs.
|
||||
let body = "local.get $px local.get $px f32.load f32.const -1 f32.mul f32.sqrt f32.store \
|
||||
local.get $px local.get $px f32.load offset=4 f32.const 0 f32.div f32.store offset=4";
|
||||
let spec = load_wasm(gain_module("org.test.det", body).as_bytes(), "det.wat").unwrap();
|
||||
let bits = |img: &Image| -> Vec<u32> { (0..2).flat_map(|y| (0..4).map(move |x| (x, y))).flat_map(|(x, y)| img.get(x, y).map(f32::to_bits)).collect() };
|
||||
let first = bits(&render(spec, 0.37, true));
|
||||
let runs: Vec<Vec<u32>> = std::thread::scope(|sc| {
|
||||
let hs: Vec<_> = (0..6).map(|_| sc.spawn(|| bits(&render(spec, 0.37, true)))).collect();
|
||||
hs.into_iter().map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
for r in runs {
|
||||
assert_eq!(r, first);
|
||||
}
|
||||
// The same module loaded under another id renders the same bits.
|
||||
let twin = load_wasm(gain_module("org.test.det2", body).as_bytes(), "det2.wat").unwrap();
|
||||
assert_eq!(bits(&render(twin, 0.37, true)), first);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, TypedFunc, WasmParams, WasmResults};
|
||||
use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, WasmParams, WasmResults};
|
||||
|
||||
use crate::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams};
|
||||
|
||||
|
|
@ -10,12 +10,16 @@ use crate::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, Plugi
|
|||
/// work, but a runaway loop stops instead of hanging the render.
|
||||
const FUEL_BASE: u64 = 50_000_000;
|
||||
const FUEL_PER_PIXEL: u64 = 20_000;
|
||||
/// The most linear memory a plug-in instance may have (a 4K float frame is ~130 MB).
|
||||
const MAX_MEMORY: usize = 1 << 30;
|
||||
/// The longest manifest read.
|
||||
const MAX_MANIFEST: i32 = 1 << 20;
|
||||
|
||||
type RenderFn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64), i32>;
|
||||
|
||||
/// One instantiated module (renders running on several threads each take their own).
|
||||
struct Inst {
|
||||
store: Store<()>,
|
||||
store: Store<StoreLimits>,
|
||||
instance: Instance,
|
||||
memory: Memory,
|
||||
alloc: TypedFunc<i32, i32>,
|
||||
|
|
@ -37,9 +41,10 @@ fn err(e: impl std::fmt::Display) -> String {
|
|||
}
|
||||
|
||||
fn instantiate(engine: &Engine, module: &Module) -> Result<Inst, String> {
|
||||
let mut store = Store::new(engine, ());
|
||||
let mut store = Store::new(engine, StoreLimitsBuilder::new().memory_size(MAX_MEMORY).instances(1).build());
|
||||
store.limiter(|l| l);
|
||||
store.set_fuel(FUEL_BASE).map_err(err)?;
|
||||
let linker = Linker::<()>::new(engine);
|
||||
let linker = Linker::<StoreLimits>::new(engine);
|
||||
let instance = linker.instantiate_and_start(&mut store, module).map_err(|e| format!("instantiation failed: {e}"))?;
|
||||
let memory = instance.get_memory(&store, "memory").ok_or("the module exports no `memory`")?;
|
||||
let alloc = instance.get_typed_func::<i32, i32>(&store, "ec_alloc").map_err(|_| missing("ec_alloc"))?;
|
||||
|
|
@ -74,7 +79,10 @@ impl WasmPlugin {
|
|||
}
|
||||
let ptr = inst.func::<(), i32>("ec_manifest_ptr")?.call(&mut inst.store, ()).map_err(err)?;
|
||||
let len = inst.func::<(), i32>("ec_manifest_len")?.call(&mut inst.store, ()).map_err(err)?;
|
||||
let mut text = vec![0u8; len.max(0) as usize];
|
||||
if !(0..=MAX_MANIFEST).contains(&len) {
|
||||
return Err(format!("bad manifest length {len}"));
|
||||
}
|
||||
let mut text = vec![0u8; len as usize];
|
||||
inst.memory.read(&inst.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?;
|
||||
let manifest: PluginManifest = serde_json::from_slice(&text).map_err(|e| format!("bad manifest JSON: {e}"))?;
|
||||
manifest.validate()?;
|
||||
|
|
|
|||
|
|
@ -568,8 +568,17 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
(lay, xfs)
|
||||
};
|
||||
|
||||
// Tracking (before / after each character) and Line Anchor, per line.
|
||||
// Variable Font Axes: the advance at the animated design-space position (later characters
|
||||
// on the line move with it).
|
||||
let n = lay.glyphs.len();
|
||||
let vadv: Vec<f64> = (0..n)
|
||||
.map(|gi| match (subs[gi], xfs[gi].axis_deltas()) {
|
||||
(None, d) if !d.is_empty() => effectcraft_text::variable::char_advance_delta(&lay.glyphs[gi], &d).unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
})
|
||||
.collect();
|
||||
let advance = |gi: usize| lay.glyphs[gi].advance + vadv[gi];
|
||||
// Tracking (before / after each character) and Line Anchor, per line.
|
||||
let mut shift = vec![0.0f64; n];
|
||||
let mut li_start = 0;
|
||||
while li_start < n {
|
||||
|
|
@ -585,7 +594,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0;
|
||||
pen += x.track_before * em;
|
||||
shift[gi] = pen;
|
||||
pen += x.track_after * em;
|
||||
pen += x.track_after * em + vadv[gi];
|
||||
if x.line_anchor_k > la_k {
|
||||
la = x.line_anchor;
|
||||
la_k = x.line_anchor_k;
|
||||
|
|
@ -622,7 +631,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
let o = origin(gi);
|
||||
let e = extents.entry(group_key(g)).or_insert([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]);
|
||||
e[0] = e[0].min(o[0]);
|
||||
e[2] = e[2].max(o[0] + g.advance);
|
||||
e[2] = e[2].max(o[0] + advance(gi));
|
||||
e[1] = e[1].min(o[1]);
|
||||
e[3] = e[3].max(o[1]);
|
||||
}
|
||||
|
|
@ -645,7 +654,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
while j < n && lay.glyphs[j].line_index == line {
|
||||
j += 1;
|
||||
}
|
||||
let xs: Vec<f64> = (i..j).map(|gi| origin(gi)[0] + lay.glyphs[gi].advance / 2.0).collect();
|
||||
let xs: Vec<f64> = (i..j).map(|gi| origin(gi)[0] + advance(gi) / 2.0).collect();
|
||||
let lb = lay.line_boxes.get(line).copied().unwrap_or([0.0; 4]);
|
||||
let pos = path_text::arc_positions(&xs, [lb[0], lb[0] + lb[2]], align, pm.length(), *fm, *lm, *force);
|
||||
arc[i..j].copy_from_slice(&pos);
|
||||
|
|
@ -684,7 +693,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
}
|
||||
// Variable Font Axes: the outline redrawn at the animated design-space position.
|
||||
None => match x.axis_deltas() {
|
||||
d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), g.advance),
|
||||
d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), advance(gi)),
|
||||
_ => (g.path.clone(), g.advance),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -143,6 +143,12 @@ function __uiParseRes(src) {
|
|||
ws();
|
||||
if (s.charAt(i) === ",") {
|
||||
i++;
|
||||
// A trailing comma, as JavaScript allows.
|
||||
ws();
|
||||
if (s.charAt(i) === "]") {
|
||||
i++;
|
||||
return a;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (s.charAt(i) === "]") {
|
||||
|
|
|
|||
|
|
@ -144,7 +144,10 @@ mod imp {
|
|||
err(format!("cannot connect to {host}: {last}"))
|
||||
}
|
||||
"listen" => {
|
||||
let port = p.get("port").and_then(J::as_u64).unwrap_or(0) as u16;
|
||||
let port = match p.get("port").and_then(J::as_f64).unwrap_or(0.0) {
|
||||
v if v.fract() == 0.0 && (0.0..=65535.0).contains(&v) => v as u16,
|
||||
v => return err(format!("bad port {v} (0 to 65535)")),
|
||||
};
|
||||
match TcpListener::bind(("127.0.0.1", port)).and_then(|l| l.set_nonblocking(true).map(|_| l)) {
|
||||
Ok(l) => {
|
||||
let port = l.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||
|
|
|
|||
|
|
@ -240,3 +240,137 @@ fn sockets_talk_tcp_behind_the_network_preference() {
|
|||
let o = run_code(&mut s, "var l = new Socket(); l.listen(0); var r = [l.port > 0, l.poll() === null].join('|'); l.close(); r", "net.jsx");
|
||||
assert_eq!(o.result, json!("true|true"));
|
||||
}
|
||||
|
||||
/// `__uiParseRes(src)` as JSON, or the error message.
|
||||
fn parse_res(s: &mut Session, src: &str) -> Result<serde_json::Value, String> {
|
||||
let code = format!("JSON.stringify(__uiParseRes({}))", serde_json::to_string(src).unwrap());
|
||||
let o = run_code(s, &code, "res.jsx");
|
||||
match o.error {
|
||||
Some(e) => Err(e.message),
|
||||
None => Ok(serde_json::from_str(o.result.as_str().unwrap_or("null")).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_string_parser_edge_cases() {
|
||||
let mut s = session();
|
||||
// Accepted: trailing commas, quoted keys, number forms, escapes, comments, empty bodies,
|
||||
// null / undefined, bare words, nested arrays and object literals.
|
||||
let ok = parse_res(
|
||||
&mut s,
|
||||
"group { 'quoted key': 1, \"dq\": -.5, n: +3, e: 1e2, arr: [[1, [2]], 'x', ], \
|
||||
esc: 'it\\'s\\n\\\\', /* block */ nul: null, u: undefined, w: fill, // line\n \
|
||||
obj: { a: { b: true } }, kid: Button {}, }",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ok["type"], "group");
|
||||
let list = ok["list"].as_array().unwrap();
|
||||
let get = |k: &str| list.iter().find(|e| e[0] == k).map(|e| e[1].clone()).unwrap();
|
||||
assert_eq!(get("quoted key"), json!(1));
|
||||
assert_eq!(get("dq"), json!(-0.5));
|
||||
assert_eq!(get("n"), json!(3));
|
||||
assert_eq!(get("e"), json!(100));
|
||||
assert_eq!(get("arr"), json!([[1, [2]], "x"]));
|
||||
assert_eq!(get("esc"), json!("it's\n\\"));
|
||||
assert_eq!(get("nul"), json!(null));
|
||||
assert_eq!(get("w"), json!("fill"));
|
||||
assert_eq!(get("kid"), json!({"__element": "Button", "list": []}));
|
||||
// Rejected with a located message (never a crash).
|
||||
for bad in [
|
||||
"",
|
||||
" ",
|
||||
"dialog",
|
||||
"dialog {",
|
||||
"dialog { text: 'unterminated }",
|
||||
"dialog { text 'x' }",
|
||||
"dialog { a: [1, 2 }",
|
||||
"dialog { a: 1 } trailing",
|
||||
"dialog { /* unterminated comment",
|
||||
"dialog { a: 'x\\",
|
||||
"{ }",
|
||||
"123 { }",
|
||||
"dialog { a: 1 b: 2 }",
|
||||
"dialog { : 1 }",
|
||||
"dialog { a: }",
|
||||
"dialog { a: Button { }",
|
||||
] {
|
||||
let e = parse_res(&mut s, bad).expect_err(bad);
|
||||
assert!(e.contains("resource string"), "{bad:?}: {e}");
|
||||
assert!(e.contains("offset"), "{bad:?}: {e}");
|
||||
}
|
||||
// Deep nesting builds; absurd nesting fails cleanly (the script engine's recursion limit).
|
||||
let deep = |n: usize| format!("group {{ {}{} }}", "g: Group { ".repeat(n), "}".repeat(n));
|
||||
let d = parse_res(&mut s, &deep(40)).unwrap();
|
||||
assert_eq!(d["list"][0][1]["__element"], "Group");
|
||||
let _ = parse_res(&mut s, &deep(5000));
|
||||
// A window from a deep resource.
|
||||
let o = run_code(
|
||||
&mut s,
|
||||
&format!(
|
||||
"var w = new Window({}); w.children.length",
|
||||
serde_json::to_string(&format!("palette {{ {}{} }}", "g: Group { ".repeat(20), "}".repeat(20))).unwrap()
|
||||
),
|
||||
"deep.jsx",
|
||||
);
|
||||
assert_eq!(o.result, json!(1), "{:?}", o.error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_edge_cases() {
|
||||
let mut s = session();
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
// Bad addresses and ports: false with an error, never a throw or crash.
|
||||
for host in ["", "nohostport", "127.0.0.1", "127.0.0.1:99999", "[::1", "a:b:c", "127.0.0.1:-5"] {
|
||||
let code = format!(
|
||||
"var s = new Socket(); s.timeout = 1; var ok = s.open({}); [ok, s.error.length > 0, s.connected, s.eof].join('|')",
|
||||
serde_json::to_string(host).unwrap()
|
||||
);
|
||||
let o = run_code(&mut s, &code, "net.jsx");
|
||||
assert_eq!(o.result, json!("false|true|false|true"), "{host:?}: {:?}", o.error);
|
||||
}
|
||||
for port in ["70000", "-1", "1.5"] {
|
||||
let o = run_code(&mut s, &format!("var l = new Socket(); [l.listen({port}), l.error.length > 0].join('|')"), "net.jsx");
|
||||
assert_eq!(o.result, json!("false|true"), "{port}: {:?}", o.error);
|
||||
}
|
||||
// Never opened or closed: reads are empty, writes fail, close is idempotent.
|
||||
let o = run_code(
|
||||
&mut s,
|
||||
"var s = new Socket(); var r = [s.read(), s.read(5), s.readln(), s.write('x'), s.connected, s.eof, s.poll(), s.close(), s.close()]; r.join('|')",
|
||||
"net.jsx",
|
||||
);
|
||||
assert_eq!(o.result, json!("|||false|false|true||true|true"), "{:?}", o.error);
|
||||
// Binary bytes round trip in BINARY encoding; invalid UTF-8 is replaced in UTF-8; partial
|
||||
// and zero-length reads; read stops at the timeout without data.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
for _ in 0..2 {
|
||||
let (mut c, _) = listener.accept().unwrap();
|
||||
let bytes: Vec<u8> = (0..=255u8).collect();
|
||||
c.write_all(&bytes).unwrap();
|
||||
}
|
||||
});
|
||||
let code = format!(
|
||||
"var s = new Socket(); s.open('127.0.0.1:{port}', 'BINARY'); var z = s.read(0); var a = s.read(10); var b = s.read(); s.close(); \
|
||||
var ok = true; for (var i = 0; i < 10; i++) ok = ok && a.charCodeAt(i) === i; for (var j = 0; j < 246; j++) ok = ok && b.charCodeAt(j) === j + 10; \
|
||||
[z.length, a.length, b.length, ok].join('|')"
|
||||
);
|
||||
let o = run_code(&mut s, &code, "net.jsx");
|
||||
assert_eq!(o.result, json!("0|10|246|true"), "{:?}", o.error);
|
||||
let code = format!(
|
||||
"var s = new Socket(); s.open('127.0.0.1:{port}', 'UTF-8'); var d = s.read(); s.close(); [d.charCodeAt(0), d.charCodeAt(127), d.indexOf('\\ufffd') > 0].join('|')"
|
||||
);
|
||||
let o = run_code(&mut s, &code, "net.jsx");
|
||||
assert_eq!(o.result, json!("0|127|true"), "{:?}", o.error);
|
||||
server.join().unwrap();
|
||||
// A listener with nobody connecting: a short read timeout returns quickly.
|
||||
let t = std::time::Instant::now();
|
||||
let o = run_code(
|
||||
&mut s,
|
||||
"var l = new Socket(); l.listen(0); l.timeout = 0; var c = new Socket(); c.timeout = 0.2; c.open(l.host); var x = c.read(); c.close(); l.close(); x.length",
|
||||
"net.jsx",
|
||||
);
|
||||
assert_eq!(o.result, json!(0), "{:?}", o.error);
|
||||
assert!(t.elapsed().as_secs_f64() < 5.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,12 +179,30 @@ pub struct Group {
|
|||
/// 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>>,
|
||||
/// Composited on its own (a transparent backdrop, as SVG groups and PDF isolated
|
||||
/// transparency groups are) rather than over what is below it (PDF non-isolated groups and
|
||||
/// clipping groups: blend modes inside reach the backdrop). Matters only when the group is
|
||||
/// drawn offscreen (clip, mask, blend mode, opacity or knockout).
|
||||
pub isolated: bool,
|
||||
/// A PDF knockout group: each child composites with the group's initial backdrop instead
|
||||
/// of with the children below it.
|
||||
pub knockout: bool,
|
||||
}
|
||||
|
||||
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 }
|
||||
Group {
|
||||
name: name.to_string(),
|
||||
transform: Affine::IDENTITY,
|
||||
opacity: 1.0,
|
||||
children: vec![],
|
||||
clip: vec![],
|
||||
blend: BlendMode::Normal,
|
||||
mask: None,
|
||||
isolated: true,
|
||||
knockout: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -753,7 +771,17 @@ 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![], blend: BlendMode::Normal, mask: None }));
|
||||
out.push(Node::Group(Group {
|
||||
name: Self::element_name(n),
|
||||
transform,
|
||||
opacity,
|
||||
children,
|
||||
clip: vec![],
|
||||
blend: BlendMode::Normal,
|
||||
mask: None,
|
||||
isolated: true,
|
||||
knockout: false,
|
||||
}));
|
||||
}
|
||||
"svg" => {
|
||||
// Nested viewport.
|
||||
|
|
@ -780,6 +808,8 @@ impl<'a, 'i> Parser<'a, 'i> {
|
|||
clip: vec![],
|
||||
blend: BlendMode::Normal,
|
||||
mask: None,
|
||||
isolated: true,
|
||||
knockout: false,
|
||||
}));
|
||||
}
|
||||
"use" => {
|
||||
|
|
@ -809,11 +839,23 @@ impl<'a, 'i> Parser<'a, 'i> {
|
|||
clip: vec![],
|
||||
blend: BlendMode::Normal,
|
||||
mask: None,
|
||||
isolated: true,
|
||||
knockout: false,
|
||||
}));
|
||||
} else {
|
||||
self.node(target, &st, &mut children, depth + 1);
|
||||
}
|
||||
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None }));
|
||||
out.push(Node::Group(Group {
|
||||
name: Self::element_name(n),
|
||||
transform,
|
||||
opacity,
|
||||
children,
|
||||
clip: vec![],
|
||||
blend: BlendMode::Normal,
|
||||
mask: None,
|
||||
isolated: true,
|
||||
knockout: false,
|
||||
}));
|
||||
}
|
||||
"path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => {
|
||||
if !st.visible {
|
||||
|
|
@ -861,6 +903,8 @@ impl<'a, 'i> Parser<'a, 'i> {
|
|||
clip: vec![],
|
||||
blend: BlendMode::Normal,
|
||||
mask: None,
|
||||
isolated: true,
|
||||
knockout: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -1056,7 +1100,7 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
|
|||
Ok(Doc {
|
||||
width: dw,
|
||||
height: dh,
|
||||
root: Group { name: "svg".into(), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None },
|
||||
root: Group { name: "svg".into(), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None, isolated: true, knockout: false },
|
||||
skipped: std::mem::take(&mut p.skipped),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,26 +31,62 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
|
|||
if o <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let isolated = !g.clip.is_empty() || g.mask.is_some() || g.blend != BlendMode::Normal || (g.opacity < 1.0 && g.children.len() > 1);
|
||||
if !isolated {
|
||||
let offscreen = !g.clip.is_empty()
|
||||
|| g.mask.is_some()
|
||||
|| g.blend != BlendMode::Normal
|
||||
|| (g.opacity < 1.0 && !matches!(g.children.as_slice(), [Node::Shape(_) | Node::Image(_)]))
|
||||
|| g.knockout;
|
||||
if !offscreen {
|
||||
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);
|
||||
let only_clip = g.mask.is_none() && g.blend == BlendMode::Normal && o >= 1.0 && !g.knockout;
|
||||
// Draw the children on their own (isolated) or over a copy of the backdrop, keep what
|
||||
// every clip path covers and the soft mask lets through, then composite with the group's
|
||||
// opacity and blend mode.
|
||||
let backdrop = if g.isolated { None } else { Some(dst.clone()) };
|
||||
let mut tmp = match &backdrop {
|
||||
Some(b) => b.clone(),
|
||||
None => Image::new(dst.width, dst.height),
|
||||
};
|
||||
draw_children(g, m, &mut tmp, backdrop.as_ref());
|
||||
if let Some(b) = &backdrop {
|
||||
if only_clip {
|
||||
// A non-isolated clipping group: the backdrop where the clip excludes the children.
|
||||
let cov = clip_coverage(g, m, dst.width, dst.height);
|
||||
dst.data.par_iter_mut().zip(tmp.data.par_iter()).zip(cov.par_iter()).for_each(|((d, t), c)| {
|
||||
for i in 0..4 {
|
||||
d[i] = d[i] * (1.0 - c) + t[i] * c;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Remove the backdrop's contribution (ISO 32000-1 §11.4.8): with the group's own
|
||||
// alpha αg (its children alone), C = Cn + (Cn − C0)·(α0/αg − α0).
|
||||
let mut solo = Image::new(dst.width, dst.height);
|
||||
draw_children(g, m, &mut solo, None);
|
||||
tmp.data.par_iter_mut().zip(b.data.par_iter()).zip(solo.data.par_iter()).for_each(|((t, b0), s)| {
|
||||
let ag = s[3];
|
||||
if ag <= 0.0 {
|
||||
*t = [0.0; 4];
|
||||
return;
|
||||
}
|
||||
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 (cn, c0, a0) = (un(t), un(b0), b0[3]);
|
||||
let k = a0 / ag - a0;
|
||||
let mut out = [0.0; 4];
|
||||
for i in 0..3 {
|
||||
out[i] = (cn[i] + (cn[i] - c0[i]) * k).clamp(0.0, 1.0) * ag;
|
||||
}
|
||||
out[3] = ag;
|
||||
*t = out;
|
||||
});
|
||||
}
|
||||
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)| {
|
||||
if !g.clip.is_empty() {
|
||||
let cov = clip_coverage(g, m, dst.width, dst.height);
|
||||
tmp.data.par_iter_mut().zip(cov.par_iter()).for_each(|(p, c)| {
|
||||
for v in p.iter_mut() {
|
||||
*v *= c;
|
||||
}
|
||||
|
|
@ -89,6 +125,76 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The product of a group's clip-path coverages.
|
||||
fn clip_coverage(g: &Group, m: Affine, w: u32, h: u32) -> Vec<f32> {
|
||||
let mut out = vec![1.0f32; w as usize * h as usize];
|
||||
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), w, h, rule);
|
||||
out.par_iter_mut().zip(cov.data.par_iter()).for_each(|(o, c)| *o *= c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw a group's children into `dst`. Knockout groups composite each child with the
|
||||
/// group's initial backdrop (`backdrop`, or transparent) instead of with the children below:
|
||||
/// where the child has shape `f`, `dst = dst·(1 − f) + child + backdrop·(f − αchild)`.
|
||||
fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>) {
|
||||
if !g.knockout {
|
||||
for c in &g.children {
|
||||
draw_node(c, m, 1.0, dst);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in &g.children {
|
||||
let mut ci = Image::new(dst.width, dst.height);
|
||||
draw_node(c, m, 1.0, &mut ci);
|
||||
// Shape: the child drawn fully opaque (constant opacities to 1).
|
||||
let mut si = Image::new(dst.width, dst.height);
|
||||
draw_node(&opaque(c), m, 1.0, &mut si);
|
||||
let zero = [0.0f32; 4];
|
||||
dst.data.par_iter_mut().enumerate().for_each(|(i, d)| {
|
||||
let (cp, f) = (ci.data[i], si.data[i][3].max(ci.data[i][3]));
|
||||
if f <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let b = backdrop.map(|b| b.data[i]).unwrap_or(zero);
|
||||
for k in 0..4 {
|
||||
d[k] = d[k] * (1.0 - f) + cp[k] + b[k] * (f - cp[3]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A node with its constant opacities set to 1 (its shape, for knockout groups).
|
||||
fn opaque(n: &Node) -> Node {
|
||||
match n {
|
||||
Node::Group(g) => {
|
||||
let mut g = g.clone();
|
||||
g.opacity = 1.0;
|
||||
g.children = g.children.iter().map(opaque).collect();
|
||||
Node::Group(g)
|
||||
}
|
||||
Node::Shape(s) => {
|
||||
let mut s = s.clone();
|
||||
s.opacity = 1.0;
|
||||
s.fill_opacity = 1.0;
|
||||
if let Some(st) = &mut s.stroke {
|
||||
st.opacity = 1.0;
|
||||
}
|
||||
Node::Shape(s)
|
||||
}
|
||||
Node::Image(i) => {
|
||||
let mut i = i.clone();
|
||||
i.opacity = 1.0;
|
||||
Node::Image(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `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] {
|
||||
|
|
|
|||
|
|
@ -123,6 +123,37 @@ pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<Be
|
|||
Some(g.outline_xf * (*units).clone())
|
||||
}
|
||||
|
||||
/// A glyph's advance in font units at the given axis values (user units, unspecified axes at
|
||||
/// their defaults).
|
||||
pub fn advance_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Option<f32> {
|
||||
let f = fonts::face(face);
|
||||
let font = f.font()?;
|
||||
let loc = font.axes().location(coords.iter().map(|(t, v)| (tag_of(t), *v)));
|
||||
font.glyph_metrics(Size::unscaled(), LocationRef::from(&loc)).advance_width(GlyphId::new(gid))
|
||||
}
|
||||
|
||||
/// The change of a laid-out character's advance (layout units) when its face's axes move by
|
||||
/// `deltas` (as [`char_outline_varied`]): Variable Font Axes animators re-space the text.
|
||||
pub fn char_advance_delta(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<f64> {
|
||||
let axes = font_axes(g.face);
|
||||
let base = |t: &str, d: f32| g.variations.iter().find(|(x, _)| x.trim_end() == t.trim_end()).map(|(_, v)| *v).unwrap_or(d);
|
||||
let mut coords: Vec<(String, f32)> =
|
||||
deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (base(&a.tag, a.default) + d).clamp(a.min, a.max)))).collect();
|
||||
if coords.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for (t, v) in &g.variations {
|
||||
if !coords.iter().any(|(c, _)| c == t) {
|
||||
coords.push((t.clone(), *v));
|
||||
}
|
||||
}
|
||||
let varied = advance_units_at(g.face, g.gid, &coords)?;
|
||||
let own = advance_units_at(g.face, g.gid, &g.variations)?;
|
||||
// Font units → layout units: the outline transform's horizontal scale.
|
||||
let sx = g.outline_xf.as_coeffs()[0];
|
||||
Some((varied - own) as f64 * sx)
|
||||
}
|
||||
|
||||
/// The first installed face with variation axes (system fonts are scanned), for tests and
|
||||
/// demos: (face, its axes).
|
||||
pub fn find_variable_face() -> Option<(FaceId, Vec<FontAxis>)> {
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ Tracking 96%, Effects 85%, Web 65%.
|
|||
|
||||
What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking
|
||||
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands;
|
||||
approximated effects (Key Cleaner) and Liquify's viewer brush; mesh shadings and JPX images in
|
||||
imported PDF/AI files and Illustrator procset EPS; and codec depth (B-frames, SAO/CDEF; HEVC and AV1 export and Opus SILK/hybrid landed in M13.4). Face tracking and a true Subspace Warp
|
||||
approximated effects (Key Cleaner) and Liquify's viewer brush; JPX / JBIG2 images in
|
||||
imported PDF/AI files (mesh shadings and CCITT landed in M13.12) and Illustrator procset EPS; and codec depth (B-frames, SAO/CDEF; HEVC and AV1 export and Opus SILK/hybrid landed in M13.4). Face tracking and a true Subspace Warp
|
||||
landed in M13.3. ScriptUI (dialogs that block `show()`, palettes, dockable ScriptUI panels, File ▸ Scripts install
|
||||
and sample scripts), puppet pin recording and the remaining "better than After Effects" items — a
|
||||
versioned effect plug-in API with sandboxed WebAssembly plug-ins ([plugins.md](plugins.md)),
|
||||
|
|
@ -128,12 +128,12 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
|||
| Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit |
|
||||
| Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control; M13.4: HEVC export (MP4 `hvc1`, `effectcraft-hevcenc`: Main / Main 10, IDR + P slices, quarter-pel motion, deblocking, bit-exact with ffmpeg) and AV1 export (MP4 `av01` and WebM, `effectcraft-av1enc`: 8/10-bit key + inter frames, quarter-pel motion, loop filter, bit-exact with libdav1d) with profile, level, bitrate / constant-quality and key-frame interval options; Opus SILK (NB/MB/WB, mid/side stereo) and hybrid (SWB/FB) modes chosen by bitrate and Audio/Voice tuning, with the Opus bitrate in the Output Module. HEVC/AV1 import decodes through FilmCraft (MP4/MOV/MKV/WebM). Left: HEVC/AV1 have no B-frames, multi-reference, SAO/CDEF/restoration or alpha (compression below mature encoders); Opus has no FEC/DTX and only 20 ms frames; Photoshop sequence output, overflow for movies only checks at file creation |
|
||||
| Audio | 85% | 0.5 | audio to keyframes |
|
||||
| Import | 96% | 0.6 | mesh shadings (types 4–7), JPX / CCITT / JBIG2 images and non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) |
|
||||
| Import | 97% | 0.5 | JPX / JBIG2 images and predefined non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.12: mesh shadings — free-form / lattice Gouraud triangles, Coons and tensor patches — and function-based shadings, CCITT G3 / G4 images, knockout / isolated transparency groups with group alpha and soft masks on the group's result, `/W2` vertical metrics, EPS text with embedded Type 1 / CFF or bundled fonts, and Create Shapes from Vector Layer keeping images as parented footage layers in paint order; M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) |
|
||||
| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
|
||||
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
|
||||
| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' mirrored properties (CMP-7: Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
|
||||
| Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
|
||||
| Text | ≈ 95% | 0.4 | no extruded strokes, the Variable Font Axes animator changes outlines but not advances (M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
|
||||
| Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
|
||||
| Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) |
|
||||
| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
|
||||
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (152 run on the GPU since M13.9, EFF-5: keying, mattes, channel, colour correction, stylize, distort, noise, blurs and time effects joined the 58 of M12.7; Warp, Bezier Warp, Reshape, Smear, Cartoon, CC Page Turn and others still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
|
||||
|
|
@ -180,8 +180,8 @@ Tate-Chu-Yoko, forced LTR paragraphs, the editing caret on animated / path text
|
|||
style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and layer ends
|
||||
(ANM-4; the reference graph existed); viewer ROI resize handles, Pan Behind snapping and 3D
|
||||
Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry
|
||||
taper/wave and the Variable Font Axes animator doesn't change advances (the Character panel's
|
||||
axes do since M13.6; Advanced 3D's DOF has iris shapes since M13.8).
|
||||
taper/wave (the Variable Font Axes animator changes advances since M13.12, the Character panel's
|
||||
axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8).
|
||||
|
||||
## Update: M13.8 Preview panel, Align panel, Advanced 3D completion
|
||||
|
||||
|
|
@ -241,6 +241,32 @@ menu entries. Not yet: creating a reference frame by editing a still (After Effe
|
|||
Photoshop), Content-Aware Fill's learned model (ours is PatchMatch + flow propagation), and Media
|
||||
Browser in the web app (landed in M13.10: browser storage and File System Access folders).
|
||||
|
||||
## Update: M13.12 import and text leftovers, test hardening
|
||||
|
||||
- **PDF / AI**: mesh shadings — free-form and lattice-form Gouraud triangle meshes (types 4, 5),
|
||||
Coons and tensor-product patch meshes (6, 7, tessellated to triangles) — and function-based
|
||||
shadings (type 1, with multi-input sampled functions), for `sh` and shading-pattern fills,
|
||||
rasterised into image nodes; CCITT Group 3 (1-D and 2-D) and Group 4 images from ITU-T T.4 /
|
||||
T.6 (checked against an independent encoder's output); knockout and isolated transparency
|
||||
groups, the current alpha / blend mode / soft mask applied to a transparency group XObject's
|
||||
combined result, and blend modes inside clipping groups reaching the backdrop; `/W2` / `/DW2`
|
||||
vertical metrics and CMap `WMode`. Not done: JBIG2 images (deferred) and predefined CJK CMaps (no permissively licensed
|
||||
pure-Rust source of the CMap data; Adobe's CMap resources are Adobe assets).
|
||||
- **EPS**: text through the PostScript interpreter — `findfont` / `scalefont` / `makefont` /
|
||||
`selectfont` / `setfont`, `show`, `ashow`, `widthshow`, `awidthshow`, `xshow` / `yshow` /
|
||||
`xyshow`, `kshow`, `glyphshow`, `charpath`, `stringwidth`, re-encoded fonts (`definefont`
|
||||
copies with a new `/Encoding`) — with embedded Type 1 and CFF programs or the bundled fonts.
|
||||
- **Create Shapes from Vector Layer**: images become PNG footage layers parented to the shape
|
||||
layer, and the shapes between images are split into layers of their own so the stack keeps
|
||||
the document's paint order; soft masks and clipped images are reported.
|
||||
- **Text**: the Variable Font Axes animator re-spaces the text (advances at the animated
|
||||
design-space position), not only the outlines.
|
||||
- **Hardening**: PDF / EPS parsers survive truncated and corrupted files (fixed an
|
||||
out-of-range stream slice and unbounded PostScript recursion); WebAssembly plug-ins get a
|
||||
memory cap, a manifest length limit and slider schema checks, with tests for missing exports,
|
||||
wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script
|
||||
`Socket` validates ports, and ScriptUI resource strings accept trailing array commas.
|
||||
|
||||
## Highest-value gaps, in order
|
||||
|
||||
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ There are two ways to write one:
|
|||
|---|---|---|
|
||||
| What | a `.wasm` (or `.wat`) module | a type implementing `effectcraft_effects::plugin::EffectPlugin` |
|
||||
| Loaded by | Effect ▸ Load Effect Plug-in…, `effect.plugins.load {path \| folder}`, or the `Plug-ins` folder next to the settings at start-up | `register_plugin(Arc::new(MyEffect))` in an app that links EffectCraft's crates |
|
||||
| Sandbox | yes: no imports (no files, clock or network), fuel-limited, deterministic floats | none (it is your code) |
|
||||
| Sandbox | yes: no imports (no files, clock or network), fuel-limited, at most 1 GiB of memory, deterministic floats | none (it is your code) |
|
||||
| Platforms | desktop and CLI (the wasmi interpreter, crate feature `effectcraft-plugin/wasm`); not the web build | everywhere |
|
||||
|
||||
`effect.plugins.list` lists what is loaded: `{api, wasm, plugins: [{id, name, category, version,
|
||||
|
|
@ -60,7 +60,9 @@ Every plug-in describes itself with a JSON manifest:
|
|||
* Parameter types: `slider` (`default`, `min`, `max`, optional `sliderMin`, `sliderMax`,
|
||||
`decimals`), `angle` (`default` degrees), `checkbox` (`default` bool), `popup` (`options`,
|
||||
`default` index), `point` (`default` as fractions of the layer size, `[0.5, 0.5]` = centre),
|
||||
`color` (`default` RGBA 0–1). Parameter ids are letters, digits and `_`.
|
||||
`color` (`default` RGBA 0–1). Parameter ids are letters, digits and `_`, unique; at most 256
|
||||
parameters; a slider needs `min ≤ default ≤ max` (and `sliderMin ≤ sliderMax`); the manifest
|
||||
is at most 1 MiB.
|
||||
|
||||
## WebAssembly ABI (v1)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue