mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
M4.87: PDF 1.3 compatibility: transparency flattened with the chosen preset
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8961824876
commit
79581c7ff5
@@ -135,11 +135,7 @@ fn choice<T>(field: &str, v: Option<String>, read: impl Fn(&str) -> Option<T>, d
|
||||
/// The flattener options `p` asks for (`flattenerPreset` and `flattener` over it), built-in
|
||||
/// presets and `saved` ones; discarded overprints are dropped from the flattened art too.
|
||||
fn flattener(p: &Params, saved: &[super::super::FlattenerPreset]) -> Result<FlattenOptions> {
|
||||
let mut q = json!({ "options": p.flattener.clone().unwrap_or(Value::Null) });
|
||||
if let Some(name) = &p.flattener_preset {
|
||||
q["preset"] = json!(name);
|
||||
}
|
||||
FlattenOptions::from_params_with(&q, saved).map_err(|e| bad(C, format!("EPS flattener: {e}")))
|
||||
FlattenOptions::for_export(p.flattener_preset.as_deref(), p.flattener.as_ref(), saved).map_err(|e| bad(C, format!("EPS flattener: {e}")))
|
||||
}
|
||||
|
||||
fn params(p: &Value) -> Result<Params> {
|
||||
@@ -176,7 +172,7 @@ fn parse(p: &Value) -> Result<Settings> {
|
||||
/// `saved` it names written out, as the encoder needs it.
|
||||
pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::result::Result<Value, String> {
|
||||
let run = || -> Result<Value> {
|
||||
let q = expanded(p, saved)?.unwrap_or_else(|| p.clone());
|
||||
let q = expanded(C, p, saved)?.unwrap_or_else(|| p.clone());
|
||||
parse(&q)?;
|
||||
Ok(q)
|
||||
};
|
||||
@@ -185,10 +181,10 @@ pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::resul
|
||||
|
||||
/// `p` with a saved flattener preset it names written out as `flattener` options, for the
|
||||
/// encoder, which knows only the built-in presets; `None` when `p` names none.
|
||||
fn expanded(p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
|
||||
fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
|
||||
match str_param(p, "flattenerPreset") {
|
||||
Some(name) if FlattenOptions::preset(name).is_none() => {
|
||||
let o = flattener(¶ms(p)?, saved)?;
|
||||
Some(name) if !name.trim().is_empty() && FlattenOptions::preset(name).is_none() => {
|
||||
let o = FlattenOptions::for_export(Some(name), p.get("flattener"), saved).map_err(|e| bad(cmd, format!("flattener: {e}")))?;
|
||||
let mut q = p.clone();
|
||||
q["flattener"] = serde_json::to_value(o).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let Some(m) = q.as_object_mut() {
|
||||
@@ -299,8 +295,8 @@ fn export_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// `p` with a saved flattener preset of the session's written out (see [`expanded`]).
|
||||
pub(super) fn with_flattener<'a>(s: &Session, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
|
||||
Ok(match expanded(&p, &s.prefs.flattener_presets)? {
|
||||
pub(super) fn with_flattener<'a>(s: &Session, cmd: &str, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
|
||||
Ok(match expanded(cmd, &p, &s.prefs.flattener_presets)? {
|
||||
Some(q) => Cow::Owned(q),
|
||||
None => p,
|
||||
})
|
||||
|
||||
@@ -104,8 +104,7 @@ pub(super) fn without_artboards(p: &Value) -> Value {
|
||||
/// `p` with the saved presets it names (PDF, flattener) written out as options, for the encoders,
|
||||
/// which know only the built-in ones.
|
||||
fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
|
||||
let pdf = super::pdf::expand_preset(s, cmd, p)?;
|
||||
super::eps::with_flattener(s, pdf)
|
||||
super::pdf::expand_preset(s, cmd, p)
|
||||
}
|
||||
|
||||
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
|
||||
|
||||
@@ -914,3 +914,6 @@ mod tests_pdflayers;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfwebview;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdf13;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -60,7 +60,9 @@ fn export_pdf_writes_the_compatibility_header_and_the_range() {
|
||||
fn bad_options_are_refused() {
|
||||
let mut s = session(2);
|
||||
for p in [
|
||||
json!({"compatibility": "1.3"}),
|
||||
json!({"compatibility": "1.2"}),
|
||||
json!({"compatibility": "1.3", "standard": "pdfA2b"}),
|
||||
json!({"compatibility": "1.3", "flattenerPreset": "Nope"}),
|
||||
json!({"compatibility": 1.5}),
|
||||
json!({"range": "1-3"}),
|
||||
json!({"range": "0"}),
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! `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()
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
|
||||
let v = settings(&mut s, json!({"standard": "pdfX4"}));
|
||||
assert_eq!((&v["compatibility"], &v["preserveEditing"]), (&json!("1.6"), &json!(false)));
|
||||
let v = settings(&mut s, json!({"standard": "pdfX1a", "createLayers": null, "preset": "Press Quality"}));
|
||||
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.4"), &json!(false)));
|
||||
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.3"), &json!(false)));
|
||||
let v = settings(&mut s, json!({"standard": "pdfX4", "compatibility": "1.5", "createLayers": true}));
|
||||
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.5"), &json!(true)));
|
||||
// What is asked for explicitly is checked, not changed.
|
||||
|
||||
@@ -204,6 +204,16 @@ impl FlattenOptions {
|
||||
Self::from_params_with(p, &[])
|
||||
}
|
||||
|
||||
/// The options an export flattens with: preset `name` (built-in or one of `saved`; default
|
||||
/// medium) with the export's `flattener` `options` over it.
|
||||
pub fn for_export(name: Option<&str>, options: Option<&Value>, saved: &[FlattenerPreset]) -> std::result::Result<Self, String> {
|
||||
let mut q = json!({ "options": options.cloned().unwrap_or(Value::Null) });
|
||||
if let Some(name) = name {
|
||||
q["preset"] = json!(name);
|
||||
}
|
||||
Self::from_params_with(&q, saved)
|
||||
}
|
||||
|
||||
/// The options `p` asks for: `preset` (built-in or one of `saved`; default medium) adjusted by
|
||||
/// the option keys given at the top level or in `options`.
|
||||
pub fn from_params_with(p: &Value, saved: &[FlattenerPreset]) -> std::result::Result<Self, String> {
|
||||
|
||||
Reference in New Issue
Block a user