Files
vectorcraft/crates/engine/src/cmd/fileio/tests_pdf.rs
T
bflatasticandClaude Opus 5.5 a40bce615a M4.32: PDF warnings, Summary section, View PDF after Saving and status text
document.export and document.serialize return the encoder's warnings (PDF:
options not applied yet, features approximated or left out, overprint).
document.pdfSettings lists the options that differ from the defaults and
the warnings (includeDocument adds the active document's own from an
in-memory export); the dialog's Summary section shows both. View PDF after
Saving opens the written file as a file:// URL through Services.open_url;
the status line reports the notes. The PDF writer's raster-effects warning
now names what it leaves out (objects of a kind, fills, strokes) instead of
claiming raster effects are never exported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:22:27 +07:00

178 lines
8.5 KiB
Rust

//! `document.exportPdf`, `document.pdfSettings` and the PDF options of every PDF export.
use serde_json::{Value, json};
use super::pdf::DEFAULT_PRESET;
use super::*;
fn session(artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": artboards})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
s
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
fn export(s: &mut Session, p: Value) -> Value {
s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"))
}
fn warnings(v: &Value) -> Vec<String> {
v["warnings"].as_array().expect("warnings").iter().map(|w| w.as_str().unwrap().to_string()).collect()
}
fn pages(bytes: &[u8]) -> usize {
vectorcraft_pdf::import(bytes).unwrap().artboards.len()
}
/// `p` plus `format: pdf` (the same options through `document.export`).
fn as_export(p: &Value) -> Value {
let mut o = p.as_object().cloned().unwrap_or_default();
o.insert("format".into(), json!("pdf"));
Value::Object(o)
}
#[test]
fn export_pdf_writes_the_compatibility_header_and_the_range() {
let mut s = session(3);
let v = export(&mut s, json!({}));
let pdf = b64(&v);
assert!(pdf.starts_with(b"%PDF-1.7"));
assert_eq!(pages(&pdf), 3);
assert_eq!(v["bytes"], pdf.len());
assert!(warnings(&v).is_empty(), "{v}");
assert!(b64(&export(&mut s, json!({"compatibility": "1.5"}))).starts_with(b"%PDF-1.5"));
assert!(b64(&export(&mut s, json!({"compatibility": "2.0"}))).starts_with(b"%PDF-2.0"));
assert_eq!(pages(&b64(&export(&mut s, json!({"range": "1,3"})))), 2);
assert_eq!(pages(&b64(&export(&mut s, json!({"artboards": [1]})))), 1);
// The same options through document.export and document.serialize.
let v = s.execute("document.export", &as_export(&json!({"compatibility": "1.4", "range": "2"}))).unwrap();
assert!(b64(&v).starts_with(b"%PDF-1.4") && pages(&b64(&v)) == 1);
assert!(v["warnings"].is_array());
let v = s.execute("document.serialize", &as_export(&json!({"compatibility": "1.6"}))).unwrap();
assert!(b64(&v).starts_with(b"%PDF-1.6"));
}
#[test]
fn bad_options_are_refused() {
let mut s = session(2);
for p in [
json!({"compatibility": "1.3"}),
json!({"compatibility": 1.5}),
json!({"range": "1-3"}),
json!({"range": "0"}),
json!({"artboards": []}),
json!({"preset": "Nope"}),
json!({"standard": "pdfX4"}),
json!({"standard": "pdfA2b", "compatibility": "2.0"}),
json!({"compression": {"color": {"ppi": 0}}}),
json!({"compression": "zip"}),
json!({"security": {"openPassword": "x"}}),
] {
let e = s.execute("document.exportPdf", &p).unwrap_err();
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}");
assert!(s.execute("document.export", &as_export(&p)).is_err(), "document.export {p}");
}
}
#[test]
fn options_apply_over_the_preset() {
let mut s = session(1);
let a = b64(&export(&mut s, json!({"preset": DEFAULT_PRESET, "compression": {"compressText": false}})));
let b = b64(&export(&mut s, json!({"preset": "default"})));
assert!(a.len() > b.len(), "content streams left uncompressed");
assert!(String::from_utf8_lossy(&a).contains(" re") || String::from_utf8_lossy(&a).contains(" l"));
// `null` (the default document.formats lists) keeps the preset's value, at any depth.
let p = json!({"compatibility": null, "compression": null, "security": {"openPassword": null}, "marks": {"weight": null}});
let v = s.execute("document.pdfSettings", &p).unwrap();
assert_eq!(v["changed"], json!([]), "{v}");
assert!(b64(&export(&mut s, p)).starts_with(b"%PDF-1.7"));
}
#[test]
fn pdf_a_refuses_pdf_2() {
let mut s = session(1);
let e = s.execute("document.pdfSettings", &json!({"standard": "pdfA2b", "compatibility": "2.0"})).unwrap_err();
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{e}");
let pdf = b64(&export(&mut s, json!({"standard": "pdfA2b", "compatibility": "1.6"})));
assert!(pdf.starts_with(b"%PDF-1.6") && String::from_utf8_lossy(&pdf).contains("pdfaid"));
}
#[test]
fn options_not_applied_yet_and_document_features_warn() {
let mut s = session(1);
let w = warnings(&export(&mut s, json!({"thumbnails": true, "marks": {"trim": true}})));
assert_eq!(w.len(), 2, "{w:?}");
assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("marks")));
// A pattern stroke is approximated: its warning comes back from every PDF path.
let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [tile]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let frame = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setStroke", &json!({"ids": [frame], "swatch": "Dots"})).unwrap();
let pattern = |w: Vec<String>| w.iter().any(|w| w.contains("pattern strokes"));
assert!(pattern(warnings(&export(&mut s, json!({})))));
assert!(pattern(warnings(&s.execute("document.export", &as_export(&json!({}))).unwrap())));
// Other formats have none.
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
}
#[test]
fn export_pdf_writes_a_path() {
let mut s = session(2);
let dir = std::env::temp_dir().join(format!("vc-exportpdf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("out.pdf").to_string_lossy().to_string();
let v = export(&mut s, json!({"path": path, "range": "2"}));
assert_eq!(v["path"], path.as_str());
assert!(v.get("dataBase64").is_none());
let bytes = std::fs::read(&path).unwrap();
assert_eq!(v["bytes"], bytes.len());
assert_eq!(pages(&bytes), 1);
assert_eq!(s.doc().unwrap().path, None, "exporting never retargets the document");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn pdf_settings_summarise_changes_and_warnings() {
let mut s = Session::new();
let v = s.execute("document.pdfSettings", &json!({})).unwrap();
assert_eq!(v["settings"]["compatibility"], "1.7");
assert_eq!(v["presets"][0], DEFAULT_PRESET);
assert_eq!(v["changed"], json!([]));
assert_eq!(v["warnings"], json!([]));
let p = json!({"compatibility": "1.5", "compression": {"compressText": false}, "createLayers": true, "security": {"copy": false, "openPassword": ""}});
let v = s.execute("document.pdfSettings", &p).unwrap();
let changed: Vec<&str> = v["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect();
assert_eq!(changed, ["compatibility", "compression.compressText", "createLayers", "security.copy"], "key order");
assert_eq!(v["changed"][0]["value"], "1.5");
assert_eq!(v["warnings"].as_array().unwrap().len(), 2, "layers and permissions: {}", v["warnings"]);
assert!(!v.to_string().contains("Password"), "passwords never come back");
assert!(s.execute("document.pdfSettings", &json!({"compatibility": "9"})).is_err());
// With the document: the in-memory export adds the document's own warnings.
let mut s = session(1);
s.execute("select.all", &json!({})).unwrap();
s.execute("object.group", &json!({})).unwrap();
let g = s.doc().unwrap().selection.objects[0].0;
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [g]})).unwrap();
assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["warnings"], json!([]));
let full = s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap();
assert!(warnings(&full).iter().any(|w| w.contains("group objects")), "{}", full["warnings"]);
}
#[test]
fn formats_list_the_pdf_options() {
let mut s = Session::new();
let v = s.execute("document.formats", &json!({})).unwrap();
let pdf = v["formats"].as_array().unwrap().iter().find(|f| f["id"] == "pdf").unwrap();
for k in ["artboard", "range", "preset", "standard", "compatibility", "compression", "security"] {
assert!(pdf["options"].get(k).is_some(), "{k}");
}
assert_eq!(pdf["options"]["compatibility"]["default"], "1.7");
assert_eq!(pdf["options"]["preset"]["default"], DEFAULT_PRESET);
}