diff --git a/Cargo.lock b/Cargo.lock index 0d73e0f6..9d9270f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4217,6 +4217,8 @@ dependencies = [ "krilla", "kurbo 0.13.1", "log", + "serde", + "serde_json", "smallvec", "thiserror 2.0.21", "vectorcraft-brush", diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 15666abc..c36d7e03 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -8,7 +8,6 @@ use vectorcraft_doc::Document; use super::super::*; use super::Format; -use crate::EngineError; const C: &str = "document.export"; @@ -102,10 +101,19 @@ fn check_format_size(f: &Format, w: f64, h: f64) -> Result<()> { /// options from `p` (see `document.formats`). Raster formats leave template layers out, and no /// format writes the opacity-mask editing layer. pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { + encode_with_warnings(doc, format, p).map(|(bytes, _)| bytes) +} + +/// Like [`encode`], also returning the encoder's warnings (PDF: options not applied yet, features +/// approximated or left out; the other formats have none). +pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec, Vec)> { let f = super::writable(C, Some(format), None)?; let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); - Ok(match f.id { + if f.id == "pdf" { + return super::pdf::encode(C, doc, p); + } + let bytes = match f.id { "vectorcraft" => vectorcraft_format::save_file(doc), "svg" => { let o: SvgOptions = options(f, p)?; @@ -113,11 +121,6 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { let opts = vectorcraft_svg::ExportOptions { artboard, outline_text: o.outline_text.unwrap_or(false), ..Default::default() }; vectorcraft_svg::export(doc, &opts).into_bytes() } - "pdf" => { - let o: ArtboardPick = options(f, p)?; - let opts = vectorcraft_pdf::PdfOptions { artboards: boards(o.resolve(n))?, ..Default::default() }; - super::super::rasterfx::export_pdf(doc, &opts).map_err(|e| EngineError::Other(e.to_string()))? - } "png" | "jpg" | "webp" => { let o: RasterOptions = options(f, p)?; let region = doc.artboards[boards(o.boards.one(n))?].rect; @@ -133,5 +136,6 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { .map_err(EngineError::Other)? } _ => return Err(bad(C, format!("no encoder for {} yet", f.label))), - }) + }; + Ok((bytes, vec![])) } diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 566dd4cf..642f9465 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -8,22 +8,22 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; use super::super::*; -use super::{ARTBOARD_PARAMS, ArtboardPick, Format, create_dir, encode, writable, write_file, write_or_return}; +use super::{ARTBOARD_PARAMS, ArtboardPick, Format, create_dir, encode, encode_with_warnings, writable, write_file, write_or_return}; pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?; - let bytes = encode(&s.doc()?.doc, f.id, p)?; + let (bytes, warnings) = encode_with_warnings(&s.doc()?.doc, f.id, p)?; if f.id == "svg" { - return Ok(json!({ "text": String::from_utf8_lossy(&bytes) })); + return Ok(json!({ "text": String::from_utf8_lossy(&bytes), "warnings": warnings })); } - Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) })) + Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes), "warnings": warnings })) } pub(super) fn export(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path"); let f = writable("document.export", str_param(p, "format"), path)?; - let bytes = encode(&s.doc()?.doc, f.id, p)?; - write_or_return(path, &bytes, json!({ "format": f.id })) + let (bytes, warnings) = encode_with_warnings(&s.doc()?.doc, f.id, p)?; + write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings })) } /// `p` without its artboard choice (for documents made of one synthetic artboard). diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 7e238c74..2a5e5044 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -5,6 +5,7 @@ //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`. //! - `save`: `document.save`, `file.saveAsTemplate`. +//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -13,11 +14,12 @@ mod batch; mod encode; mod export; mod load; +pub mod pdf; mod save; use serde_json::{Value, json}; -pub use encode::{ARTBOARD_PARAMS, ArtboardPick, encode}; +pub use encode::{ARTBOARD_PARAMS, ArtboardPick, encode, encode_with_warnings}; pub use load::{Loaded, RasterImage, detect, load, open_bytes, raster_image}; use super::*; @@ -48,7 +50,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|svg|pdf|png|jpg|webp, …the format's options (see document.formats)} → {text} for svg, else {dataBase64}", + "{format?: vectorcraft (default)|svg|pdf|png|jpg|webp, …the format's options (see document.formats)} → {text, warnings} for svg, else {dataBase64, warnings}", has_doc, export::serialize ), @@ -57,7 +59,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" (1-based; PDF writes one page per artboard, default all; the other formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg)} → {path, format, bytes}; no path → {dataBase64, format, bytes}. Never changes the document's path", + "{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" (1-based; PDF writes one page per artboard, default all; the other formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg), …the PDF options of document.exportPdf} → {path, format, bytes, warnings}; no path → {dataBase64, format, bytes, warnings}. Never changes the document's path", has_doc, export::export ), @@ -105,7 +107,7 @@ pub fn specs() -> Vec { #[derive(Clone, Copy, Debug)] pub struct FormatOption { pub name: &'static str, - /// JSON type: `number`, `integer`, `boolean`, `string` or `array`. + /// JSON type: `number`, `integer`, `boolean`, `string`, `array` or `object`. pub ty: &'static str, /// The default as a JSON literal (`"1"`, `"false"`, `"null"`). pub default: &'static str, @@ -200,16 +202,7 @@ pub const FORMATS: &[Format] = &[ options: &[ARTBOARD, OUTLINE_TEXT], }, reader("svgz", "SVG Compressed", &["svgz"], "image/svg+xml", false), - Format { - id: "pdf", - label: "PDF", - extensions: &["pdf"], - mime: "application/pdf", - read: true, - write: true, - raster: false, - options: &[ARTBOARD, ARTBOARDS, RANGE], - }, + Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS }, reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false), reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false), Format { id: "png", label: "PNG", extensions: &["png"], mime: "image/png", read: true, write: true, raster: true, options: &[ARTBOARD, SCALE] }, @@ -346,3 +339,5 @@ fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result Vec { + vec![ + cmd!( + "document.exportPdf", + "Export PDF", + [], + None, + "{path?, preset?: \"VectorCraft Default\", artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing?, thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength}, compressText?: true}, marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6}, bleed?: {useDocument, top, bottom, left, right} (pt), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", + has_doc, + export_pdf + ), + cmd!( + query "document.pdfSettings", + "PDF Settings", + [], + None, + "{preset?, includeDocument?: false, …document.exportPdf options} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the defaults), warnings}; includeDocument also exports the active document in memory and adds its warnings (pattern strokes, effects left out…)", + always, + pdf_settings + ), + ] +} + +/// Every preset name (built-in first). +pub fn presets() -> Vec { + vec![DEFAULT_PRESET.to_string()] +} + +fn preset(cmd: &str, name: &str) -> Result { + if name.eq_ignore_ascii_case(DEFAULT_PRESET) || name.eq_ignore_ascii_case("default") { + return Ok(PdfSettings::default()); + } + Err(bad(cmd, format!("unknown PDF preset `{name}` (presets: {})", presets().join(", ")))) +} + +/// Merge `over` into `base`: objects key by key (recursively), `null` keeps the base (the +/// preset's value), anything else replaces. +fn merge(base: &mut Value, over: &Value) { + match (base, over) { + (Value::Object(b), Value::Object(o)) => { + for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) { + merge(b.entry(k.as_str()).or_insert(Value::Null), v); + } + } + (b, o) => *b = o.clone(), + } +} + +/// A writer error as an engine error: bad settings are bad params of `cmd`. +fn pdf_error(cmd: &str, e: PdfError) -> EngineError { + match e { + PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()), + e => EngineError::Other(e.to_string()), + } +} + +/// The settings `p` asks for: its preset (default: [`DEFAULT_PRESET`]) with `p`'s options applied +/// over it, checked. Keys that aren't PDF options (path, format…) are ignored. +pub fn settings(cmd: &str, p: &Value) -> Result { + let base = match str_param(p, "preset") { + Some(name) => preset(cmd, name)?, + None => PdfSettings::default(), + }; + if !p.is_object() { + return Ok(base); + } + let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?; + merge(&mut v, p); + let s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?; + s.check().map_err(|e| pdf_error(cmd, e))?; + Ok(s) +} + +/// The full export options for `doc`: settings plus the artboards `p` picks (default all). +pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result { + let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() }; + let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?; + Ok(PdfOptions { settings: settings(cmd, p)?, artboards, ..Default::default() }) +} + +/// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered +/// to images at the document's raster effects resolution. +pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { + let opts = options(cmd, doc, p)?; + let r = super::super::rasterfx::export_pdf_with_report(doc, &opts).map_err(|e| pdf_error(cmd, e))?; + let mut warnings = r.warnings; + if opts.settings.advanced.overprint == Overprint::Preserve && doc.layers.iter().any(|l| l.has_overprint()) { + warnings.push("overprinting objects are written without overprint: overprint is not written to PDF yet".into()); + } + Ok((r.bytes, warnings)) +} + +fn export_pdf(s: &mut Session, p: &Value) -> Result { + let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?; + write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) +} + +/// `(path, value)` for every leaf of `v` that differs from `default` (`compression.color.ppi`). +fn changed(prefix: &str, v: &Value, default: &Value, out: &mut Vec) { + match (v, default) { + (Value::Object(o), Value::Object(d)) => { + for (k, x) in o { + let path = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") }; + changed(&path, x, d.get(k).unwrap_or(&Value::Null), out); + } + } + _ if v != default => out.push(json!({ "option": prefix, "value": v })), + _ => {} + } +} + +fn pdf_settings(s: &mut Session, p: &Value) -> Result { + const Q: &str = "document.pdfSettings"; + let set = settings(Q, p)?; + let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; + let default = serde_json::to_value(PdfSettings::default()).map_err(|e| EngineError::Other(e.to_string()))?; + let mut diff = vec![]; + changed("", &v, &default, &mut diff); + let warnings = match s.active() { + Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, p)?.1, + _ => set.warnings(), + }; + Ok(json!({ "settings": v, "presets": presets(), "changed": diff, "warnings": warnings })) +} diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs new file mode 100644 index 00000000..2156a294 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -0,0 +1,177 @@ +//! `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 { + 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 { + 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| 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); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 21adff72..45a2f159 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -210,6 +210,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(attributes::specs()); v.extend(newart::specs()); v.extend(colortheme::specs()); + v.extend(fileio::pdf::specs()); v }) } diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index d1b9533d..8233976b 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -181,9 +181,18 @@ pub fn flatten_raster_effects(doc: &Document) -> Option { /// PDF bytes for `doc`, with raster effects rendered as images. pub fn export_pdf(doc: &Document, opts: &vectorcraft_pdf::PdfOptions) -> std::result::Result, vectorcraft_pdf::PdfError> { + export_pdf_with_report(doc, opts).map(|r| r.bytes) +} + +/// Like [`export_pdf`], also returning the writer's warnings (options not applied yet, features +/// approximated or left out). +pub fn export_pdf_with_report( + doc: &Document, + opts: &vectorcraft_pdf::PdfOptions, +) -> std::result::Result { match flatten_raster_effects(doc) { - Some(d) => vectorcraft_pdf::export(&d, opts), - None => vectorcraft_pdf::export(doc, opts), + Some(d) => vectorcraft_pdf::export_with_report(&d, opts), + None => vectorcraft_pdf::export_with_report(doc, opts), } } diff --git a/crates/engine/src/tests_colormgmt.rs b/crates/engine/src/tests_colormgmt.rs index 0bc4e2d2..b4265791 100644 --- a/crates/engine/src/tests_colormgmt.rs +++ b/crates/engine/src/tests_colormgmt.rs @@ -318,7 +318,8 @@ fn has_cmyk_op(pdf: &str) -> bool { } fn pdf_text(doc: &Document) -> String { - let bytes = vectorcraft_pdf::export(doc, &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap(); + let bytes = + vectorcraft_pdf::export(doc, &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap(); String::from_utf8_lossy(&bytes).into_owned() } diff --git a/crates/engine/src/tests_knockout.rs b/crates/engine/src/tests_knockout.rs index 26e27cc0..972dd9f7 100644 --- a/crates/engine/src/tests_knockout.rs +++ b/crates/engine/src/tests_knockout.rs @@ -145,7 +145,7 @@ fn pdf_and_svg_write_knockout_groups_as_masks() { run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "on"})); let ko = s.doc().unwrap().doc.clone(); - let pdf = |d: &Document| vectorcraft_pdf::export_with_report(d, &vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap(); + let pdf = |d: &Document| vectorcraft_pdf::export_with_report(d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); let r = pdf(&plain); assert!(!String::from_utf8_lossy(&r.bytes).contains("/SMask") && r.warnings.is_empty()); let r = pdf(&ko); diff --git a/crates/engine/src/tests_labspots.rs b/crates/engine/src/tests_labspots.rs index f405d494..24b1a5c9 100644 --- a/crates/engine/src/tests_labspots.rs +++ b/crates/engine/src/tests_labspots.rs @@ -149,7 +149,7 @@ fn spot_options_and_lab_colours_round_trip() { } fn pdf_bytes(s: &Session) -> Vec { - let opts = vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }; + let opts = vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }; vectorcraft_pdf::export(doc(s), &opts).unwrap() } diff --git a/crates/engine/src/tests_linked_stops.rs b/crates/engine/src/tests_linked_stops.rs index edd392fa..2b0cae9e 100644 --- a/crates/engine/src/tests_linked_stops.rs +++ b/crates/engine/src/tests_linked_stops.rs @@ -120,7 +120,7 @@ fn spot_stops_separate_on_their_plate() { } fn pdf_of(s: &Session) -> (String, Vec) { - let opts = vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }; + let opts = vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }; let r = vectorcraft_pdf::export_with_report(doc(s), &opts).unwrap(); (String::from_utf8_lossy(&r.bytes).into_owned(), r.warnings) } diff --git a/crates/engine/src/tests_registration.rs b/crates/engine/src/tests_registration.rs index af232531..9b539f47 100644 --- a/crates/engine/src/tests_registration.rs +++ b/crates/engine/src/tests_registration.rs @@ -103,9 +103,11 @@ fn pdf_writes_registration_as_separation_all() { let mut s = session(); run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40})); run(&mut s, "object.createTrimMarks", json!({})); - let bytes = - vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }) - .unwrap(); + let bytes = vectorcraft_pdf::export( + &s.doc().unwrap().doc, + &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }, + ) + .unwrap(); let pdf = String::from_utf8_lossy(&bytes); assert!(pdf.contains("/Separation/All/DeviceCMYK"), "Registration prints on every plate"); } diff --git a/crates/engine/src/tests_strokegradient.rs b/crates/engine/src/tests_strokegradient.rs index fce22425..eb9b0335 100644 --- a/crates/engine/src/tests_strokegradient.rs +++ b/crates/engine/src/tests_strokegradient.rs @@ -133,7 +133,7 @@ fn svg_and_pdf_write_the_same_gradient_as_slices_with_a_warning() { assert!(svg.contains(" 3, "{mode}: sliced"); let back = vectorcraft_svg::import(&svg).unwrap(); assert_same(&before, &render(&back), 0.04, &format!("{mode} SVG")); - let opts = vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }; + let opts = vectorcraft_pdf::PdfOptions::uncompressed(); let r = vectorcraft_pdf::export_with_report(doc(&s), &opts).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("along or across strokes")), "{:?}", r.warnings); assert!(String::from_utf8_lossy(&r.bytes).matches("/ShadingType 2").count() > 3, "{mode}: axial shadings"); diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index 5b886278..d22bf5eb 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -57,7 +57,8 @@ fn grouped_spot_swatches_print_on_their_own_plate() { assert!(plates["plates"].as_array().unwrap().iter().any(|p| p["name"] == "Signal Orange" && p["spot"] == true), "{plates}"); let a = rect(&mut s); run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Signal Orange"})); - let bytes = vectorcraft_pdf::export(doc(&s), &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap(); + let bytes = + vectorcraft_pdf::export(doc(&s), &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap(); let pdf = String::from_utf8_lossy(&bytes); assert!(pdf.contains("/Separation") && pdf.contains("Signal#20Orange"), "a grouped spot swatch is a Separation"); let info = run(&mut s, "document.info", json!({})); diff --git a/crates/engine/src/tests_tints.rs b/crates/engine/src/tests_tints.rs index f977e985..caf99043 100644 --- a/crates/engine/src/tests_tints.rs +++ b/crates/engine/src/tests_tints.rs @@ -120,7 +120,7 @@ fn spot_tints_print_their_percentage_of_the_plate() { // A process colour linked to a global swatch separates into process inks. assert!(proof::inks(d, &c, &color, Some(("Brand", 0.4)), proof::Intent::RelativeColorimetric).spot.is_none()); // PDF: the Separation value is the tint (102 / 255 = 0.4). - let bytes = vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap(); + let bytes = vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap(); let pdf = String::from_utf8_lossy(&bytes); assert!(pdf.contains("/Separation/Ink/DeviceCMYK"), "a spot tint is a Separation"); assert!(pdf.contains("cs 0.4 scn"), "the tint is the Separation value"); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index aea0aa4c..738c3013 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -50,7 +50,7 @@ fn rich_svg() -> String { } fn rich_pdf() -> Vec { - vectorcraft_engine::export_pdf(&rich_doc(), &vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap() + vectorcraft_engine::export_pdf(&rich_doc(), &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap() } /// Numbers that tend to break arithmetic: zero, negatives, huge, tiny, exponents, junk. diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 540e7f33..cf018bce 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -23,6 +23,8 @@ hayro-syntax = "0.7.2" hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } log = { workspace = true } smallvec = "1" +serde = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 164e1255..ca6c069b 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -19,15 +19,18 @@ use vectorcraft_effects::stroke::{self, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; use crate::lab_spot::{find, rfind}; -use crate::{Compatibility, ExportReport, PdfError, PdfOptions}; +use crate::{Compatibility, ExportReport, PdfError, PdfOptions, Standard}; /// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`). pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { export_with_report(doc, opts).map(|r| r.bytes) } -/// Like [`export`], also returning warnings about approximated or dropped features. +/// Like [`export`], also returning warnings: options accepted but not applied yet (see +/// [`crate::PdfSettings::warnings`]) and features approximated or dropped. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { + let set = &opts.settings; + set.check()?; // Live geometry effects export as their result; raster effects are reported below. let baked = vectorcraft_effects::bake_document(doc); let doc = baked.as_ref().unwrap_or(doc); @@ -45,21 +48,20 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result (PdfVersion::Pdf14, None), - Compatibility::Pdf15 => (PdfVersion::Pdf15, None), - Compatibility::Pdf16 => (PdfVersion::Pdf16, None), - Compatibility::Pdf17 => (PdfVersion::Pdf17, None), - Compatibility::Pdf20 => (PdfVersion::Pdf20, None), - Compatibility::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)), - Compatibility::PdfX4 => return Err(PdfError::Unsupported("PDF/X-4 output is not supported by the PDF writer yet".into())), + let version = match set.compatibility { + Compatibility::Pdf14 => PdfVersion::Pdf14, + Compatibility::Pdf15 => PdfVersion::Pdf15, + Compatibility::Pdf16 => PdfVersion::Pdf16, + Compatibility::Pdf17 => PdfVersion::Pdf17, + Compatibility::Pdf20 => PdfVersion::Pdf20, }; let mut cb = ConfigurationBuilder::new().with_version(version); - if let Some(a) = archival { - cb = cb.with_archival_validator(a); + // `check` has refused the standards (and standard/version pairs) the writer can't produce. + if set.standard == Standard::PdfA2b { + cb = cb.with_archival_validator(Archival::A2_B); } let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; - let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() }; + let settings = krilla::SerializeSettings { compress_content_streams: set.compression.compress_text, configuration, ..Default::default() }; let mut pdf = krilla::Document::new_with(settings); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); @@ -108,7 +110,8 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { } /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect. - fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect]) { + /// Warn when `effects` has a raster effect: the app renders them to images before export + /// (`vectorcraft_engine::export_pdf`), so the ones still here (on `what`) can't be written. + fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect], what: impl FnOnce() -> String) { if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { - self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet"); + self.warn(format!("raster effects (shadows, glows, blur, feather) on {} are left out of the PDF", what())); } } @@ -583,7 +588,7 @@ impl Exporter<'_> { None if !n.is_container() => return, _ => {} } - self.warn_raster(&n.appearance.effects); + self.warn_raster(&n.appearance.effects, || format!("{} objects", n.kind_label().to_lowercase())); let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); // Whether this container's children knock each other out (a knockout group is written as a group). let knockout = n.knocks_out(self.knockout); @@ -678,7 +683,7 @@ impl Exporter<'_> { if !fl.visible || fl.paint.is_none() { continue; } - self.warn_raster(&fl.effects); + self.warn_raster(&fl.effects, || "fills".into()); // Pattern fills: the tile instances covering the shape, clipped to it. let doc = self.doc; if let Paint::Pattern { pattern, xf } = &fl.paint @@ -713,7 +718,7 @@ impl Exporter<'_> { if !st.visible || st.paint.is_none() || st.width <= 0.0 { continue; } - self.warn_raster(&st.effects); + self.warn_raster(&st.effects, || "strokes".into()); self.stroke(s, bp, &path, r, st, page, bounds); } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index bcdae790..7931df82 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -5,7 +5,9 @@ //! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes //! as their brush art), opacity and blend modes (transparency groups), //! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph -//! paths. Hidden objects, guides and template layers are skipped. +//! paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF +//! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output, +//! Advanced, Security); options the writer doesn't apply yet come back as warnings. //! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a //! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups, //! transparency groups, axial/radial shadings → gradients, images (JPEG passthrough, others @@ -15,49 +17,32 @@ mod export; mod import; mod lab_spot; +mod settings; pub use export::{export, export_with_report}; pub use import::{import, import_with_report}; +pub use settings::*; use vectorcraft_doc::Document; -/// PDF standard / version to target. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Compatibility { - /// PDF 1.4. - Pdf14, - /// PDF 1.5. - Pdf15, - /// PDF 1.6. - Pdf16, - /// PDF 1.7. The default. - #[default] - Pdf17, - /// PDF 2.0. - Pdf20, - /// PDF/A-2b (archival; validated by krilla). - PdfA2b, - /// PDF/X-4 — not supported by the writer yet; export returns [`PdfError::Unsupported`]. - PdfX4, -} - -/// Export options. -#[derive(Clone, Debug)] +/// Export options: the Save PDF settings plus what one export run decides (pages, title, date). +#[derive(Clone, Debug, Default)] pub struct PdfOptions { + pub settings: PdfSettings, /// 0-based artboard indices to export, in page order; `None` = all artboards. pub artboards: Option>, - pub compatibility: Compatibility, - /// Compress content streams (Flate). - pub compress: bool, /// Document title for the metadata; `None` = the document's title. pub title: Option, /// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date. pub created: Option, } -impl Default for PdfOptions { - fn default() -> Self { - Self { artboards: None, compatibility: Compatibility::Pdf17, compress: true, title: None, created: None } +impl PdfOptions { + /// Default options with uncompressed content streams (readable operators, for tests and + /// debugging). + pub fn uncompressed() -> Self { + let compression = CompressionSettings { compress_text: false, ..Default::default() }; + Self { settings: PdfSettings { compression, ..Default::default() }, ..Default::default() } } } @@ -104,6 +89,8 @@ pub enum PdfError { Parse(String), #[error("the PDF has no pages")] NoPages, + #[error("invalid PDF setting: {0}")] + BadSetting(String), } #[cfg(test)] @@ -121,6 +108,8 @@ mod tests_focal; #[cfg(test)] mod tests_fx; #[cfg(test)] +mod tests_settings; +#[cfg(test)] mod tests_stroke; #[cfg(test)] mod tests_strokeout; diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs new file mode 100644 index 00000000..4738f323 --- /dev/null +++ b/crates/pdf/src/settings.rs @@ -0,0 +1,459 @@ +//! Save PDF settings: the serde model a preset stores and `document.exportPdf` takes (camelCase +//! JSON; every field is optional and falls back to its default), the checks that refuse settings +//! the writer can't honour, and the warnings for options it accepts but doesn't apply yet. + +use serde::{Deserialize, Serialize}; + +use crate::PdfError; + +/// A closed list of choices (a settings enum), for UIs that show it as a dropdown. +pub trait Choice: Copy + 'static { + /// Every choice, in display order. + const ALL: &'static [Self]; + /// The JSON ids, in display order. + const IDS: &'static [&'static str]; + /// The labels shown in the Save PDF dialog, in display order. + const LABELS: &'static [&'static str]; +} + +/// A closed list of choices: a serde enum (its JSON ids) with a UI label per variant. +macro_rules! choice { + ($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $v:ident = $id:literal, $label:literal;)+ } default $d:ident) => { + $(#[$m])* + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum $name { + $($(#[$vm])* #[serde(rename = $id)] $v,)+ + } + + impl Choice for $name { + const ALL: &'static [Self] = &[$(Self::$v),+]; + const IDS: &'static [&'static str] = &[$($id),+]; + const LABELS: &'static [&'static str] = &[$($label),+]; + } + + impl $name { + /// The label shown in the Save PDF dialog. + pub fn label(self) -> &'static str { + match self { + $(Self::$v => $label,)+ + } + } + + /// The JSON id. + pub fn id(self) -> &'static str { + match self { + $(Self::$v => $id,)+ + } + } + } + + impl Default for $name { + fn default() -> Self { + Self::$d + } + } + }; +} + +choice! { + /// The PDF standard the file conforms to. + Standard { + None = "none", "None"; + /// PDF/A-2b (archival), validated by the writer. + PdfA2b = "pdfA2b", "PDF/A-2b"; + PdfX1a = "pdfX1a", "PDF/X-1a:2001"; + PdfX3 = "pdfX3", "PDF/X-3:2002"; + PdfX4 = "pdfX4", "PDF/X-4:2010"; + } default None +} + +impl Standard { + /// The writer can produce this standard (PDF/X is not supported yet). + pub fn supported(self) -> bool { + matches!(self, Self::None | Self::PdfA2b) + } + + /// A file of this standard can declare PDF version `c` (PDF/A-2 is based on PDF 1.7, so it + /// can't be PDF 2.0). + pub fn allows(self, c: Compatibility) -> bool { + !(self == Self::PdfA2b && c == Compatibility::Pdf20) + } +} + +choice! { + /// The PDF version written in the file header. + Compatibility { + Pdf14 = "1.4", "PDF 1.4"; + Pdf15 = "1.5", "PDF 1.5"; + Pdf16 = "1.6", "PDF 1.6"; + Pdf17 = "1.7", "PDF 1.7"; + Pdf20 = "2.0", "PDF 2.0"; + } default Pdf17 +} + +choice! { + /// How images above the threshold resolution are resampled. + Downsample { + None = "none", "No downsampling"; + Average = "average", "Average"; + Subsample = "subsample", "Subsample"; + Bicubic = "bicubic", "Bicubic"; + } default None +} + +choice! { + /// Compression of colour and greyscale images. + ImageCodec { + None = "none", "None"; + Zip = "zip", "ZIP"; + Jpeg = "jpeg", "JPEG"; + Jpeg2000 = "jpeg2000", "JPEG 2000"; + /// JPEG images stay JPEG, the others are compressed losslessly. + Auto = "auto", "Automatic"; + } default Auto +} + +choice! { + /// JPEG image quality. + JpegQuality { + Minimum = "minimum", "Minimum"; + Low = "low", "Low"; + Medium = "medium", "Medium"; + High = "high", "High"; + Maximum = "maximum", "Maximum"; + } default Maximum +} + +choice! { + /// Compression of 1-bit (monochrome) images. + MonoCodec { + None = "none", "None"; + CcittG3 = "ccittG3", "CCITT Group 3"; + CcittG4 = "ccittG4", "CCITT Group 4"; + Zip = "zip", "ZIP"; + RunLength = "runLength", "Run Length"; + } default Zip +} + +choice! { + /// The style of printer's marks. + MarkKind { + Roman = "roman", "Roman"; + Japanese = "japanese", "Japanese"; + } default Roman +} + +choice! { + /// Colour conversion on output. + ColorConversion { + None = "none", "No conversion"; + Destination = "destination", "Convert to destination"; + /// Convert only colours whose profile differs from the destination's. + PreserveNumbers = "preserveNumbers", "Convert to destination (preserve numbers)"; + } default None +} + +choice! { + /// Which ICC profiles the file embeds. + ProfileInclusion { + None = "none", "Don't include profiles"; + All = "all", "Include all profiles"; + Destination = "destination", "Include destination profile"; + TaggedSource = "taggedSource", "Include tagged source profiles"; + } default None +} + +choice! { + /// What happens to overprinting fills and strokes. + Overprint { + Preserve = "preserve", "Preserve"; + Discard = "discard", "Discard"; + } default Preserve +} + +choice! { + /// Printing allowed by the permissions. + Printing { + None = "none", "None"; + Low = "low", "Low resolution (150 ppi)"; + High = "high", "High resolution"; + } default High +} + +choice! { + /// Changes allowed by the permissions. + Changes { + None = "none", "None"; + Pages = "pages", "Inserting, deleting and rotating pages"; + Forms = "forms", "Filling in form fields and signing"; + Comments = "comments", "Commenting, filling in form fields and signing"; + Any = "any", "Any except extracting pages"; + } default Any +} + +/// Save PDF settings (the dialog's sections; what a preset stores). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PdfSettings { + pub standard: Standard, + pub compatibility: Compatibility, + // General. + /// Embed the native document so the PDF reopens editable. + pub preserve_editing: bool, + /// Embed a thumbnail image per page. + pub thumbnails: bool, + /// Write a linearised file that displays page by page while it downloads. + pub fast_web_view: bool, + /// The app opens the written file (frontends only; the writer ignores it). + pub view_after_saving: bool, + /// Write top-level layers as PDF layers (optional content). + pub create_layers: bool, + pub compression: CompressionSettings, + pub marks: MarkSettings, + pub bleed: BleedSettings, + pub output: OutputSettings, + pub advanced: AdvancedSettings, + pub security: SecuritySettings, +} + +/// Resampling and compression of one kind of image. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ImageCompression { + pub downsample: Downsample, + /// Target resolution (ppi). + pub ppi: f64, + /// Only images above this resolution (ppi) are downsampled. + pub above_ppi: f64, + pub compression: ImageCodec, + pub quality: JpegQuality, +} + +impl Default for ImageCompression { + fn default() -> Self { + Self { downsample: Downsample::None, ppi: 300.0, above_ppi: 450.0, compression: ImageCodec::Auto, quality: JpegQuality::Maximum } + } +} + +/// Resampling and compression of monochrome images. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MonoCompression { + pub downsample: Downsample, + pub ppi: f64, + pub above_ppi: f64, + pub compression: MonoCodec, +} + +impl Default for MonoCompression { + fn default() -> Self { + Self { downsample: Downsample::None, ppi: 1200.0, above_ppi: 1800.0, compression: MonoCodec::Zip } + } +} + +/// The Compression section. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CompressionSettings { + pub color: ImageCompression, + pub gray: ImageCompression, + pub mono: MonoCompression, + /// Compress text and line art (Flate content streams). + pub compress_text: bool, +} + +impl Default for CompressionSettings { + fn default() -> Self { + Self { color: ImageCompression::default(), gray: ImageCompression::default(), mono: MonoCompression::default(), compress_text: true } + } +} + +/// Printer's marks (Marks and Bleeds section). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MarkSettings { + pub trim: bool, + pub registration: bool, + pub color_bars: bool, + pub page_info: bool, + pub kind: MarkKind, + /// Trim mark weight (pt). + pub weight: f64, + /// Distance of the marks from the artboard (pt). + pub offset: f64, +} + +impl Default for MarkSettings { + fn default() -> Self { + Self { trim: false, registration: false, color_bars: false, page_info: false, kind: MarkKind::Roman, weight: 0.25, offset: 6.0 } + } +} + +impl MarkSettings { + /// Any mark is on. + pub fn any(&self) -> bool { + self.trim || self.registration || self.color_bars || self.page_info + } +} + +/// Bleed (Marks and Bleeds section), in points. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BleedSettings { + /// Use the document's bleed instead of the values below. + pub use_document: bool, + pub top: f64, + pub bottom: f64, + pub left: f64, + pub right: f64, +} + +impl BleedSettings { + fn values(&self) -> [f64; 4] { + [self.top, self.bottom, self.left, self.right] + } +} + +/// The Output section. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OutputSettings { + pub conversion: ColorConversion, + /// Destination profile name (empty: the document's profile). + pub destination: String, + pub profiles: ProfileInclusion, + /// PDF/X output intent profile name. + pub output_intent: String, + pub output_condition: String, + pub output_condition_id: String, + pub registry: String, + /// Mark the file as trapped. + pub trapped: bool, +} + +/// The Advanced section. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AdvancedSettings { + /// Embed whole fonts when more than this share (%) of their characters is used. + pub font_subset_percent: f64, + /// Text as glyph outlines (the only text the writer produces today). + pub outline_text: bool, + pub overprint: Overprint, +} + +impl Default for AdvancedSettings { + fn default() -> Self { + Self { font_subset_percent: 100.0, outline_text: true, overprint: Overprint::Preserve } + } +} + +/// The Security section. Passwords are never serialized (presets and summaries leave them out). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SecuritySettings { + #[serde(skip_serializing)] + pub open_password: String, + #[serde(skip_serializing)] + pub permissions_password: String, + pub printing: Printing, + pub changes: Changes, + /// Allow copying text, images and other content. + pub copy: bool, + /// Allow screen readers to read the text. + pub screen_reader: bool, + /// Leave the metadata unencrypted. + pub plaintext_metadata: bool, +} + +impl Default for SecuritySettings { + fn default() -> Self { + Self { + open_password: String::new(), + permissions_password: String::new(), + printing: Printing::High, + changes: Changes::Any, + copy: true, + screen_reader: true, + plaintext_metadata: true, + } + } +} + +/// `v` lies in `lo..=hi` (and is a number). +fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result<(), PdfError> { + if (lo..=hi).contains(&v) { Ok(()) } else { Err(PdfError::BadSetting(format!("{name} must be {lo}–{hi}{unit} (got {v})"))) } +} + +impl PdfSettings { + /// Refuse settings the writer can't honour: an unsupported standard, a standard with a PDF + /// version it doesn't allow, a password (the file would not be protected) or an out-of-range + /// value. + pub fn check(&self) -> Result<(), PdfError> { + if !self.standard.supported() { + return Err(PdfError::Unsupported(format!("{} output is not supported yet", self.standard.label()))); + } + if !self.standard.allows(self.compatibility) { + return Err(PdfError::BadSetting(format!("{} files can't be {}", self.standard.label(), self.compatibility.label()))); + } + if !self.security.open_password.is_empty() || !self.security.permissions_password.is_empty() { + return Err(PdfError::Unsupported("password protection is not supported yet (the PDF would not be encrypted)".into())); + } + let c = &self.compression; + for (name, img) in + [("color", (c.color.ppi, c.color.above_ppi)), ("gray", (c.gray.ppi, c.gray.above_ppi)), ("mono", (c.mono.ppi, c.mono.above_ppi))] + { + within(&format!("compression.{name}.ppi"), img.0, 9.0, 2400.0, " ppi")?; + within(&format!("compression.{name}.abovePpi"), img.1, 9.0, 2400.0, " ppi")?; + } + within("marks.weight", self.marks.weight, 0.05, 2.0, " pt")?; + within("marks.offset", self.marks.offset, 0.0, 72.0, " pt")?; + for (side, v) in ["top", "bottom", "left", "right"].iter().zip(self.bleed.values()) { + within(&format!("bleed.{side}"), v, 0.0, 72.0, " pt")?; + } + within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%") + } + + /// Options that are accepted but not applied by the writer yet, one warning each. + pub fn warnings(&self) -> Vec { + let d = Self::default(); + let c = &self.compression; + let o = &self.output; + let s = &self.security; + [ + (self.preserve_editing, "Preserve editing is not written yet: the PDF reopens as plain artwork"), + (self.thumbnails, "page thumbnails are not embedded yet"), + (self.fast_web_view, "fast web view (a linearised file) is not written yet"), + (self.create_layers, "PDF layers are not written yet: every layer is plain page content"), + ( + c.color != d.compression.color || c.gray != d.compression.gray || c.mono != d.compression.mono, + "image downsampling and compression settings are not applied yet: images are embedded unchanged", + ), + (self.marks.any(), "printer's marks are not drawn yet"), + (!self.bleed.use_document && self.bleed.values().iter().any(|v| *v > 0.0), "bleed is not added yet: each page is its artboard"), + (o.conversion != ColorConversion::None, "colour conversion is not applied yet: colours are written as they are"), + (o.profiles != ProfileInclusion::None, "ICC profiles are not embedded yet"), + ( + !o.output_intent.is_empty() + || !o.output_condition.is_empty() + || !o.output_condition_id.is_empty() + || !o.registry.is_empty() + || o.trapped, + "output intent and trapped entries are not written yet", + ), + (!self.advanced.outline_text, "text is exported as outlines: real, selectable text is not written yet"), + ( + s.printing != d.security.printing + || s.changes != d.security.changes + || s.copy != d.security.copy + || s.screen_reader != d.security.screen_reader + || s.plaintext_metadata != d.security.plaintext_metadata, + "permissions need a permissions password, which is not supported yet: they are not applied", + ), + ] + .into_iter() + .filter(|(on, _)| *on) + .map(|(_, w)| w.to_string()) + .collect() + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b7cb0732..c9850ae9 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -43,7 +43,7 @@ fn roundtrip(d: &Document) -> Document { } fn uncompressed(d: &Document) -> String { - let bytes = export(d, &PdfOptions { compress: false, ..Default::default() }).expect("export"); + let bytes = export(d, &PdfOptions::uncompressed()).expect("export"); String::from_utf8_lossy(&bytes).into_owned() } @@ -498,15 +498,29 @@ fn import_rejects_garbage() { assert!(matches!(import(b"not a pdf at all"), Err(PdfError::Parse(_)) | Err(PdfError::NoPages))); } +/// Options with `settings` changed by `f`. +fn with(f: impl FnOnce(&mut PdfSettings)) -> PdfOptions { + let mut o = PdfOptions::default(); + f(&mut o.settings); + o +} + #[test] fn compatibility_levels() { let mut d = doc(100.0, 100.0); add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::rgb(1.0, 0.0, 0.0))); - let b14 = export(&d, &PdfOptions { compatibility: Compatibility::Pdf14, ..Default::default() }).unwrap(); - assert!(b14.starts_with(b"%PDF-1.4")); - let a = export(&d, &PdfOptions { compatibility: Compatibility::PdfA2b, ..Default::default() }).unwrap(); + for (c, header) in [(Compatibility::Pdf14, "%PDF-1.4"), (Compatibility::Pdf16, "%PDF-1.6"), (Compatibility::Pdf20, "%PDF-2.0")] { + let b = export(&d, &with(|s| s.compatibility = c)).unwrap(); + assert!(b.starts_with(header.as_bytes()), "{c:?}"); + } + let a = export(&d, &with(|s| s.standard = Standard::PdfA2b)).unwrap(); assert!(String::from_utf8_lossy(&a).contains("pdfaid"), "PDF/A identification in XMP"); - assert!(matches!(export(&d, &PdfOptions { compatibility: Compatibility::PdfX4, ..Default::default() }), Err(PdfError::Unsupported(_)))); + for x in [Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] { + assert!(matches!(export(&d, &with(|s| s.standard = x)), Err(PdfError::Unsupported(_))), "{x:?}"); + } + // PDF/A-2b is a PDF 1.7 standard. + let e = export(&d, &with(|s| (s.standard, s.compatibility) = (Standard::PdfA2b, Compatibility::Pdf20))); + assert!(matches!(e, Err(PdfError::BadSetting(_))), "{e:?}"); } #[test] @@ -516,7 +530,7 @@ fn compress_option_changes_output() { add(&mut d, rect_node(Rect::new(i as f64, i as f64, 50.0 + i as f64, 50.0), Color::BLACK)); } let c = export(&d, &PdfOptions::default()).unwrap(); - let u = export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap(); + let u = export(&d, &PdfOptions::uncompressed()).unwrap(); assert!(u.len() > c.len()); assert!(String::from_utf8_lossy(&u).contains(" re") || String::from_utf8_lossy(&u).contains(" l")); } @@ -555,7 +569,7 @@ fn stroke_alignment_and_arrowheads_export() { fn creation_date_written() { let d = doc(100.0, 100.0); // 2024-02-29 12:34:56 UTC - let bytes = export(&d, &PdfOptions { created: Some(1_709_210_096), compress: false, ..Default::default() }).unwrap(); + let bytes = export(&d, &PdfOptions { created: Some(1_709_210_096), ..PdfOptions::uncompressed() }).unwrap(); assert!(String::from_utf8_lossy(&bytes).contains("D:20240229123456")); } @@ -566,7 +580,7 @@ fn opacity_mask_exports_as_luminosity_soft_mask() { let art = rect_node(Rect::new(10.0, 10.0, 50.0, 90.0), Color::WHITE); n.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); add(&mut d, n); - let bytes = export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap(); + let bytes = export(&d, &PdfOptions::uncompressed()).unwrap(); let text = String::from_utf8_lossy(&bytes); assert!(text.contains("/SMask"), "soft mask in an ExtGState"); assert!(text.contains("/Luminosity")); diff --git a/crates/pdf/src/tests_blend.rs b/crates/pdf/src/tests_blend.rs index dbafa475..346b4f34 100644 --- a/crates/pdf/src/tests_blend.rs +++ b/crates/pdf/src/tests_blend.rs @@ -18,7 +18,7 @@ fn add(d: &mut Document, mut n: Node) { /// The file's text with spaces removed (content streams uncompressed). fn text(d: &Document) -> String { - let bytes = export(d, &PdfOptions { compress: false, ..Default::default() }).unwrap(); + let bytes = export(d, &PdfOptions::uncompressed()).unwrap(); String::from_utf8_lossy(&bytes).replace(' ', "") } diff --git a/crates/pdf/src/tests_cmykblend.rs b/crates/pdf/src/tests_cmykblend.rs index a9c95bec..c450b7e0 100644 --- a/crates/pdf/src/tests_cmykblend.rs +++ b/crates/pdf/src/tests_cmykblend.rs @@ -37,7 +37,7 @@ fn doc(mode: ColorMode, grouped: bool) -> Document { /// The file's text with spaces removed (content streams uncompressed), after checking it reads. fn text(d: &Document) -> String { - let bytes = export(d, &PdfOptions { compress: false, ..Default::default() }).unwrap(); + let bytes = export(d, &PdfOptions::uncompressed()).unwrap(); import(&bytes).expect("the rewritten file still reads"); String::from_utf8_lossy(&bytes).replace(' ', "") } diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests_settings.rs new file mode 100644 index 00000000..a154f61d --- /dev/null +++ b/crates/pdf/src/tests_settings.rs @@ -0,0 +1,99 @@ +//! Save PDF settings: JSON shape, checks and the warnings for options not applied yet. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::*; + +fn settings(v: serde_json::Value) -> PdfSettings { + serde_json::from_value(v).unwrap() +} + +fn doc() -> Document { + let mut d = Document::new(100.0, 100.0); + let layer = d.default_layer().unwrap(); + let id = d.alloc_id(); + let n = Node::path(id, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + d.insert(Some(layer), 0, n).unwrap(); + d +} + +#[test] +fn json_is_camel_case_and_partial_objects_keep_defaults() { + let d = PdfSettings::default(); + let v = serde_json::to_value(&d).unwrap(); + assert_eq!(v["compatibility"], "1.7"); + assert_eq!(v["standard"], "none"); + assert_eq!(v["compression"]["compressText"], true); + assert_eq!(v["compression"]["color"]["abovePpi"], 450.0); + assert_eq!(v["advanced"]["overprint"], "preserve"); + assert_eq!(serde_json::from_value::(v).unwrap(), d, "round-trips"); + let s = settings(json!({"compatibility": "1.5", "marks": {"trim": true}, "compression": {"gray": {"downsample": "bicubic"}}})); + assert_eq!(s.compatibility, Compatibility::Pdf15); + assert!(s.marks.trim && s.marks.weight == 0.25); + assert_eq!(s.compression.gray.downsample, Downsample::Bicubic); + assert_eq!(s.compression.gray.ppi, 300.0); + assert!(s.compression.compress_text); + for bad in [json!({"compatibility": "1.3"}), json!({"standard": "pdfX9"}), json!({"marks": {"trim": "yes"}})] { + assert!(serde_json::from_value::(bad.clone()).is_err(), "{bad}"); + } +} + +#[test] +fn passwords_are_never_serialized() { + let s = settings(json!({"security": {"openPassword": "secret", "permissionsPassword": "owner"}})); + assert_eq!(s.security.open_password, "secret"); + let text = serde_json::to_string(&s).unwrap(); + assert!(!text.contains("secret") && !text.contains("owner") && !text.contains("Password"), "{text}"); +} + +#[test] +fn checks_refuse_what_the_writer_cant_honour() { + assert_eq!(PdfSettings::default().check(), Ok(())); + let refused = |v: serde_json::Value| settings(v).check().unwrap_err(); + assert!(matches!(refused(json!({"standard": "pdfX4"})), PdfError::Unsupported(_))); + assert!(matches!(refused(json!({"security": {"openPassword": "x"}})), PdfError::Unsupported(_))); + assert!(matches!(refused(json!({"security": {"permissionsPassword": "x"}})), PdfError::Unsupported(_))); + assert!(matches!(refused(json!({"compression": {"color": {"ppi": 5}}})), PdfError::BadSetting(m) if m.contains("compression.color.ppi"))); + assert!(matches!(refused(json!({"bleed": {"left": 100}})), PdfError::BadSetting(m) if m.contains("bleed.left"))); + assert!(matches!(refused(json!({"marks": {"weight": 0}})), PdfError::BadSetting(_))); + assert!(matches!(refused(json!({"advanced": {"fontSubsetPercent": 120}})), PdfError::BadSetting(_))); + // PDF/A-2 is a PDF 1.7 standard: earlier versions are fine, 2.0 is not. + assert!(matches!(refused(json!({"standard": "pdfA2b", "compatibility": "2.0"})), PdfError::BadSetting(m) if m.contains("PDF/A-2b"))); + assert_eq!(settings(json!({"standard": "pdfA2b", "compatibility": "1.4"})).check(), Ok(())); + assert!(Compatibility::ALL.iter().all(|c| Standard::None.allows(*c))); + assert!(export(&doc(), &PdfOptions { settings: settings(json!({"standard": "pdfX1a"})), ..Default::default() }).is_err()); +} + +#[test] +fn options_not_applied_yet_come_back_as_warnings() { + assert!(PdfSettings::default().warnings().is_empty()); + for (v, word) in [ + (json!({"preserveEditing": true}), "Preserve editing"), + (json!({"thumbnails": true}), "thumbnails"), + (json!({"fastWebView": true}), "fast web view"), + (json!({"createLayers": true}), "layers"), + (json!({"compression": {"color": {"compression": "jpeg"}}}), "image"), + (json!({"marks": {"registration": true}}), "marks"), + (json!({"bleed": {"top": 9}}), "bleed"), + (json!({"output": {"conversion": "destination"}}), "conversion"), + (json!({"output": {"profiles": "all"}}), "ICC"), + (json!({"output": {"trapped": true}}), "trapped"), + (json!({"advanced": {"outlineText": false}}), "outlines"), + (json!({"security": {"printing": "low"}}), "permissions"), + ] { + let w = settings(v.clone()).warnings(); + assert!(w.len() == 1 && w[0].contains(word), "{v}: {w:?}"); + } + // A document bleed, the default view/overprint choices and applied options warn about nothing. + for v in [json!({"bleed": {"useDocument": true, "top": 9}}), json!({"viewAfterSaving": true}), json!({"compression": {"compressText": false}})] { + assert!(settings(v.clone()).warnings().is_empty(), "{v}"); + } + let r = + export_with_report(&doc(), &PdfOptions { settings: settings(json!({"thumbnails": true, "marks": {"trim": true}})), ..Default::default() }) + .unwrap(); + assert_eq!(r.warnings.len(), 2, "{:?}", r.warnings); + assert!(r.bytes.starts_with(b"%PDF-1.7")); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 462cd177..178f3939 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -31,6 +31,7 @@ mod path_ops; pub mod recolor; pub mod saturate; mod save_changes; +mod save_pdf; pub mod save_style_library; pub mod save_swatch_library; mod shapes; @@ -46,6 +47,7 @@ use serde_json::{Value, json}; pub use color_picker::open as open_color_picker; pub use effect::open as open_effect_dialog; +pub use save_pdf::open as open_save_pdf; pub use tools::open_tool_dialog; use crate::state::Dialog; @@ -170,6 +172,7 @@ registry! { SpotColors: [spot_colors::KIND] => spot_colors::SPEC, TransformEach: [transform_each::KIND] => transform_each::SPEC, WidthPoint: [width_point::KIND] => width_point::SPEC, + SavePdf: [save_pdf::KIND] => save_pdf::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs new file mode 100644 index 00000000..2c9dd607 --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -0,0 +1,668 @@ +//! File → Save as PDF: the Save PDF dialog. Preset, Standard and Compatibility on top, then the +//! sections General, Compression, Marks and Bleeds, Output, Advanced, Security and Summary. +//! +//! The fields are the `document.exportPdf` options (sections are objects such as `compression`), +//! plus `preset`, `range`, `path?` and UI-only `__` keys, so agents fill the dialog with +//! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is +//! `document.pdfSettings`. + +use std::sync::LazyLock; + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_engine::cmd::fileio::pdf; +use vectorcraft_pdf::{ + Changes, Choice, ColorConversion, Compatibility, Downsample, ImageCodec, JpegQuality, MarkKind, MonoCodec, Overprint, PdfSettings, Printing, + ProfileInclusion, Standard, +}; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, io, widgets}; + +pub(super) const KIND: &str = "savePdf"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Save PDF".into(), body, confirm, ok: Some("Save PDF"), ..DialogSpec::FORM }; + +const SECTIONS: [&str; 7] = ["General", "Compression", "Marks and Bleeds", "Output", "Advanced", "Security", "Summary"]; + +/// The default settings as JSON: what a field an agent left out reads as. +static DEFAULTS: LazyLock = LazyLock::new(|| serde_json::to_value(PdfSettings::default()).unwrap_or_default()); + +/// Trim mark weights offered (pt). +const WEIGHTS: [f64; 3] = [0.125, 0.25, 0.5]; +const WEIGHT_LABELS: [&str; 3] = ["0.125 pt", "0.25 pt", "0.5 pt"]; + +/// The Destination entry for the document's own profile (stored as ""). +const DOCUMENT_PROFILE: &str = "Document profile"; + +const LABEL_WIDTH: f32 = 150.0; +/// Width of the section list (its frame adds 6 px a side). +const LIST_WIDTH: f32 = 150.0; +/// The top rows' labels end where the section content starts, so their dropdowns line up with it. +const TOP_LABEL_WIDTH: f32 = LIST_WIDTH + 26.0; + +/// Open the dialog with `params` (`document.exportPdf` options and `path?`) applied over their +/// preset. +pub fn open(app: &mut VectorcraftApp, params: &Value) -> Result { + let mut fields = settings_fields(params)?; + let s = |k: &str| params.get(k).and_then(Value::as_str); + fields.insert("preset".into(), json!(s("preset").unwrap_or(pdf::DEFAULT_PRESET))); + fields.insert("__presets".into(), json!(pdf::presets())); + fields.insert("__section".into(), json!(SECTIONS[0])); + fields.insert("__allArtboards".into(), json!(s("range").is_none())); + fields.insert("range".into(), json!(s("range").unwrap_or(""))); + if let Some(path) = s("path") { + fields.insert("path".into(), json!(path)); + } + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + Ok(Value::Null) +} + +/// The settings `params` ask for (their preset with their options applied) as dialog fields. +fn settings_fields(params: &Value) -> Result, String> { + let settings = pdf::settings("ui.savePdfDialog", params).map_err(|e| e.to_string())?; + match serde_json::to_value(settings).map_err(|e| e.to_string())? { + Value::Object(fields) => Ok(fields), + _ => Err("PDF settings are not an object".into()), + } +} + +/// The `document.exportPdf` params the dialog stands for. +fn params(d: &Dialog) -> Value { + let mut p = form::params(d); + if (d.bool("__allArtboards") || d.str("range").trim().is_empty()) + && let Some(o) = p.as_object_mut() + { + o.remove("range"); + } + p +} + +/// Save PDF: write the file; the dialog stays open when that fails (bad range, cancelled save…). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let r = io::export_pdf(app, params(d)); + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +// ---------- fields by path (`compression.color.ppi`) ---------- + +fn lookup<'a>(m: &'a Map, path: &str) -> Option<&'a Value> { + let mut keys = path.split('.'); + let first = m.get(keys.next()?)?; + keys.try_fold(first, |v, k| v.get(k)) +} + +/// The value at `path`: the dialog's, else the default. +fn get<'a>(d: &'a Dialog, path: &str) -> &'a Value { + lookup(&d.fields, path).or_else(|| DEFAULTS.as_object().and_then(|m| lookup(m, path))).unwrap_or(&Value::Null) +} + +fn set(d: &mut Dialog, path: &str, value: Value) { + let (parents, key) = path.rsplit_once('.').unwrap_or(("", path)); + let mut map = &mut d.fields; + for k in parents.split('.').filter(|k| !k.is_empty()) { + let e = map.entry(k).or_insert(Value::Null); + if !e.is_object() { + *e = Value::Object(Map::new()); + } + let Value::Object(m) = e else { return }; + map = m; + } + map.insert(key.into(), value); +} + +// ---------- widgets bound to a path ---------- + +fn heading(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(6.0); + ui.label(egui::RichText::new(text).font(theme::semibold(12.5)).color(t.text_strong)); + ui.add_space(2.0); +} + +fn note(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).size(11.5).color(t.text_dim)); +} + +/// A labelled row. +fn row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { + row_with(ui, label, LABEL_WIDTH, add); +} + +fn row_with(ui: &mut egui::Ui, label: &str, width: f32, add: impl FnOnce(&mut egui::Ui)) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(egui::vec2(width, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(width); + ui.label(egui::RichText::new(label).color(t.text)); + }); + add(ui); + }); +} + +fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &str, label: &str, enabled: bool) { + let on = get(d, path).as_bool().unwrap_or(false); + if widgets::check(ui, label, on, enabled) { + set(d, path, json!(!on)); + } +} + +/// The index in `T`'s choices of the value at `path`. +fn choice_index(d: &Dialog, path: &str) -> Option { + get(d, path).as_str().and_then(|s| T::IDS.iter().position(|i| *i == s)) +} + +/// The choice at `path`. +fn choice(d: &Dialog, path: &str) -> Option { + choice_index::(d, path).and_then(|i| T::ALL.get(i).copied()) +} + +/// A dropdown of the choices `T` (options for which `enabled` is false are greyed). +fn pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, width: f32, enabled: impl Fn(T) -> bool) -> bool { + let current = choice_index::(d, path); + let label = current.and_then(|i| T::LABELS.get(i)).copied().unwrap_or_default(); + let chosen = widgets::dropdown_with(ui, path, label, T::LABELS, width, |i| T::ALL.get(i).is_some_and(|c| enabled(*c))); + let Some(id) = chosen.and_then(|i| T::IDS.get(i)) else { return false }; + set(d, path, json!(id)); + true +} + +fn number(ui: &mut egui::Ui, d: &mut Dialog, path: &str, suffix: &str, enabled: bool) { + let v = get(d, path).as_f64().unwrap_or(0.0); + ui.add_enabled_ui(enabled, |ui| { + if let Some(x) = widgets::plain_field(ui, path, v, suffix, 3, 64.0) { + set(d, path, json!(x)); + } + }); +} + +/// A length in points, shown in the document's units. +fn length(ui: &mut egui::Ui, d: &mut Dialog, path: &str, unit: Unit, enabled: bool) { + let v = get(d, path).as_f64(); + ui.add_enabled_ui(enabled, |ui| { + if let Some(x) = widgets::num_field(ui, path, v, unit, 80.0) { + set(d, path, json!(x)); + } + }); +} + +fn text(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) { + let mut s = get(d, path).as_str().unwrap_or_default().to_string(); + if ui.add_enabled(enabled, egui::TextEdit::singleline(&mut s).desired_width(240.0)).changed() { + set(d, path, json!(s)); + } +} + +// ---------- the dialog ---------- + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + row_with(ui, "Preset:", TOP_LABEL_WIDTH, |ui| { + let presets: Vec<&str> = + d.fields.get("__presets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + let current = d.str("preset"); + let chosen = widgets::dropdown(ui, "pdf-preset", ¤t, &presets, 300.0).and_then(|i| presets.get(i)).map(|p| p.to_string()); + if let Some(name) = chosen { + apply_preset(app, d, &name); + } + }); + row_with(ui, "Standard:", TOP_LABEL_WIDTH, |ui| { + let picked = pick::(ui, d, "standard", 170.0, Standard::supported); + let standard = choice::(d, "standard").unwrap_or_default(); + if picked && choice::(d, "compatibility").is_some_and(|c| !standard.allows(c)) { + // The standard's own version (PDF/A-2b is a PDF 1.7 standard). + set(d, "compatibility", json!(Compatibility::Pdf17.id())); + } + ui.add_space(16.0); + ui.label(egui::RichText::new("Compatibility:").color(t.text)); + pick::(ui, d, "compatibility", 110.0, |c| standard.allows(c)); + }); + ui.add_space(10.0); + let section = SECTIONS.iter().copied().find(|s| *s == d.str("__section")).unwrap_or(SECTIONS[0]); + ui.horizontal_top(|ui| { + egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| { + ui.set_width(LIST_WIDTH); + ui.set_min_height(380.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 1.0; + for s in SECTIONS { + let sel = s == section; + let label = egui::RichText::new(s).size(12.5).color(if sel { t.text_strong } else { t.text }); + if ui.add(egui::Button::selectable(sel, label).frame_when_inactive(false).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() + { + d.fields.insert("__section".into(), json!(s)); + } + } + }); + }); + ui.add_space(14.0); + ui.vertical(|ui| { + ui.set_width(500.0); + ui.label(egui::RichText::new(section).font(theme::semibold(14.0)).color(t.text_strong)); + egui::ScrollArea::vertical().id_salt(("save-pdf", section)).max_height(360.0).auto_shrink([false, false]).show(ui, |ui| match section { + "Compression" => compression(ui, d), + "Marks and Bleeds" => marks_and_bleeds(app, ui, d), + "Output" => output(ui, d), + "Advanced" => advanced(ui, d), + "Security" => security(ui, d), + "Summary" => summary(app, ui, d), + _ => general(ui, d), + }); + }); + }); + false +} + +/// Replace the settings with preset `name`'s (the artboard choice and path stay). +fn apply_preset(app: &mut VectorcraftApp, d: &mut Dialog, name: &str) { + match settings_fields(&json!({ "preset": name })) { + Ok(settings) => { + d.fields.extend(settings); + d.fields.insert("preset".into(), json!(name)); + } + Err(e) => app.status(e), + } +} + +fn general(ui: &mut egui::Ui, d: &mut Dialog) { + heading(ui, "Options"); + flag(ui, d, "preserveEditing", "Preserve editing capabilities", true); + flag(ui, d, "thumbnails", "Embed page thumbnails", true); + flag(ui, d, "fastWebView", "Optimize for fast web view", true); + flag(ui, d, "viewAfterSaving", "View PDF after saving", true); + flag(ui, d, "createLayers", "Create PDF layers from top-level layers", true); + heading(ui, "Artboards"); + let mut all = d.bool("__allArtboards"); + ui.horizontal(|ui| { + let changed = ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed(); + if changed { + d.fields.insert("__allArtboards".into(), json!(all)); + } + let mut range = d.str("range"); + if ui.add_enabled(!all, egui::TextEdit::singleline(&mut range).desired_width(140.0).hint_text("1-3, 5")).changed() { + d.fields.insert("range".into(), json!(range)); + } + }); +} + +fn image_rows(ui: &mut egui::Ui, d: &mut Dialog, key: &str, title: &str) { + heading(ui, title); + let base = format!("compression.{key}"); + let on = get(d, &format!("{base}.downsample")) != Downsample::None.id(); + ui.horizontal(|ui| { + pick::(ui, d, &format!("{base}.downsample"), 150.0, |_| true); + ui.label("to"); + number(ui, d, &format!("{base}.ppi"), " ppi", on); + ui.label("above"); + number(ui, d, &format!("{base}.abovePpi"), " ppi", on); + }); + ui.horizontal(|ui| { + ui.label("Compression:"); + if key == "mono" { + pick::(ui, d, &format!("{base}.compression"), 150.0, |_| true); + } else { + pick::(ui, d, &format!("{base}.compression"), 120.0, |_| true); + let lossy = get(d, &format!("{base}.compression")).as_str().is_some_and(|c| c != ImageCodec::None.id() && c != ImageCodec::Zip.id()); + ui.label("Quality:"); + ui.add_enabled_ui(lossy, |ui| { + pick::(ui, d, &format!("{base}.quality"), 110.0, |_| true); + }); + } + }); +} + +fn compression(ui: &mut egui::Ui, d: &mut Dialog) { + image_rows(ui, d, "color", "Color images"); + image_rows(ui, d, "gray", "Grayscale images"); + image_rows(ui, d, "mono", "Monochrome images"); + ui.add_space(8.0); + flag(ui, d, "compression.compressText", "Compress text and line art", true); +} + +fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let unit = app.session.active().map_or(Unit::Points, |s| s.doc.units); + const MARKS: [(&str, &str); 4] = [ + ("marks.trim", "Trim marks"), + ("marks.registration", "Registration marks"), + ("marks.colorBars", "Color bars"), + ("marks.pageInfo", "Page information"), + ]; + heading(ui, "Marks"); + let all = MARKS.iter().all(|(p, _)| get(d, p).as_bool() == Some(true)); + if widgets::check(ui, "All printer's marks", all, true) { + for (p, _) in MARKS { + set(d, p, json!(!all)); + } + } + egui::Grid::new("pdf-marks").num_columns(2).spacing([24.0, 4.0]).show(ui, |ui| { + for pair in MARKS.chunks(2) { + for (p, label) in pair { + flag(ui, d, p, label, true); + } + ui.end_row(); + } + }); + row(ui, "Printer mark type:", |ui| { + pick::(ui, d, "marks.kind", 130.0, |_| true); + }); + row(ui, "Trim mark weight:", |ui| { + let w = get(d, "marks.weight").as_f64().unwrap_or(0.25); + let current = + WEIGHTS.iter().zip(WEIGHT_LABELS).find(|(x, _)| (*x - w).abs() < 1e-9).map_or_else(|| format!("{w} pt"), |(_, l)| l.to_string()); + if let Some(x) = widgets::dropdown(ui, "marks.weight", ¤t, &WEIGHT_LABELS, 130.0).and_then(|i| WEIGHTS.get(i)) { + set(d, "marks.weight", json!(x)); + } + }); + row(ui, "Offset:", |ui| length(ui, d, "marks.offset", unit, true)); + heading(ui, "Bleeds"); + flag(ui, d, "bleed.useDocument", "Use document bleed settings", true); + let custom = get(d, "bleed.useDocument").as_bool() != Some(true); + egui::Grid::new("pdf-bleed").num_columns(4).spacing([10.0, 6.0]).show(ui, |ui| { + for pair in [[("bleed.top", "Top:"), ("bleed.bottom", "Bottom:")], [("bleed.left", "Left:"), ("bleed.right", "Right:")]] { + for (p, label) in pair { + ui.label(label); + length(ui, d, p, unit, custom); + } + ui.end_row(); + } + }); +} + +fn output(ui: &mut egui::Ui, d: &mut Dialog) { + heading(ui, "Color"); + row(ui, "Color conversion:", |ui| { + pick::(ui, d, "output.conversion", 300.0, |_| true); + }); + let converting = get(d, "output.conversion") != ColorConversion::None.id(); + row(ui, "Destination:", |ui| { + let profiles = vectorcraft_color::cms::profiles(); + let names: Vec<&str> = std::iter::once(DOCUMENT_PROFILE).chain(profiles.iter().map(|p| p.name.as_str())).collect(); + let current = get(d, "output.destination").as_str().filter(|s| !s.is_empty()).unwrap_or(DOCUMENT_PROFILE).to_string(); + ui.add_enabled_ui(converting, |ui| { + if let Some(i) = widgets::dropdown(ui, "output.destination", ¤t, &names, 300.0) { + set(d, "output.destination", json!(if i == 0 { "" } else { names.get(i).copied().unwrap_or_default() })); + } + }); + }); + row(ui, "Profile inclusion:", |ui| { + pick::(ui, d, "output.profiles", 300.0, |_| true); + }); + heading(ui, "PDF/X"); + let pdfx = matches!(choice::(d, "standard"), Some(Standard::PdfX1a | Standard::PdfX3 | Standard::PdfX4)); + for (p, label) in [ + ("output.outputIntent", "Output intent profile:"), + ("output.outputCondition", "Output condition:"), + ("output.outputConditionId", "Condition identifier:"), + ("output.registry", "Registry name:"), + ] { + row(ui, label, |ui| text(ui, d, p, pdfx)); + } + flag(ui, d, "output.trapped", "Mark as trapped", pdfx); + if !pdfx { + note(ui, "These apply with a PDF/X standard."); + } +} + +fn advanced(ui: &mut egui::Ui, d: &mut Dialog) { + heading(ui, "Fonts"); + let outline = get(d, "advanced.outlineText").as_bool() == Some(true); + row(ui, "Subset fonts below:", |ui| { + number(ui, d, "advanced.fontSubsetPercent", "%", !outline); + ui.label("of characters used"); + }); + flag(ui, d, "advanced.outlineText", "Convert text to outlines", true); + heading(ui, "Overprint"); + row(ui, "Overprint:", |ui| { + pick::(ui, d, "advanced.overprint", 130.0, |_| true); + }); +} + +fn security(ui: &mut egui::Ui, d: &mut Dialog) { + note(ui, "Password protection is not available yet, so these options are off."); + heading(ui, "Document open password"); + widgets::check(ui, "Require a password to open the document", false, false); + row(ui, "Password:", |ui| { + ui.add_enabled(false, egui::TextEdit::singleline(&mut String::new()).password(true).desired_width(200.0)); + }); + heading(ui, "Permissions"); + widgets::check(ui, "Restrict printing, editing and other tasks", false, false); + row(ui, "Permissions password:", |ui| { + ui.add_enabled(false, egui::TextEdit::singleline(&mut String::new()).password(true).desired_width(200.0)); + }); + ui.add_enabled_ui(false, |ui| { + row(ui, "Printing allowed:", |ui| { + pick::(ui, d, "security.printing", 260.0, |_| true); + }); + row(ui, "Changes allowed:", |ui| { + pick::(ui, d, "security.changes", 260.0, |_| true); + }); + }); + flag(ui, d, "security.copy", "Enable copying of text, images and other content", false); + flag(ui, d, "security.screenReader", "Enable text access for screen readers", false); + flag(ui, d, "security.plaintextMetadata", "Enable plaintext metadata", false); +} + +/// The section a changed option belongs to (for the Summary's order). +fn section_of(option: &str) -> usize { + match option.split('.').next().unwrap_or_default() { + "compression" => 1, + "marks" | "bleed" => 2, + "output" => 3, + "advanced" => 4, + "security" => 5, + _ => 0, + } +} + +/// `compression.color.abovePpi` → "Compression › Color › Above Ppi": the section, then the keys +/// (without the section's own object), `thumbnails` → "General › Thumbnails". +fn option_label(option: &str) -> String { + let section = SECTIONS.get(section_of(option)).copied().unwrap_or(SECTIONS[0]); + let mut keys = option.split('.').peekable(); + keys.next_if(|k| k.eq_ignore_ascii_case(section)); + std::iter::once(section.to_string()).chain(keys.map(|k| form::humanize(k).trim_end_matches(':').to_string())).collect::>().join(" › ") +} + +/// `document.pdfSettings` for the dialog with the document's warnings (an export in memory), +/// cached until a field or the document changes. +fn summary_of(app: &mut VectorcraftApp, ctx: &egui::Context, p: &Value) -> Result { + let revision = app.session.active().map_or(0, |s| s.revision); + let stamp = egui::Id::new((p.to_string(), revision)); + let key = egui::Id::new("save-pdf-summary"); + if let Some((s, v)) = ctx.data(|m| m.get_temp::<(egui::Id, Result)>(key)) + && s == stamp + { + return v; + } + let mut q = p.clone(); + q["includeDocument"] = json!(true); + let v = app.session.execute("document.pdfSettings", &q).map_err(|e| e.to_string()); + ctx.data_mut(|m| m.insert_temp(key, (stamp, v.clone()))); + v +} + +fn summary(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let v = match summary_of(app, ui.ctx(), ¶ms(d)) { + Ok(v) => v, + Err(e) => { + heading(ui, "Error"); + ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text)); + return; + } + }; + heading(ui, "Options"); + let mut changed: Vec<&Value> = v["changed"].as_array().map(|a| a.iter().collect()).unwrap_or_default(); + changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default())); + if changed.is_empty() { + note(ui, "Every option is at its default."); + } + for c in changed { + let value = match &c["value"] { + Value::Bool(b) => if *b { "On" } else { "Off" }.to_string(), + Value::String(s) => s.clone(), + other => other.to_string(), + }; + ui.label(egui::RichText::new(format!("{}: {value}", option_label(c["option"].as_str().unwrap_or_default()))).color(t.text)); + } + heading(ui, "Warnings"); + let warnings = v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default(); + if warnings.is_empty() { + note(ui, "None."); + } + for w in warnings { + ui.label(egui::RichText::new(format!("⚠ {}", w.as_str().unwrap_or_default())).color(t.text)); + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use vectorcraft_engine::Session; + + use super::*; + use crate::Services; + + type Log = Rc)>>>; + + /// An app with two artboards whose writer and URL opener record what they get. + fn app() -> (VectorcraftApp, Log, Log) { + let (written, opened) = (Log::default(), Log::default()); + let (w, o) = (written.clone(), opened.clone()); + let services = Services { + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + open_url: Some(Box::new(move |u: &str| o.borrow_mut().push((u.to_string(), vec![])))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 120, "height": 90, "artboards": 3})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + (app, written, opened) + } + + /// One headless frame of the dialog layer. + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn set_field(app: &mut VectorcraftApp, path: &str, v: Value) { + set(app.ui.dialog.as_mut().expect("dialog open"), path, v); + } + + #[test] + fn every_section_draws() { + let (mut app, _, _) = app(); + app.run("file.export.pdf", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + assert_eq!(super::super::DialogKind::of(KIND), Some(super::super::DialogKind::SavePdf)); + for s in SECTIONS { + set_field(&mut app, "__section", json!(s)); + frame(&mut app); + assert!(app.ui.dialog.is_some(), "{s} closed the dialog"); + } + // The Summary lists what changed and the warnings for options not applied yet. + set_field(&mut app, "thumbnails", json!(true)); + let p = params(app.ui.dialog.as_ref().unwrap()); + let v = summary_of(&mut app, &egui::Context::default(), &p).unwrap(); + assert_eq!(v["changed"][0]["option"], "thumbnails"); + assert!(v["warnings"][0].as_str().unwrap().contains("thumbnails")); + } + + #[test] + fn confirm_runs_export_pdf_with_the_dialog_options() { + let (mut app, written, opened) = app(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/out.pdf", "compatibility": "1.5"})).unwrap(); + set_field(&mut app, "__allArtboards", json!(false)); + set_field(&mut app, "range", json!("1,3")); + set_field(&mut app, "compression.compressText", json!(false)); + set_field(&mut app, "viewAfterSaving", json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "closes after saving"); + assert_eq!(r["path"], "/tmp/out.pdf"); + assert!(r.get("dataBase64").is_none()); + let w = written.borrow(); + assert_eq!(w.len(), 1); + assert_eq!(w[0].0, "/tmp/out.pdf"); + assert!(w[0].1.starts_with(b"%PDF-1.5"), "compatibility reaches the writer"); + assert_eq!(vectorcraft_pdf::import(&w[0].1).unwrap().artboards.len(), 2, "range 1,3"); + assert!(!String::from_utf8_lossy(&w[0].1).contains("/FlateDecode"), "uncompressed content"); + assert_eq!(opened.borrow().len(), 1, "View PDF after Saving opens the file once"); + let url = &opened.borrow()[0].0; + assert!(url.starts_with("file:///") && url.ends_with("/tmp/out.pdf"), "the written file as a URL: {url}"); + assert!(app.ui.status.starts_with("Saved /tmp/out.pdf"), "{}", app.ui.status); + } + + #[test] + fn warnings_reach_the_status_and_errors_keep_the_dialog_open() { + let (mut app, written, opened) = app(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/w.pdf"})).unwrap(); + set_field(&mut app, "marks.trim", json!(true)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.status.contains("1 note(s)") && app.ui.status.contains("marks"), "{}", app.ui.status); + assert!(opened.borrow().is_empty(), "not opened unless asked"); + app.run("ui.savePdfDialog", json!({"path": "/tmp/bad.pdf"})).unwrap(); + set_field(&mut app, "__allArtboards", json!(false)); + set_field(&mut app, "range", json!("7")); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some(), "a bad range keeps the dialog open"); + assert_eq!(written.borrow().len(), 1); + // Without a path, bad options are refused before a save dialog asks for one. + let asked = Rc::new(RefCell::new(0)); + let a = asked.clone(); + app.services.pick_save = Some(Box::new(move |name: &str| { + *a.borrow_mut() += 1; + Some(format!("/tmp/picked-{name}")) + })); + app.run("ui.savePdfDialog", json!({"range": "7"})).unwrap(); + assert!(super::super::confirm(&mut app).is_err()); + assert_eq!(*asked.borrow(), 0, "no save dialog for a bad range"); + set_field(&mut app, "range", json!("2")); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(*asked.borrow(), 1); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with("/tmp/picked-") && p.ends_with(".pdf")), "{r}"); + // Agents can export without the dialog. + let r = app.run("file.export.pdf", json!({"path": "/tmp/direct.pdf", "range": "2"})).unwrap(); + assert_eq!(r["path"], "/tmp/direct.pdf"); + assert!(r["warnings"].is_array()); + } + + #[test] + fn presets_reset_the_settings_and_fields_fall_back_to_defaults() { + let (mut app, _, _) = app(); + app.run("ui.savePdfDialog", json!({"compatibility": "1.4", "marks": {"trim": true}})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(get(d, "compatibility"), "1.4"); + assert_eq!(get(d, "marks.weight"), 0.25, "the rest of a section keeps its defaults"); + d.fields.remove("compression"); + assert_eq!(get(d, "compression.color.ppi"), 300.0, "a field an agent dropped reads as its default"); + let mut d = d.clone(); + apply_preset(&mut app, &mut d, pdf::DEFAULT_PRESET); + assert_eq!(get(&d, "compatibility"), "1.7"); + assert_eq!(get(&d, "marks.trim"), false); + assert!(app.run("ui.savePdfDialog", json!({"compatibility": "1.0"})).is_err(), "bad options are refused"); + } + + #[test] + fn option_labels_read_well() { + assert_eq!(option_label("compression.color.abovePpi"), "Compression › Color › Above Ppi"); + assert_eq!(option_label("compression.compressText"), "Compression › Compress Text"); + assert_eq!(option_label("thumbnails"), "General › Thumbnails"); + assert_eq!(option_label("bleed.top"), "Marks and Bleeds › Bleed › Top"); + assert_eq!(section_of("bleed.top"), 2); + assert_eq!(SECTIONS[section_of("standard")], "General"); + } +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 673bbe36..50eaf85c 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -120,24 +120,79 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option Result { - let path = target_path(app, params.get("path").and_then(Value::as_str).map(str::to_string), ext)?; - if let Some(o) = params.as_object_mut() { - o.remove("path"); - if o.get("format").is_none() && id == "document.exportSelection" { - let e = Some(fileio::extension(&path)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into()); - o.insert("format".into(), serde_json::json!(e)); - } - } - let v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; - // Binary output comes as base64, text (swatch libraries) as is. - let bytes = v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode).or_else(|| v["data"].as_str().map(|t| t.as_bytes().to_vec())); - let bytes = bytes.ok_or("no data")?; - write_out(app, &path, &bytes)?; +pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result { + let (path, _) = run_to_file(app, id, ext, params)?; app.status(format!("Saved {path}")); Ok(path) } +/// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to +/// that path (else a picked or suggested name) → (path, the command's result without the data). +/// The command runs before a path is asked for, so bad params never open a save dialog, except +/// for Export Selection without a `format`, which takes it from the picked path. +fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> { + let mut path = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string)); + if let Some(o) = params.as_object_mut() + && o.get("format").is_none() + && id == "document.exportSelection" + { + let picked = target_path(app, path, ext)?; + let e = Some(fileio::extension(&picked)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into()); + o.insert("format".into(), serde_json::json!(e)); + path = Some(picked); + } + let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; + // Binary output comes as base64, text (swatch libraries) as is; the result keeps the rest. + let o = v.as_object_mut().ok_or("no data")?; + let bytes = match (o.remove("dataBase64"), o.remove("data")) { + (Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64), + (_, Some(Value::String(text))) => Some(text.into_bytes()), + _ => None, + }; + let bytes = bytes.ok_or("no data")?; + let path = match path { + Some(p) => p, + None => target_path(app, None, ext)?, + }; + write_out(app, &path, &bytes)?; + Ok((path, v)) +} + +/// File → Save as PDF: `document.exportPdf` with `params` (the Save PDF dialog's options), written +/// to `path` (else a picked or suggested name) → `{path, bytes, warnings}`. With +/// `viewAfterSaving`, the written file opens in the system viewer (not on the web, which +/// downloads it). +pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result { + let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); + let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?; + if view && app.services.download.is_none() { + app.open_url(&file_url(&path)); + } + let warnings: Vec<&str> = v["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + app.status(match warnings.first() { + Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()), + None => format!("Saved {path}"), + }); + v["path"] = Value::String(path); + Ok(v) +} + +/// `path` as an absolute `file://` URL for the system opener (bytes other than letters, digits and +/// `/-._~:` percent-encoded). +fn file_url(path: &str) -> String { + let abs = std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()); + let abs = abs.replace('\\', "/"); + let mut url = String::from(if abs.starts_with('/') { "file://" } else { "file:///" }); + for b in abs.bytes() { + if b.is_ascii_alphanumeric() || b"/-._~:".contains(&b) { + url.push(char::from(b)); + } else { + url.push_str(&format!("%{b:02X}")); + } + } + url +} + /// File → Place… (embed an image or SVG into the active document). pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { let f = fileio::detect(name, bytes).ok_or_else(|| format!("can't place `{name}`: not a format VectorCraft reads"))?; @@ -269,6 +324,26 @@ mod tests { assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("exportForScreens")); } + #[test] + fn export_selection_takes_its_format_from_the_picked_path() { + let (mut app, written) = app(); + app.session.execute("file.new", &json!({"width": 60, "height": 40})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); + app.services.pick_save = Some(Box::new(|_: &str| Some("/tmp/sel.svg".into()))); + assert_eq!(app.run("document.exportSelection", json!({})).unwrap()["path"], "/tmp/sel.svg"); + app.run("document.exportSelection", json!({"path": "/tmp/sel.png"})).unwrap(); + let w = written.borrow(); + assert!(String::from_utf8_lossy(&w[0].1).contains(" Option app.run("effect.apply", json!({"effect": e, "params": params})), None => Err("no effect applied yet".into()), }, - "file.export.pdf" => io::export(app, Some("pdf"), s("path"), p).map(|p| json!({"path": p})), + "file.export.pdf" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::open_save_pdf(app, &json!({})), + "file.export.pdf" => io::export_pdf(app, p.clone()), "help.about" => { app.ui.about = true; Ok(Value::Null) @@ -721,6 +733,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::width_point::open(app, p), "ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p), + "ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p), _ => return None, }; Some(r) @@ -896,7 +909,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { } "effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), - "file.export.pdf" => app.session.active().is_some(), + "file.export.pdf" | "ui.savePdfDialog" => app.session.active().is_some(), "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), "ui.graphicStyleOptions" => app.session.active().is_some(), "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d2bf33f1..eba2a74d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -378,10 +378,22 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool /// A compact dropdown. Returns the chosen index. pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { + dropdown_with(ui, id, current, options, width, |_| true) +} + +/// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable). +pub fn dropdown_with( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + current: &str, + options: &[&str], + width: f32, + enabled: impl Fn(usize) -> bool, +) -> Option { combo(ui, id, current, width, |ui| { let mut chosen = None; for (i, o) in options.iter().enumerate() { - if ui.selectable_label(*o == current, *o).clicked() { + if ui.add_enabled(enabled(i), egui::Button::selectable(*o == current, *o)).clicked() { chosen = Some(i); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7334defb..acbe4645 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -20,6 +20,7 @@ | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | +| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch (fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object, diff --git a/docs/mcp.md b/docs/mcp.md index 7f48b721..4174d2bf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -148,8 +148,16 @@ Long-tail commands: {"name":"run_command","arguments":{"command":"select.all"}} {"name":"run_command","arguments":{"command":"object.align","params":{"align":"left"}}} {"name":"run_command","arguments":{"command":"object.group"}} +{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/art.pdf","range":"1, 3","compatibility":"1.5"}}} +{"name":"run_command","arguments":{"command":"document.pdfSettings","params":{"marks":{"trim":true},"includeDocument":true}}} ``` +PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibility`, the General toggles and the +`compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf` +documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`: +options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused. +`document.pdfSettings` lists the options that differ from the defaults and the warnings without writing a file. + Drive a tool like a mouse: ```json