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:
bflatastic
2026-10-05 18:05:36 +07:00
co-authored by Claude Opus 5.5
parent 8961824876
commit 79581c7ff5
21 changed files with 507 additions and 126 deletions
+7 -11
View File
@@ -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(&params(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,
})
+1 -2
View File
@@ -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
+3
View File
@@ -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
+3 -1
View File
@@ -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"}),
+155
View File
@@ -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()
);
}
+1 -1
View File
@@ -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.
+10
View File
@@ -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> {