diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index c636fb9bc..c36d7e03a 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -101,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)?; @@ -112,7 +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" => super::pdf::encode(C, doc, p)?.0, "png" | "jpg" | "webp" => { let o: RasterOptions = options(f, p)?; let region = doc.artboards[boards(o.boards.one(n))?].rect; @@ -128,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 566dd4cf1..642f94655 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 c8bf32df2..2a5e50440 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -19,7 +19,7 @@ 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::*; @@ -50,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 ), @@ -59,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), …the PDF options of document.exportPdf} → {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 ), diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index a1f50de64..0128ff284 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -1,6 +1,6 @@ //! PDF export settings for every PDF path (`document.export {format: pdf}`, Export for Screens, -//! `document.exportPdf` and the Save PDF dialog): presets, the options parsed over them and the -//! warnings that come back. +//! `document.exportPdf` and the Save PDF dialog): presets, the options parsed over them, the +//! warnings that come back, and `document.pdfSettings` (the dialog's Summary). use serde::Deserialize; use serde_json::{Value, json}; @@ -33,15 +33,26 @@ pub(super) const OPTIONS: &[FormatOption] = &[ ]; pub fn specs() -> 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?, 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 - )] + 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). @@ -117,3 +128,31 @@ 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 index 3bd5abbdb..2156a2946 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -1,4 +1,4 @@ -//! `document.exportPdf` and the PDF options of every PDF export. +//! `document.exportPdf`, `document.pdfSettings` and the PDF options of every PDF export. use serde_json::{Value, json}; @@ -51,6 +51,7 @@ fn export_pdf_writes_the_compatibility_header_and_the_range() { // 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")); } @@ -86,15 +87,15 @@ fn options_apply_over_the_preset() { 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 = export(&mut s, p); - assert!(b64(&v).starts_with(b"%PDF-1.7")); - assert!(warnings(&v).is_empty(), "{v}"); + 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.exportPdf", &json!({"standard": "pdfA2b", "compatibility": "2.0"})).unwrap_err(); + 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")); @@ -106,7 +107,7 @@ fn options_not_applied_yet_and_document_features_warn() { 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. + // 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(); @@ -115,6 +116,9 @@ fn options_not_applied_yet_and_document_features_warn() { 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] @@ -133,6 +137,33 @@ fn export_pdf_writes_a_path() { 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(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 9d9ac13c2..ca6c069b6 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -391,9 +391,11 @@ impl Exporter<'_> { } /// 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())); } } @@ -586,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); @@ -681,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 @@ -716,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/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 277242519..2c9dd607a 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -1,9 +1,10 @@ //! File → Save as PDF: the Save PDF dialog. Preset, Standard and Compatibility on top, then the -//! sections General, Compression, Marks and Bleeds, Output, Advanced and Security. +//! 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`]. +//! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is +//! `document.pdfSettings`. use std::sync::LazyLock; @@ -24,7 +25,7 @@ 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; 6] = ["General", "Compression", "Marks and Bleeds", "Output", "Advanced", "Security"]; +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()); @@ -33,6 +34,9 @@ static DEFAULTS: LazyLock = LazyLock::new(|| serde_json::to_value(PdfSett 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; @@ -156,20 +160,17 @@ fn choice_index(d: &Dialog, path: &str) -> Option { /// The choice at `path`. fn choice(d: &Dialog, path: &str) -> Option { - choice_index::(d, path).map(|i| T::ALL[i]) + 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.map_or("", |i| T::LABELS[i]); - match widgets::dropdown_with(ui, path, label, T::LABELS, width, |i| enabled(T::ALL[i])) { - Some(i) => { - set(d, path, json!(T::IDS[i])); - true - } - None => false, - } + 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) { @@ -206,7 +207,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { 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).map(|i| presets[i].to_string()); + 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); } @@ -250,6 +251,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { "Output" => output(ui, d), "Advanced" => advanced(ui, d), "Security" => security(ui, d), + "Summary" => summary(app, ui, d), _ => general(ui, d), }); }); @@ -351,9 +353,10 @@ fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { }); row(ui, "Trim mark weight:", |ui| { let w = get(d, "marks.weight").as_f64().unwrap_or(0.25); - let current = WEIGHTS.iter().position(|x| (x - w).abs() < 1e-9).map_or_else(|| format!("{w} pt"), |i| WEIGHT_LABELS[i].to_string()); - if let Some(i) = widgets::dropdown(ui, "marks.weight", ¤t, &WEIGHT_LABELS, 130.0) { - set(d, "marks.weight", json!(WEIGHTS[i])); + 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)); @@ -379,11 +382,11 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog) { 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(names[0]).to_string(); + 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[i] })); + set(d, "output.destination", json!(if i == 0 { "" } else { names.get(i).copied().unwrap_or_default() })); } }); }); @@ -445,6 +448,79 @@ fn security(ui: &mut egui::Ui, d: &mut Dialog) { 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; @@ -457,21 +533,22 @@ mod tests { type Log = Rc)>>>; - /// An app with three artboards whose writer records what it gets. - fn app() -> (VectorcraftApp, Log) { - let written = Log::default(); - let w = written.clone(); + /// 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) + (app, written, opened) } /// One headless frame of the dialog layer. @@ -488,7 +565,7 @@ mod tests { #[test] fn every_section_draws() { - let (mut app, _) = app(); + 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)); @@ -497,15 +574,22 @@ mod tests { 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) = app(); + 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"); @@ -516,16 +600,20 @@ mod tests { 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!(app.ui.status, "Saved /tmp/out.pdf"); + 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 errors_keep_the_dialog_open_and_agents_export_directly() { - let (mut app, written) = app(); + 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)); - let r = super::super::confirm(&mut app).unwrap(); - assert!(r["warnings"][0].as_str().unwrap().contains("marks"), "options not applied yet warn: {r}"); + 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")); @@ -554,7 +642,7 @@ mod tests { #[test] fn presets_reset_the_settings_and_fields_fall_back_to_defaults() { - let (mut app, _) = app(); + 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"); @@ -567,4 +655,14 @@ mod tests { 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 fa3c87930..50eaf85c5 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -159,14 +159,40 @@ fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) } /// 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}`. +/// 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)?; - app.status(format!("Saved {path}")); + 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"))?; @@ -311,6 +337,13 @@ mod tests { assert_eq!(&w[1].1[1..4], b"PNG", "PNG from the given path"); } + #[test] + fn written_files_open_as_file_urls() { + let url = super::file_url("/tmp/My Art #1.pdf"); + assert!(url.starts_with("file:///") && url.ends_with("/tmp/My%20Art%20%231.pdf"), "{url}"); + assert!(!url.contains('\\'), "{url}"); + } + #[test] fn control_export_without_path_returns_bytes() { let (mut app, written) = app(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 346eb48ff..78ffd7632 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -169,7 +169,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.export.pdf", "Save as PDF…", "", - "{} opens the Save PDF dialog; with params = document.exportPdf options written to path (asked when missing) → {path, bytes, warnings}", + "{} opens the Save PDF dialog; with params = document.exportPdf options written to path (asked when missing; viewAfterSaving opens the file) → {path, bytes, warnings}", ), ("help.about", "About VectorCraft", "", "{}"), ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d8ba8cf34..acbe46458 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -20,7 +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). `ui.dialog.confirm` writes `path` (else asks for one) and returns `{path, bytes, warnings}` | +| `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 c29e2f4e2..4174d2bf1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -149,12 +149,14 @@ Long-tail commands: {"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); `export` with format `pdf` takes them in `options`. `document.exportPdf` returns `warnings`: +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: