M4.31: Save PDF dialog and document.exportPdf with PDF settings

PdfSettings (serde, camelCase) is the Save PDF model: Standard, Compatibility
PDF 1.4-2.0, General (Preserve editing, Thumbnails, Fast web view, View after
saving, Create layers), Compression, Marks and Bleeds, Output, Advanced and
Security. PdfOptions = settings + pages, title and date.

document.exportPdf {path?, preset?, artboard?|artboards?|range?, standard?,
compatibility?, ...sections} -> {path|dataBase64, bytes, warnings} applies the
options over the preset (null keeps the preset's value); PDF/X, passwords,
PDF/A-2b at 2.0 and out-of-range values are refused; options accepted but not
applied yet come back as warnings. document.export {format: pdf} and
document.formats take and list the same options.

The Save PDF dialog (file.export.pdf, ui.savePdfDialog) has Preset,
Standard and Compatibility on top and a section list; it runs the export
before asking for a path, so bad options never open a save dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 07:22:27 +07:00
1 parent b0a8d78926
commit 4304a7c0b1
31 files changed
+1586 -102

No files matched your search

Generated
+2
View File
@@ -4217,6 +4217,8 @@ dependencies = [
"krilla",
"kurbo 0.13.1",
"log",
"serde",
"serde_json",
"smallvec",
"thiserror 2.0.21",
"vectorcraft-brush",
+1 -6
View File
@@ -8,7 +8,6 @@ use vectorcraft_doc::Document;
use super::super::*;
use super::Format;
use crate::EngineError;
const C: &str = "document.export";
@@ -113,11 +112,7 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
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()))?
}
"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;
+7 -12
View File
@@ -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,6 +14,7 @@ mod batch;
mod encode;
mod export;
mod load;
pub mod pdf;
mod save;
use serde_json::{Value, json};
@@ -57,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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}; no path → {dataBase64, format, bytes}. Never changes the document's path",
has_doc,
export::export
),
@@ -105,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
#[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<Val
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_pdf;
+119
View File
@@ -0,0 +1,119 @@
//! 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.
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfSettings};
use super::super::*;
use super::{ArtboardPick, FormatOption, write_or_return};
use crate::EngineError;
const C: &str = "document.exportPdf";
/// The built-in preset every PDF export starts from.
pub const DEFAULT_PRESET: &str = "VectorCraft Default";
/// The PDF options `document.formats` lists; `document.exportPdf` documents every field.
pub(super) const OPTIONS: &[FormatOption] = &[
super::ARTBOARD,
super::ARTBOARDS,
super::RANGE,
FormatOption { name: "preset", ty: "string", default: "\"VectorCraft Default\"", description: "the PDF preset the other options apply over" },
FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" },
FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" },
FormatOption { name: "compression", ty: "object", default: "null", description: "{color, gray, mono, compressText} (see document.exportPdf)" },
FormatOption { name: "marks", ty: "object", default: "null", description: "printer's marks (see document.exportPdf)" },
FormatOption { name: "bleed", ty: "object", default: "null", description: "{useDocument, top, bottom, left, right} in points" },
FormatOption { name: "output", ty: "object", default: "null", description: "colour conversion and output intent (see document.exportPdf)" },
FormatOption { name: "advanced", ty: "object", default: "null", description: "{fontSubsetPercent, outlineText, overprint}" },
FormatOption { name: "security", ty: "object", default: "null", description: "passwords (not supported yet) and permissions" },
];
pub fn specs() -> Vec<CommandSpec> {
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
)]
}
/// Every preset name (built-in first).
pub fn presets() -> Vec<String> {
vec![DEFAULT_PRESET.to_string()]
}
fn preset(cmd: &str, name: &str) -> Result<PdfSettings> {
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<PdfSettings> {
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<PdfOptions> {
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<u8>, Vec<String>)> {
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<Value> {
let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?;
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
}
+146
View File
@@ -0,0 +1,146 @@
//! `document.exportPdf` and the PDF options of every PDF export.
use serde_json::{Value, json};
use super::pdf::DEFAULT_PRESET;
use super::*;
fn session(artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": artboards})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
s
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
fn export(s: &mut Session, p: Value) -> Value {
s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"))
}
fn warnings(v: &Value) -> Vec<String> {
v["warnings"].as_array().expect("warnings").iter().map(|w| w.as_str().unwrap().to_string()).collect()
}
fn pages(bytes: &[u8]) -> usize {
vectorcraft_pdf::import(bytes).unwrap().artboards.len()
}
/// `p` plus `format: pdf` (the same options through `document.export`).
fn as_export(p: &Value) -> Value {
let mut o = p.as_object().cloned().unwrap_or_default();
o.insert("format".into(), json!("pdf"));
Value::Object(o)
}
#[test]
fn export_pdf_writes_the_compatibility_header_and_the_range() {
let mut s = session(3);
let v = export(&mut s, json!({}));
let pdf = b64(&v);
assert!(pdf.starts_with(b"%PDF-1.7"));
assert_eq!(pages(&pdf), 3);
assert_eq!(v["bytes"], pdf.len());
assert!(warnings(&v).is_empty(), "{v}");
assert!(b64(&export(&mut s, json!({"compatibility": "1.5"}))).starts_with(b"%PDF-1.5"));
assert!(b64(&export(&mut s, json!({"compatibility": "2.0"}))).starts_with(b"%PDF-2.0"));
assert_eq!(pages(&b64(&export(&mut s, json!({"range": "1,3"})))), 2);
assert_eq!(pages(&b64(&export(&mut s, json!({"artboards": [1]})))), 1);
// The same options through document.export and document.serialize.
let v = s.execute("document.export", &as_export(&json!({"compatibility": "1.4", "range": "2"}))).unwrap();
assert!(b64(&v).starts_with(b"%PDF-1.4") && pages(&b64(&v)) == 1);
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 = export(&mut s, p);
assert!(b64(&v).starts_with(b"%PDF-1.7"));
assert!(warnings(&v).is_empty(), "{v}");
}
#[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();
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.
let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [tile]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let frame = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setStroke", &json!({"ids": [frame], "swatch": "Dots"})).unwrap();
let pattern = |w: Vec<String>| w.iter().any(|w| w.contains("pattern strokes"));
assert!(pattern(warnings(&export(&mut s, json!({})))));
}
#[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 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);
}
+1
View File
@@ -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
})
}
+11 -2
View File
@@ -181,9 +181,18 @@ pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
/// PDF bytes for `doc`, with raster effects rendered as images.
pub fn export_pdf(doc: &Document, opts: &vectorcraft_pdf::PdfOptions) -> std::result::Result<Vec<u8>, 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<vectorcraft_pdf::ExportReport, vectorcraft_pdf::PdfError> {
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),
}
}
+2 -1
View File
@@ -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()
}
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -149,7 +149,7 @@ fn spot_options_and_lab_colours_round_trip() {
}
fn pdf_bytes(s: &Session) -> Vec<u8> {
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()
}
+1 -1
View File
@@ -120,7 +120,7 @@ fn spot_stops_separate_on_their_plate() {
}
fn pdf_of(s: &Session) -> (String, Vec<String>) {
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)
}
+5 -3
View File
@@ -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");
}
+1 -1
View File
@@ -133,7 +133,7 @@ fn svg_and_pdf_write_the_same_gradient_as_slices_with_a_warning() {
assert!(svg.contains("<clipPath") && svg.matches("<linearGradient").count() > 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");
+2 -1
View File
@@ -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!({}));
+1 -1
View File
@@ -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");
+1 -1
View File
@@ -50,7 +50,7 @@ fn rich_svg() -> String {
}
fn rich_pdf() -> Vec<u8> {
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.
+2
View File
@@ -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 }
+17 -14
View File
@@ -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<Vec<u8>, 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<ExportReport, PdfError> {
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<ExportRep
return Err(PdfError::BadArtboard(*bad));
}
let (version, archival) = match opts.compatibility {
Compatibility::Pdf14 => (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<ExportRep
cmyk_blending(&mut bytes);
}
let bytes = if ex.lab_spots.is_empty() { bytes } else { crate::lab_spot::lab_alternates(bytes, &ex.lab_spots) };
let mut warnings = ex.warnings;
let mut warnings = set.warnings();
warnings.extend(ex.warnings);
warnings.dedup();
Ok(ExportReport { bytes, warnings })
}
+18 -29
View File
@@ -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<Vec<usize>>,
pub compatibility: Compatibility,
/// Compress content streams (Flate).
pub compress: bool,
/// Document title for the metadata; `None` = the document's title.
pub title: Option<String>,
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date.
pub created: Option<i64>,
}
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;
+459
View File
@@ -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<String> {
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()
}
}
+22 -8
View File
@@ -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"));
+1 -1
View File
@@ -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(' ', "")
}
+1 -1
View File
@@ -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(' ', "")
}
+99
View File
@@ -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::<PdfSettings>(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::<PdfSettings>(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"));
}
+3
View File
@@ -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).
+570
View File
@@ -0,0 +1,570 @@
//! 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.
//!
//! 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`].
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; 6] = ["General", "Compression", "Marks and Bleeds", "Output", "Advanced", "Security"];
/// The default settings as JSON: what a field an agent left out reads as.
static DEFAULTS: LazyLock<Value> = 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"];
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<Value, String> {
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<Map<String, Value>, 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<Value, String> {
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<String, Value>, 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<T: Choice>(d: &Dialog, path: &str) -> Option<usize> {
get(d, path).as_str().and_then(|s| T::IDS.iter().position(|i| *i == s))
}
/// The choice at `path`.
fn choice<T: Choice>(d: &Dialog, path: &str) -> Option<T> {
choice_index::<T>(d, path).map(|i| T::ALL[i])
}
/// A dropdown of the choices `T` (options for which `enabled` is false are greyed).
fn pick<T: Choice>(ui: &mut egui::Ui, d: &mut Dialog, path: &str, width: f32, enabled: impl Fn(T) -> bool) -> bool {
let current = choice_index::<T>(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,
}
}
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", &current, &presets, 300.0).map(|i| presets[i].to_string());
if let Some(name) = chosen {
apply_preset(app, d, &name);
}
});
row_with(ui, "Standard:", TOP_LABEL_WIDTH, |ui| {
let picked = pick::<Standard>(ui, d, "standard", 170.0, Standard::supported);
let standard = choice::<Standard>(d, "standard").unwrap_or_default();
if picked && choice::<Compatibility>(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::<Compatibility>(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),
_ => 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::<Downsample>(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::<MonoCodec>(ui, d, &format!("{base}.compression"), 150.0, |_| true);
} else {
pick::<ImageCodec>(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::<JpegQuality>(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::<MarkKind>(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().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", &current, &WEIGHT_LABELS, 130.0) {
set(d, "marks.weight", json!(WEIGHTS[i]));
}
});
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::<ColorConversion>(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(names[0]).to_string();
ui.add_enabled_ui(converting, |ui| {
if let Some(i) = widgets::dropdown(ui, "output.destination", &current, &names, 300.0) {
set(d, "output.destination", json!(if i == 0 { "" } else { names[i] }));
}
});
});
row(ui, "Profile inclusion:", |ui| {
pick::<ProfileInclusion>(ui, d, "output.profiles", 300.0, |_| true);
});
heading(ui, "PDF/X");
let pdfx = matches!(choice::<Standard>(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::<Overprint>(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::<Printing>(ui, d, "security.printing", 260.0, |_| true);
});
row(ui, "Changes allowed:", |ui| {
pick::<Changes>(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);
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use vectorcraft_engine::Session;
use super::*;
use crate::Services;
type Log = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app with three artboards whose writer records what it gets.
fn app() -> (VectorcraftApp, Log) {
let written = Log::default();
let w = written.clone();
let services = Services {
write: Some(Box::new(move |p: &str, b: &[u8]| {
w.borrow_mut().push((p.to_string(), b.to_vec()));
Ok(())
})),
..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)
}
/// 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");
}
}
#[test]
fn confirm_runs_export_pdf_with_the_dialog_options() {
let (mut app, written) = 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));
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!(app.ui.status, "Saved /tmp/out.pdf");
}
#[test]
fn errors_keep_the_dialog_open_and_agents_export_directly() {
let (mut app, written) = 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}");
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");
}
}
+56 -14
View File
@@ -120,24 +120,53 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path
/// (Export Selection, Save as Template).
pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<String, String> {
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, &params).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<String, String> {
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, &params).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}`.
pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?;
app.status(format!("Saved {path}"));
v["path"] = Value::String(path);
Ok(v)
}
/// 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 +298,19 @@ 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("<svg"), "SVG from the picked name");
assert_eq!(&w[1].1[1..4], b"PNG", "PNG from the given path");
}
#[test]
fn control_export_without_path_returns_bytes() {
let (mut app, written) = app();
+16 -3
View File
@@ -165,7 +165,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name, or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)",
),
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
("file.export.pdf", "Save as PDF…", "", "{path?, artboard? | artboards? | range?: \"1-3, 5\"} (document.export options)"),
(
"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}",
),
("help.about", "About VectorCraft", "", "{}"),
("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"),
("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"),
@@ -350,6 +355,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{library: swatch library id or name (see swatch.library.list) | \"document\" (the document's swatches) | \"\" or null (no limit)} limit the Color Guide panel's colours to that library: every harmony colour and variation snaps to its nearest colour, as color.harmony {limitTo} answers (ui.inspect: ui.color_guide_limit) → {limitTo, name}",
),
(
"ui.savePdfDialog",
"Save PDF Dialog",
"",
"{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@@ -634,7 +645,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Some((e, params)) => 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<R
},
"ui.widthPointEdit" => 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()),
+13 -1
View File
@@ -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<usize> {
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<usize> {
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);
}
}
+1
View File
@@ -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). `ui.dialog.confirm` writes `path` (else asks for one) and returns `{path, bytes, warnings}` |
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,
+6
View File
@@ -148,8 +148,14 @@ 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"}}}
```
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`:
options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused.
Drive a tool like a mouse:
```json