mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
compatibility "1.3" writes a %PDF-1.3 file (its XMP metadata says 1.3 too) without transparency: a copy of the document is flattened first with flattenerPreset (stored with PDF presets; empty is High Resolution) and the flattener options over it, see-through image pixels counting as transparency and rasterized areas clipped to their regions so no soft mask is needed. Page groups are left out and the writer refuses a file that would still have transparency. PDF/X-1a and PDF/X-3 share this path (their version is now PDF 1.3; 1.4 is still accepted and written as 1.3), and with it the preset, the header and metadata code and the transparency check. Passwords at PDF 1.3 encrypt with 40-bit RC4 (revision 2). The Save PDF dialog lists PDF 1.3 and picks the flattener preset under Advanced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
8.7 KiB
Rust
156 lines
8.7 KiB
Rust
//! `document.exportPdf {compatibility: "1.3", flattenerPreset?, flattener?}`: a PDF 1.3 file has
|
|
//! no transparency (no soft masks, blend modes, constant opacity or transparency groups): it is
|
|
//! flattened with the chosen preset, and reads back looking like the canvas.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_geom::Rect;
|
|
use vectorcraft_render::{Rendered, Renderer};
|
|
|
|
use super::*;
|
|
|
|
/// A document with overlapping transparency: a half-opaque rectangle, a Multiply ellipse, a
|
|
/// gradient fading out, a drop shadow and a PNG with see-through pixels, in an isolated page.
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 160}));
|
|
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 90, "height": 70}));
|
|
run(&mut s, "paint.setFill", json!({"color": "#2060e0"}));
|
|
run(&mut s, "shape.rectangle", json!({"x": 50, "y": 40, "width": 90, "height": 70}));
|
|
run(&mut s, "paint.setFill", json!({"color": "#f02010"}));
|
|
run(&mut s, "transparency.set", json!({"opacity": 50}));
|
|
run(&mut s, "shape.ellipse", json!({"x": 90, "y": 20, "width": 80, "height": 80}));
|
|
run(&mut s, "paint.setFill", json!({"color": "#20c040"}));
|
|
run(&mut s, "transparency.set", json!({"blendMode": "multiply"}));
|
|
let shadowed = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 100, "width": 50, "height": 40}))["id"].clone();
|
|
run(&mut s, "paint.setFill", json!({"color": "#e0c020"}));
|
|
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [shadowed]}));
|
|
let mut png = vec![];
|
|
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
|
|
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
|
.unwrap();
|
|
run(&mut s, "file.place", json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 120]}));
|
|
s
|
|
}
|
|
|
|
fn export(s: &mut Session, p: Value) -> (Vec<u8>, Vec<String>) {
|
|
let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
|
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
|
(bytes, v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect())
|
|
}
|
|
|
|
/// The transparency `pdf` has, in its dictionaries.
|
|
fn transparency(pdf: &[u8]) -> Vec<&'static str> {
|
|
let text = String::from_utf8_lossy(pdf);
|
|
let mut found = vec![];
|
|
for (what, there) in [
|
|
("soft masks", text.contains("/SMask") && !text.contains("/SMask/None")),
|
|
("blend modes", text.contains("/BM") && !text.contains("/BM/Normal")),
|
|
("transparency groups", text.contains("/S/Transparency")),
|
|
("opacity", text.contains("/ca 0.") || text.contains("/CA 0.")),
|
|
] {
|
|
if there {
|
|
found.push(what);
|
|
}
|
|
}
|
|
found
|
|
}
|
|
|
|
fn render(d: &vectorcraft_doc::Document) -> Rendered {
|
|
Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 160.0), 1.0, true)
|
|
}
|
|
|
|
/// Mean difference of the colour channels of two renders, as a share of full scale.
|
|
fn mean_diff(a: &Rendered, b: &Rendered) -> f64 {
|
|
let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum();
|
|
sum as f64 / a.pixels.len() as f64 / 255.0
|
|
}
|
|
|
|
#[test]
|
|
fn pdf_1_3_flattens_all_transparency_and_looks_the_same() {
|
|
let mut s = session();
|
|
let before = s.doc().unwrap().doc.clone();
|
|
// The same document at PDF 1.4 has transparency.
|
|
let (pdf, _) = export(&mut s, json!({"compatibility": "1.4", "compression": {"compressText": false}, "preserveEditing": false}));
|
|
assert!(transparency(&pdf).len() >= 3, "{:?}", transparency(&pdf));
|
|
let (pdf, warnings) = export(&mut s, json!({"compatibility": "1.3", "compression": {"compressText": false}, "preserveEditing": false}));
|
|
assert!(pdf.starts_with(b"%PDF-1.3"));
|
|
assert!(String::from_utf8_lossy(&pdf).contains("<pdf:PDFVersion>1.3<"), "the metadata says 1.3 too");
|
|
assert_eq!(transparency(&pdf), Vec::<&str>::new());
|
|
assert!(warnings.iter().any(|w| w.contains("flattened") && w.contains("PDF 1.3")), "{warnings:?}");
|
|
// It reads back looking like the canvas.
|
|
let canvas = render(&before);
|
|
let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&pdf).unwrap()));
|
|
assert!(diff < 0.02, "read back {:.2}% off", diff * 100.0);
|
|
// The document itself is untouched.
|
|
assert_eq!(s.doc().unwrap().doc, before);
|
|
// The writer refuses a document that isn't flat; a flat one with isolated page blending gets
|
|
// no page group.
|
|
let opts = vectorcraft_pdf::PdfOptions { settings: serde_json::from_value(json!({"compatibility": "1.3"})).unwrap(), ..Default::default() };
|
|
let e = vectorcraft_pdf::export(&before, &opts).unwrap_err();
|
|
assert!(matches!(&e, vectorcraft_pdf::PdfError::Unsupported(m) if m.contains("PDF 1.3")), "{e}");
|
|
let mut flat = vectorcraft_doc::Document::new(100.0, 100.0);
|
|
flat.page_isolate = true;
|
|
let l = flat.default_layer().unwrap();
|
|
let id = flat.alloc_id();
|
|
let rect = vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0));
|
|
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
|
|
flat.insert(Some(l), 0, vectorcraft_doc::Node::path(id, rect, vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0)))
|
|
.unwrap();
|
|
assert!(transparency(&vectorcraft_pdf::export(&flat, &opts).unwrap()).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_flattener_preset_and_options_apply() {
|
|
let mut s = session();
|
|
let images = |pdf: &[u8]| String::from_utf8_lossy(pdf).matches("/Subtype/Image").count();
|
|
let p = |extra: Value| {
|
|
let mut p = json!({"compatibility": "1.3", "preserveEditing": false});
|
|
super::pdf::merge(&mut p, &extra);
|
|
p
|
|
};
|
|
let (high, _) = export(&mut s, p(json!({})));
|
|
let (default, _) = export(&mut s, p(json!({"flattenerPreset": "High Resolution"})));
|
|
assert_eq!(high.len(), default.len(), "the default is High Resolution");
|
|
let (low, _) = export(&mut s, p(json!({"flattenerPreset": "low"})));
|
|
assert!(low.len() < high.len(), "lower resolution rasters");
|
|
// All raster: one image a flattened group at most, and still flat.
|
|
let (raster, _) = export(&mut s, p(json!({"flattener": {"balance": 0}})));
|
|
assert!(images(&raster) >= 1 && transparency(&raster).is_empty());
|
|
// A saved preset by name, through every PDF path; an unknown one is refused.
|
|
s.execute("flattener.presets.save", &json!({"name": "All Raster", "preset": "high", "balance": 0})).unwrap();
|
|
let (saved, _) = export(&mut s, p(json!({"flattenerPreset": "All Raster"})));
|
|
assert_eq!(saved.len(), raster.len());
|
|
let v = s
|
|
.execute("document.export", &json!({"format": "pdf", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false}))
|
|
.unwrap();
|
|
assert_eq!(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap().len(), raster.len());
|
|
s.execute("pdf.preset.save", &json!({"name": "Old Press", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false}))
|
|
.unwrap();
|
|
assert_eq!(export(&mut s, json!({"preset": "Old Press"})).0.len(), raster.len(), "a PDF preset keeps its flattener preset");
|
|
assert!(s.execute("document.exportPdf", &p(json!({"flattenerPreset": "Nope"}))).is_err());
|
|
// Other versions keep their transparency, whatever the preset.
|
|
let (kept, warnings) = export(&mut s, json!({"flattenerPreset": "low", "compression": {"compressText": false}}));
|
|
assert!(!transparency(&kept).is_empty() && !warnings.iter().any(|w| w.contains("flattened")));
|
|
}
|
|
|
|
#[test]
|
|
fn pdf_1_3_files_are_encrypted_with_40_bit_rc4_and_can_be_linearised() {
|
|
let mut s = session();
|
|
let (pdf, warnings) = export(
|
|
&mut s,
|
|
json!({"compatibility": "1.3", "fastWebView": true, "thumbnails": true, "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": false}}),
|
|
);
|
|
assert!(pdf.starts_with(b"%PDF-1.3") && warnings.iter().all(|w| !w.contains("metadata")), "{warnings:?}");
|
|
let text = String::from_utf8_lossy(&pdf);
|
|
assert!(text.contains("/V 1/R 2/Length 40") && text.contains("/Linearized 1") && text.contains("/Thumb "));
|
|
for password in ["open", "owner"] {
|
|
s.execute("document.close", &json!({"force": true})).ok();
|
|
s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": password})).unwrap();
|
|
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "{password}");
|
|
}
|
|
assert!(
|
|
s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": "nope"})).is_err()
|
|
);
|
|
}
|